From 70cc481ae04b04111b28f775e90d19e388541835 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 11 Nov 2026 08:59:20 -0800 Subject: [PATCH 01/27] docs: link the Celery headers guide by its main URL in the 8.0 guide The migration guide is also published on posthog.com, where the relative link resolves to a missing page. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 32b45cf39..1f1bd8e09 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -214,7 +214,7 @@ Capture v1 sends processing options in an `options` object, next to `properties` - `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. - The Celery integration propagates context options to workers, like tags. - A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. - - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)`, as described in [references/middleware_configuration.md](../references/middleware_configuration.md). + - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)`, as described in [references/middleware_configuration.md](https://github.com/PostHog/posthog-python/blob/main/references/middleware_configuration.md). - Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. - Options are sent as given. PostHog validates them. It drops an event with an option value it cannot read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. - An `options` value that is not a dict is logged as an error and ignored. The event is still sent. From 540bdb807f2ee37300051545bb55711db2954489 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 11 Nov 2026 15:23:33 -0800 Subject: [PATCH 02/27] docs: describe Celery eager headers without an external link Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 1f1bd8e09..1c180ce9e 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -214,7 +214,7 @@ Capture v1 sends processing options in an `options` object, next to `properties` - `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. - The Celery integration propagates context options to workers, like tags. - A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. - - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)`, as described in [references/middleware_configuration.md](https://github.com/PostHog/posthog-python/blob/main/references/middleware_configuration.md). + - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)` or `task.apply_async(headers=...)`. - Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. - Options are sent as given. PostHog validates them. It drops an event with an option value it cannot read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. - An `options` value that is not a dict is logged as an error and ignored. The event is still sent. From 07d32c51649d3c844d7d3fdae07de2cefe89ea68 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 11:48:38 -0500 Subject: [PATCH 03/27] docs: sort the 8.0.0 migration guide by when you make each change Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 672 +++++++++++++++++++++-------------- 1 file changed, 397 insertions(+), 275 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 1c180ce9e..aac0fe284 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -1,38 +1,57 @@ -# Migrating from posthog 7.x to 8.0 +# Migrate the Python SDK from 7.x.x to 8.0.0 -posthog 8.0 sends every event through capture v1 and removes the legacy capture path. -Most apps upgrade without code changes. -Read the checklist first, then the sections that apply to you. +The Python SDK 8.x.x sends every event to the [capture v1](https://posthog.com/docs/api/capture-v1) endpoints, `/i/v1/analytics/events` and `/i/v1/ai/events`. + +Use the top right copy page button to work through this with your agents. -For the changes every capture v1 SDK shares, such as event options, limits and per-event results, see [Upgrading to capture v1 SDKs](https://posthog.com/docs/libraries/capture-v1). +You don't need to make every change on the day you upgrade your SDK. Required changes fall in these categories: + +- **Before you upgrade**: you should make these changes before you upgrade your SDK to 8.x.x. +- **During your upgrade**: These are changes that you need to make in the same PR that upgrades your SDK version. +- **Follow ups**: These are changes that can come in follow up PRs because 8.x.x has limited backward compatibility for methods that are to be deprecated. ## Checklist -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 `CaptureMode`, `posthog.capture_mode`, `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 -- sends events through a proxy that redirects capture requests to another host -- reuses one event `uuid` for more than one event -- expects `super_properties` to override properties passed to a single call, including `$set` and `$groups` -- sets `$is_server`, `$geoip_disable` or a system property such as `$os` and expects the SDK's value to win -- has a `before_send` hook that returns a new dict: copy `options`, or the event is sent without them, including the `process_person_profile=False` the SDK sets for events without a distinct ID (see [Event options](#event-options)). The same applies to MCP's `before_send` (see [MCP analytics](#mcp-analytics)) -- captures `$ai_` events with `capture()`: send them with `capture_ai()` -- tests the AI integrations with a mock client and asserts on `capture` -- passes its own client object to the AI integrations -- sets `$lib` or `$lib_version` in a call's `properties`, a context tag or `super_properties` -- filters insights or dashboards on `$lib = posthog-python-mcp` -- passes a personal API key (`phx_...`) as the project API key -- reads event content from the SDK's debug log -- calls `alias()` without a `distinct_id` and relies on the context distinct ID -- turns person processing off with `super_options`, `super_properties`, a context option or a context tag, and calls `alias`, `group_identify`, `set` or `set_once` (see [Identity and request context](#identity-and-request-context)) -- runs the Django, Flask or ASGI integration on 7.68 to 7.70 without tracing headers turned on (they are now read by default) - -## Endpoints +Read the checklist first, then the sections that apply to you. + +**Before you upgrade** + +- [ ] You send events through your own proxy → [Prepare your proxy](#prepare-your-proxy) +- [ ] A proxy redirects capture requests to another host → [Prepare your proxy](#prepare-your-proxy) +- [ ] You pass a personal API key (`phx_...`) as the project token → [Use a project token](#use-a-project-token) +- [ ] You use `openfeature-provider-posthog` below 0.2.0 → [Update OpenFeature](#update-openfeature) + +**During your upgrade** + +- [ ] You pass `Client` or `AsyncPosthog` constructor arguments by position after `host` → [Fix removed APIs](#fix-removed-apis) +- [ ] You call `posthog.request.post` or `posthog.request.flags` with positional arguments after `path` or `host` → [Fix removed APIs](#fix-removed-apis) +- [ ] You set `capture_mode`, `POSTHOG_CAPTURE_MODE`, or `gzip` → [Fix removed APIs](#fix-removed-apis) +- [ ] You import `CaptureMode`, `posthog.capture_mode`, `CaptureV1Error`, `posthog.capture_v1`, `request.batch_post`, `EVENTS_ENDPOINT`, or `AI_EVENTS_ENDPOINT` → [Fix removed APIs](#fix-removed-apis) +- [ ] Your `before_send` hook returns a new dict, in the client or in MCP → [Copy options in before_send](#copy-options-in-before_send) +- [ ] You test the AI integrations with a mock client and assert on `capture` → [Route AI events](#route-ai-events) +- [ ] You pass your own client object to the AI integrations → [Route AI events](#route-ai-events) +- [ ] You call `alias()` without a `distinct_id` and rely on the context distinct ID → [Fix identity calls](#fix-identity-calls) +- [ ] You turn person processing off in bulk and call `alias`, `group_identify`, `set`, or `set_once` → [Fix identity calls](#fix-identity-calls) +- [ ] You reuse one event `uuid` for more than one event → [Check event IDs](#check-event-ids) +- [ ] You expect `super_properties` to override a call's properties, including `$set` and `$groups` → [Check property precedence](#check-property-precedence) +- [ ] You set `$is_server`, `$geoip_disable`, or a system property such as `$os`, and expect the SDK's value to win → [Check property precedence](#check-property-precedence) +- [ ] You set `$lib` or `$lib_version` yourself → [Check SDK identity](#check-sdk-identity) +- [ ] An insight or dashboard filters on `$lib = posthog-python-mcp` → [Check SDK identity](#check-sdk-identity) +- [ ] You read event content from the SDK's debug log, or alert on SDK log lines → [Update error handling](#update-error-handling) +- [ ] You set `$exception_level` or another reserved exception property in `capture_exception(properties=...)` → [Check exceptions](#check-exceptions) +- [ ] You run the Django, Flask, or ASGI integration on 7.68.x to 7.70.x without tracing headers turned on → [Check tracing headers](#check-tracing-headers) + +**Follow ups** + +- [ ] You set legacy option properties like `$process_person_profile` → [Move to event options](#move-to-event-options) +- [ ] You capture `$ai_` events with `capture()` → [Send AI events with capture_ai](#send-ai-events-with-capture_ai) +- [ ] You use `trust_tracing_headers` or `_use_ai_lane`, set `posthog.capture_mode`, or pass a `gzip=` keyword to `request.post` → [Rename deprecated settings](#rename-deprecated-settings) + +## Before you upgrade + +### Prepare your proxy + +8.x.x sends events to these endpoints: | Method | Endpoint | | --- | --- | @@ -40,53 +59,33 @@ You need to change code if your app does any of these: | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. -Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. -An endpoint that is not served drops every event sent to it. - If you send events through your own reverse proxy, update it before you upgrade: -- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. -- Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. -- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. +- **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. +- **Pass the headers through unchanged.** Pass `Authorization`, every `PostHog-*` header, `Content-Type`, `Content-Encoding`, and `User-Agent`. `Authorization` holds the project token, sent as `Bearer `. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. +- **Accept request bodies up to 20 MB.** AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -The SDK follows a `307` or `308` redirect only to the origin of `host` (same scheme, host and port), at most 5 times. -Any other redirect fails the batch and reaches `on_error`. -In 7.x the sync client followed redirects to any origin. -If a proxy redirects capture requests to another host, point `host` at the final host. +The SDK follows a `307` or `308` redirect only to the origin of `host`, at most 5 times. The origin is the same scheme, host, and port. -## SDK identity +Any other redirect fails the batch and reaches `on_error`. In 7.x.x the sync client followed redirects to any origin. -By default every event carries `$lib: "posthog-python"` and the installed package version in `$lib_version`. -PostHog stores the values the event is sent with. +If a proxy redirects capture requests to another host, point `host` at the final host. -You can set them in a call's `properties`, a context tag or `super_properties`. Like any other property, each one comes from the most specific place that sets it, so set both in the same place. -`$lib` and `$lib_version` count as set only when they are non-empty strings. -If you set `$lib` without `$lib_version`, the SDK adds its own version only when `$lib` is `posthog-python`. -A `$lib_version` without a `$lib` is replaced with the SDK's. -`before_send` sees both and can change or remove them. -In 7.x the SDK overwrote a `$lib` or `$lib_version` set in a call or a context tag. +### Use a project token -This changes `super_properties` too: +A personal API key (`phx_...`) passed as the project token disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. -- `super_properties={"$lib": "my-lib"}` sends no `$lib_version`. In 7.x it sent the SDK version. -- `super_properties={"$lib_version": "1.0"}` without a `$lib` sends the SDK's `$lib` and version. In 7.x it sent `1.0`. +### Update OpenFeature -If an event reaches PostHog without `$lib`, PostHog fills both from the SDK name and version your request sends. -If `$lib` is that SDK name and `$lib_version` is missing, empty or not a string, PostHog fills `$lib_version` the same way. +`openfeature-provider-posthog` 0.2.0 works with `posthog` 7.x.x and 8.x.x. Older provider versions require `posthog` below 8.0.0, so upgrade the provider before or with `posthog`. -MCP analytics events from `posthog.mcp.instrument()` or `PostHogMCP` report `posthog-python-mcp` and the installed package version. -The integration sets both in each event's own properties, so neither a context tag nor `super_properties` replaces them. `before_send` can. -In 7.x the MCP integration relabeled the whole client, or the default client when `instrument()` got none: the app's own events, the request headers and feature flag requests. -In 8.0 those report `posthog-python`, so one client can serve both. +## During your upgrade -Custom events from `PostHogMCP.capture()` now report `posthog-python`. In 7.x they reported `posthog-python-mcp`. -Custom events from `McpAnalytics.capture()`, the handle `instrument()` returns, still report `posthog-python-mcp`. -If an insight or dashboard filters on `$lib = posthog-python-mcp`, check that it still finds the events you expect. +### Fix removed APIs -## Removed options and APIs +8.x.x removes these options and APIs: | Removed | Use instead | | --- | --- | @@ -95,7 +94,7 @@ If an insight or dashboard filters on `$lib = posthog-python-mcp`, check that it | `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 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`. A `gzip=` keyword raises no error: it is sent as a field in the request body. | +| 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.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error: it is sent as a field in the request body. | | 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`. @@ -103,218 +102,346 @@ The `posthoganalytics` package has the same changes. For example, import `Captur Constructor arguments after `host` are keyword-only: ```python -# 7.x +# 7.x.x Posthog("", "https://us.i.posthog.com", True) -# 8.0 +# 8.x.x Posthog("", host="https://us.i.posthog.com", debug=True) ``` -## Errors and `on_error` +### Copy options in before_send + +Review your `before_send` hooks for the new event shape. Capture v1 sends processing options in an `options` object, next to `properties`. + +To change an option, edit `options`. A hook that returns a new dict must copy `options`, or the event is sent without them. + +That includes the `process_person_profile = false` the SDK sets for an event without a distinct ID. Without it, PostHog would create a person for the event's generated ID. + +A hook that spreads the event into the new dict keeps `options`: + +```python +def remove_email(event): + properties = {key: value for key, value in event["properties"].items() if key != "email"} + return {**event, "properties": properties} +``` -Capture v1 returns a result for every event, even when the request succeeds. -An event can be dropped inside a 2xx response, for example when it is over a product quota. -8.0 reports those events as failures. -When the whole request is over the billing limit, capture returns a `402` with no per-event results instead. - -- Every capture failure is a `CaptureError`, a subclass of `APIError`. It has these fields: - - `status`: the HTTP status, or `0` when the request never got a response. With `0`, `__cause__` holds the network error. In 7.x, `on_error` received the raw `requests` exception. - - `endpoint`, `request_id` and `attempts` - - `drops`: `(uuid, details)` pairs for the events capture dropped in a 2xx response - - `retry_exhausted`: the uuids still waiting for a retry after the last attempt - - `event_results`: a `CaptureEventResult(result, details)` for each uuid that got a result in a 2xx response, from `posthog.capture_send`. An event missing from it never got a result. - - `verdict_summary()`: counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` -- `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. -- With `on_error` set, the SDK logs nothing for a failed batch. -- Without `on_error`, the SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. After a 2xx response it also counts the lost events by PostHog's reason, for example `drop/event_too_big=1`. -- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. In `sync_mode`, a failed request no longer logs the 7.x `Error in capture: ...` line, or the same line for another method. Update any log-based alerts that match these lines. -- In `sync_mode`, a failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error. -- In `sync_mode`, a failed request is retried like a background batch, in the calling thread. With the default `max_retries` of 3, a call makes up to 4 attempts, and each attempt can take up to `timeout` (`capture_ai_timeout` for AI events). With the defaults, an analytics event whose requests keep timing out blocks the call for about a minute. 7.x made one attempt. -- If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again. -- The SDK retries `404`, `408`, `429`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including the other 5xx codes. 7.x retried `429` and every 5xx, but not `404`. Capture itself does not return `429`; a proxy in front of it can. A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. -- Each attempt makes one connection try. 7.x also retried a failed connection up to twice inside each attempt, so a capture request could block for up to three times the `timeout` per attempt. -- Retries start at 100 ms and double each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients do not retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10. In 7.x the first retry waited up to 1 second. -- The SDK's own log lines no longer contain event content: the debug line for a queued event names the event and the lane, and the invalid timestamp warning no longer includes the value. When your `before_send` hook raises, the error line names the event and the exception type, not the exception message, which can hold the data the hook was removing. The traceback is logged at debug level. To inspect events, use `before_send`. -- A personal API key (`phx_...`) passed as the project API key disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. - -## Invalid events - -Capture rejects a whole batch when one event cannot be parsed. -8.0 drops only the invalid event, so the rest of the batch is delivered. - -- An event whose name is missing, `None`, empty or not a string is dropped. The SDK logs one warning per reason per process. 7.x sent it. -- An event whose `before_send` hook sets `distinct_id` to `None` or a non-string is dropped, with one warning per reason per process. The SDK does not convert the hook's value or make up an ID. 7.x sent it, and capture would reject the whole batch. -- PostHog drops an event whose name is over 200 UTF-8 bytes (`event_name_too_long`), and `on_error` reports it. 7.x sent events to legacy capture, which had no event name limit. -- A `NaN` or infinite `float` value is sent as `null`, as in 7.75 and later, and the event is kept. Earlier 7.x versions sent invalid JSON, which made capture reject the whole batch. A `NaN` value in `$set` sets that person property to `null`. -- An event that cannot be serialized to JSON, for example one with a circular reference, is dropped and logged. As in 7.x, a circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. -- A property or option key that JSON cannot represent, such as a tuple, is dropped without a log, and the rest of the event is sent. 7.x dropped the event. `int`, finite `float`, `bool` and `None` keys are still converted to strings. A `NaN` or infinite `float` key still drops the event. - -## Event uuids - -- Each event needs its own uuid. Capture keeps the first event for each uuid in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x sent every copy. Older PostHog versions reject the whole batch with a 400 `duplicate_event_uuid` instead. -- 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 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 - -Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. -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. The SDK logs a warning for each one, with the key and the value's type but not the value. - -## Identity and request context - -- An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x sent a whitespace-only ID, and capture dropped the event. -- Generated personless distinct IDs are UUIDv7. 7.x generated UUIDv4. -- `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$_`. 7.x fell back to a random ID, which created a new person on every call. If `$_` is over 200 bytes, pass `distinct_id`: PostHog drops an event whose distinct ID is over 200 bytes. -- PostHog drops an event whose `distinct_id` is over 200 UTF-8 bytes (`distinct_id_too_large`), and `on_error` reports it. 7.x sent events to legacy capture, which shortened the ID to 200 characters and kept the event. -- `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID is not the session's user. -- `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the call is dropped with a warning. -- Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. -- `process_person_profile: False` from a context option, `super_options` or a `$process_person_profile` entry in `super_properties` reaches `alias`, `group_identify`, `set` and `set_once` too. A `$process_person_profile` context tag reaches only `alias` and `group_identify`, because `set` and `set_once` add context tags to the person properties they set. Context tags and options are new on `alias` and `group_identify`; `super_properties` already reached them in 7.x. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, so they never merge a person or update a group. It keeps the event from `set` or `set_once` but ignores its person update. Once the person exists, events sent with `process_person_profile: False` are still linked to it and billed as identified events. Capture accepts these events, so `on_error` does not report the drop. To keep them, pass the option on the call, which wins over context and `super_options`, or set it in `before_send`: - - ```python - posthog.alias(previous_id="anon-1", distinct_id="user-1", options={"process_person_profile": True}) - posthog.group_identify("company", "acme", {"name": "Acme"}, options={"process_person_profile": True}) - posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_person_profile": True}) - ``` - -## Tracing headers - -The Django, Flask and ASGI integrations read the `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers by default, as in 7.71 and later. -The headers only fill a missing distinct ID or session ID, and any client can set them, so never use them for authentication. - -- To turn this off, set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, or pass `capture_tracing_headers=False` to the Flask or ASGI integration. -- The 7.68 `trust_tracing_headers` and `POSTHOG_MW_TRUST_TRACING_HEADERS` names still work, but are deprecated. -- If you upgrade from 7.68, 7.69 or 7.70 and never turned these headers on, they are now read. - -## 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`. -- `AsyncPosthog.capture_exception` now adds `$trace_id` and `$span_id` from the active OpenTelemetry span, like `Client.capture_exception`. In 7.x it added them only with `capture_trace_context=True`. - -## Event options - -Capture v1 sends processing options in an `options` object, next to `properties`. - -- `capture`, `capture_ai`, `set`, `set_once`, `alias`, `group_identify` and `capture_exception` take an `options` argument: - - ```python - posthog.capture("signed_up", distinct_id="user-1", options={"cookieless_mode": True}) - ``` - - The MCP capture methods take `event_options` instead (see [MCP analytics](#mcp-analytics)), and the AI integrations take `posthog_options` (see [AI capture](#ai-capture)). - -- `super_options`, a new `Client`, `AsyncPosthog` and module setting, sets options on every event, like `super_properties`. -- `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. -- The Celery integration propagates context options to workers, like tags. - - A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. - - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)` or `task.apply_async(headers=...)`. -- Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. -- Options are sent as given. PostHog validates them. It drops an event with an option value it cannot read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. -- An `options` value that is not a dict is logged as an error and ignored. The event is still sent. -- Each option replaces a legacy option property: - - | Option | Replaces | - | --- | --- | - | `cookieless_mode` | `$cookieless_mode` | - | `disable_skew_correction` | `$ignore_sent_at` | - | `product_tour_id` | `$product_tour_id` | - | `process_person_profile` | `$process_person_profile` | - - Legacy option properties still work, but an option overrides its legacy property. Use options. -- Pass `True` or `False` for `cookieless_mode`, `disable_skew_correction` and `process_person_profile`. PostHog also reads common forms such as `"false"`, `"off"` or `0`. - -- These four options are the full set. PostHog ignores any other key in `options`. Session and window IDs stay `$session_id` and `$window_id` properties (see [Session and window IDs](#session-and-window-ids)). -- A `None` option counts as unset, so a later step can fill it. +The same applies to MCP's `before_send`. It gets each event's `options` in an `options` key of `PostHogCaptureEvent`. + +An MCP hook that returns a new dict must copy them, or the event is sent without the anonymous `process_person_profile: False`. + +### Route AI events + +8.x.x routes AI events to their own endpoint: + +- **The endpoint.** `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. +- **Event names.** `capture_ai` sends any event name to the AI endpoint. PostHog drops an event whose name doesn't start with `$ai_` as `misrouted_event`, and `on_error` reports it. The SDK writes only a debug log. +- **Custom clients.** The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. +- **Clients without `capture_ai`.** The wrapper, callback handler, or tracing processor logs one error when you create it and captures no AI events. Provider calls still work. Before, such a client got `capture` calls. +- **Mock clients.** Tests that pass a `Mock` client to an AI integration must assert on `mock.capture_ai`, not `mock.capture`. +- **OpenTelemetry.** The OpenTelemetry integration (`PostHogSpanProcessor`, `PostHogTraceExporter`) is unchanged. It still exports spans over OTLP to `/i/v0/ai/otel`. +- **Content capture.** `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. +- **Fallback IDs.** When an AI integration or MCP falls back to a trace, run, or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options`, and context options. `before_send` can still change it. +- **Empty distinct IDs.** An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing. The integration falls back the same way and turns person processing off. +- **Empty distinct IDs in 7.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. +- **Async capture.** `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. It sends events with `requests` in a worker thread, and uses `httpx` only for feature flags and remote config. +- **Async content settings.** `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. + +`Client` and `AsyncPosthog` have new arguments for the AI lane: + +| Argument | Default | +| --- | --- | +| `capture_ai_compression` | No compression. `POSTHOG_CAPTURE_COMPRESSION` doesn't apply to it. | +| `capture_ai_max_queue_size` | 1000 | +| `capture_ai_timeout` | 30 seconds | +| `capture_ai_max_event_bytes` | 8 MiB plus 64 KiB. You can only lower it. | -Values apply in this order. Steps 2 to 4 fill only the options and properties that the steps before them left unset: +The AI lane doesn't use `max_queue_size`, `timeout`, or `capture_compression`. An invalid `capture_ai_*` value raises `ValueError` when you create the client. -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: `$lib` and `$lib_version` as a pair (see [SDK identity](#sdk-identity)), `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `$trace_id` and `$span_id` from the active OpenTelemetry span (in `capture` and `capture_ai` only with `capture_trace_context=True`, and always in `capture_exception`), `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 +The module-level client has no AI lane settings. To change them, create a `Client`. + +### Fix identity calls + +Identity calls change in 8.x.x: + +- **Empty IDs.** An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x.x sent a whitespace-only ID, and capture dropped the event. +- **Generated IDs.** Generated personless distinct IDs are UUIDv7. 7.x.x generated UUIDv4. +- **Group fallback.** `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$_`. 7.x.x fell back to a random ID, which created a new person on every call. +- **Long group IDs.** If `$_` is over 200 bytes, pass `distinct_id`. PostHog drops an event whose distinct ID is over 200 bytes. +- **Long distinct IDs.** PostHog drops an event whose `distinct_id` is over 200 UTF-8 bytes (`distinct_id_too_large`), and `on_error` reports it. 7.x.x sent events to legacy capture, which shortened the ID to 200 characters and kept the event. +- **Group sessions.** `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID isn't the session's user. +- **Alias.** `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the SDK drops the call with a warning. +- **Context values.** Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. + +`process_person_profile: False` reaches identity calls too. Where you set it decides which calls it reaches: + +- **A context option, `super_options`, or a `$process_person_profile` entry in `super_properties`.** It reaches `alias`, `group_identify`, `set`, and `set_once`. +- **A `$process_person_profile` context tag.** It reaches only `alias` and `group_identify`, because `set` and `set_once` add context tags to the person properties they set. + +Context tags and options are new on `alias` and `group_identify`. `super_properties` already reached them in 7.x. + +PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning. They never merge a person or update a group. + +PostHog keeps the event from `set` or `set_once` but ignores its person update. Once the person exists, events sent with `process_person_profile: False` are still linked to it and billed as identified events. + +Capture accepts these events, so `on_error` doesn't report the drop. To keep them, pass the option on the call, which wins over context and `super_options`, or set it in `before_send`: + +```python +posthog.alias(previous_id="anon-1", distinct_id="user-1", options={"process_person_profile": True}) +posthog.group_identify("company", "acme", {"name": "Acme"}, options={"process_person_profile": True}) +posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_person_profile": True}) +``` + +### Check event IDs + +- **Duplicates.** Each event needs its own UUID. Capture keeps the first event for each UUID in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x.x sent every copy. +- **Accepted forms.** 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`. +- **Other values.** The SDK replaces any other value with a generated UUID. It 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.** Generated UUIDs are UUIDv7. + +### Check property precedence + +An event's options and properties come from five steps, 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, 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: + - `$lib` and `$lib_version` as a pair (see [Check SDK identity](#check-sdk-identity)) + - `$is_server` from `is_server` + - `$geoip_disable` from the client's `disable_geoip` setting + - System properties such as `$os` and `$python_version` + - The `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags` + - `$trace_id` and `$span_id` from the active OpenTelemetry span (in `capture` and `capture_ai` only with `capture_trace_context=True`, and always in `capture_exception`) + - `options.process_person_profile = false` for events without a distinct ID + - `$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. + +A property set to `None` counts as set, so no later step fills it. A `None` option counts as unset, so a later step can fill it. + +`$lib` and `$lib_version` are the exception. They count as set only when they're non-empty strings (see [Check SDK identity](#check-sdk-identity)). + +`$set`, `$set_once`, `$groups`, and `$group_set` fill one level deep. When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. -A property set to `None` counts as set, so no later step fills it. -`$lib` and `$lib_version` are the exception: they count as set only when they are non-empty strings (see [SDK identity](#sdk-identity)). -`$set`, `$set_once`, `$groups` and `$group_set` fill one level deep. -When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. These changes follow from this order: -- Properties passed to a call now override `super_properties`. -- A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version` 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`. -- Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag or `super_properties`, as in 7.x. -- A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x the span's IDs beat context tags. Setting either key in the call's `properties`, a context tag or `super_properties` stops the SDK adding both. In 7.x the SDK still added the other key. -- 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`. To turn person processing on for it, set the `process_person_profile` option. -- Review your `before_send` hooks for the new event shape. To change an option, edit `options`. A hook that returns a new dict must copy `options`, or the event is sent without them. That includes the `process_person_profile = false` the SDK sets for an event without a distinct ID, so PostHog would create a person for its generated ID. -- When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id`. Their options still apply, because the trim removes only properties. - -## AI capture - -- `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. -- Send `$ai_` events with `capture_ai`. `capture()` sends every event to `/i/v1/analytics/events`, including `$ai_` events. PostHog accepts them there for now, but this is temporary. -- `capture_ai` sends any event name to the AI endpoint. PostHog drops an event whose name does not start with `$ai_` as `misrouted_event`, and `on_error` reports it. The SDK writes only a debug log. -- The OpenTelemetry integration (`PostHogSpanProcessor`, `PostHogTraceExporter`) is unchanged: it still exports spans over OTLP to `/i/v0/ai/otel`. -- `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. -- The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. If the client has no `capture_ai`, the wrapper, callback handler or tracing processor logs one error when you create it and captures no AI events; provider calls still work. Before, such a client got `capture` calls. -- Tests that pass a `Mock` client to an AI integration must assert on `mock.capture_ai`, not `mock.capture`. -- When an AI integration or MCP falls back to a trace, run or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options` and context options. `before_send` can still change it. -- An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing, so the integration falls back the same way and turns person processing off. In 7.x, an empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. -- The AI integrations take capture options: - - Every wrapper method that takes `posthog_properties` also takes `posthog_options`, for the events of that call. This covers OpenAI, Anthropic and Gemini, sync and async, and the Claude Agent SDK `query()` and `PostHogClaudeSDKClient`. The name keeps it apart from the Claude Agent SDK's own `options`. - - The Gemini clients take `posthog_options` as a default for every call. A call's `posthog_options` win key by key. - - LangChain's `CallbackHandler` and `PostHogMiddleware`, OpenAI Agents' `instrument()` and `PostHogTracingProcessor`, and the Claude Agent SDK's `instrument()` and `PostHogClaudeAgentProcessor` take `options=` for every event. LangChain also reads `posthog_options` from run metadata, like `posthog_properties`. Like properties, the handler's or processor's options win over a run's or a query's. The `$exception` event LangChain captures for a failed run gets the same options. - - These are the event's own options, so they win over context options and `super_options`, which still fill the keys they leave unset. `before_send` can still change them. - - A value that is not a dict is logged as a warning and ignored. The provider call still runs, and the provider never receives `posthog_options`. - - ```python - from posthog.ai.langchain import CallbackHandler - from posthog.ai.openai import OpenAI - - client = OpenAI(posthog_client=posthog_client) - client.chat.completions.create( - model="gpt-5-mini", - messages=[{"role": "user", "content": "Hello"}], - posthog_distinct_id="user-1", - posthog_options={"cookieless_mode": True}, - ) - - handler = CallbackHandler( - posthog_client, distinct_id="user-1", options={"cookieless_mode": True} - ) - ``` -- New `Client` and `AsyncPosthog` arguments for the AI lane: - - `capture_ai_compression`: default none. `POSTHOG_CAPTURE_COMPRESSION` does not apply to it. - - `capture_ai_max_queue_size`: default 1000 - - `capture_ai_timeout`: default 30 seconds - - `capture_ai_max_event_bytes`: default 8 MiB plus 64 KiB. You can only lower it. -- The AI lane does not use `max_queue_size`, `timeout` or `capture_compression`. An invalid `capture_ai_*` value raises `ValueError` when you create the client. -- The module-level client has no AI lane settings. To change them, create a `Client`. -- `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. -- `AsyncPosthog` sends events with `requests` in a worker thread. It uses `httpx` only for feature flags and remote config. -- `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. - -## MCP analytics +- **Call properties.** Properties passed to a call now override `super_properties`. +- **SDK values.** A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version`, 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.x the SDK overwrote it. +- **SDK values and `super_properties`.** `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`. +- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x. +- **Trace IDs.** A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x.x the span's IDs beat context tags. +- **Trace ID pairs.** Setting either key in the call's `properties`, a context tag, or `super_properties` stops the SDK adding both. In 7.x.x the SDK still added the other key. +- **The `disable_geoip` argument.** 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`.** It now sends `$geoip_disable: false`. In 7.x.x it sent no `$geoip_disable` property. +- **The `groups` argument.** It merges into a `$groups` property of the call, and wins key by key. In 7.x.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way. +- **Merged dicts.** 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. +- **Events without a distinct ID.** An event without a distinct ID gets `options.process_person_profile = false`. To turn person processing on for it, set the `process_person_profile` option. +- **Minimal flag events.** When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id`. Their options still apply, because the trim removes only properties. + +### Check SDK identity + +By default every event carries `$lib: "posthog-python"` and the installed package version in `$lib_version`. PostHog stores the values the event is sent with. + +You can set them in a call's `properties`, a context tag, or `super_properties`. Like any other property, each one comes from the most specific place that sets it, so set both in the same place. + +`$lib` and `$lib_version` count as set only when they're non-empty strings. `before_send` sees both and can change or remove them. + +If you set `$lib` without `$lib_version`, the SDK adds its own version only when `$lib` is `posthog-python`. The SDK replaces a `$lib_version` that has no `$lib` with its own. + +This changes what your events carry: + +| You set | 8.x.x sends | 7.x.x sent | +| --- | --- | --- | +| `$lib` or `$lib_version` in a call or a context tag | Your value | The SDK's value. The SDK overwrote yours. | +| `super_properties={"$lib": "my-lib"}` | No `$lib_version` | The SDK version | +| `super_properties={"$lib_version": "1.0"}` without a `$lib` | The SDK's `$lib` and version | `1.0` | + +If an event reaches PostHog without `$lib`, PostHog fills both from the SDK name and version your request sends. If `$lib` is that SDK name and `$lib_version` is missing, empty, or not a string, PostHog fills `$lib_version` the same way. + +MCP analytics events from `posthog.mcp.instrument()` or `PostHogMCP` report `posthog-python-mcp` and the installed package version. The integration sets both in each event's own properties, so neither a context tag nor `super_properties` replaces them, but `before_send` can. + +In 7.x.x the MCP integration relabeled the whole client, or the default client when `instrument()` got none. That covered the app's own events, the request headers, and feature flag requests. + +In 8.x.x those report `posthog-python`, so one client can serve both. + +Custom events from `PostHogMCP.capture()` now report `posthog-python`. In 7.x.x they reported `posthog-python-mcp`. + +Custom events from `McpAnalytics.capture()`, the handle `instrument()` returns, still report `posthog-python-mcp`. If an insight or dashboard filters on `$lib = posthog-python-mcp`, check that it still finds the events you expect. + +### Update error handling + +Capture v1 returns a result for every event, even when the request succeeds. An event can be dropped inside a 2xx response, for example when it's over a product quota. + +8.x.x reports those events as failures. When the whole request is over the billing limit, capture returns a `402` with no per-event results instead. + +Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `endpoint`, `request_id`, and `attempts`, and these fields: + +| Field | Holds | +| --- | --- | +| `status` | The HTTP status, or `0` when the request never got a response. With `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. | +| `drops` | `(uuid, details)` pairs for the events capture dropped in a 2xx response | +| `retry_exhausted` | The UUIDs still waiting for a retry after the last attempt | +| `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response, from `posthog.capture_send`. An event missing from it never got a result. | +| `verdict_summary()` | Counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` | + +`on_error` and the SDK's log lines change with it: + +- **Every failure.** `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. +- **Logs with `on_error`.** With `on_error` set, the SDK logs nothing for a failed batch. +- **Logs without `on_error`.** The SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. After a 2xx response it also counts the lost events by PostHog's reason, for example `drop/event_too_big=1`. +- **Removed log lines.** The 7.x.x lines `error uploading: ...`, `async capture upload failed (...)`, and `Immediate async capture failed (...)` are gone. In `sync_mode`, a failed request no longer logs the 7.x.x `Error in capture: ...` line, or the same line for another method. Update any log-based alerts that match these lines. +- **Failures in `sync_mode`.** A failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error. +- **Retries in `sync_mode`.** The SDK retries a failed request like a background batch, in the calling thread. With the default `max_retries` of 3, a call makes up to 4 attempts. Each attempt can take up to `timeout` (`capture_ai_timeout` for AI events). 7.x.x made one attempt. +- **Blocking in `sync_mode`.** With the defaults, an analytics event whose requests keep timing out blocks the call for about a minute. +- **Captures inside `on_error`.** If a capture inside `on_error` fails, the SDK logs the failure and doesn't call `on_error` again. +- **Event content.** The SDK's own log lines no longer contain event content. The debug line for a queued event names the event and the lane. The invalid timestamp warning no longer includes the value. To inspect events, use `before_send`. +- **Hook errors.** When your `before_send` hook raises, the error line names the event and the exception type, not the exception message. The message can hold the data the hook was removing. The SDK logs the traceback at debug level. + +Retries change too: + +| Behavior | 7.x.x | 8.x.x | +| --- | --- | --- | +| Statuses retried | `429` and every 5xx, but not `404` | `404`, `408`, `429`, `500`, `502`, `503`, and `504` responses, and network errors. Other error statuses fail the batch at once, including the other 5xx codes. | +| Connection tries in each attempt | One, plus up to two retries of a failed connection. A capture request could block for up to three times the `timeout` per attempt. | One | +| First retry wait | Up to 1 second | Retries start at 100 ms | +| `Consumer` default retries | 10 | 3 | + +In 8.x.x the wait doubles each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients don't retry at the same moment. + +A `Retry-After` header sets a minimum wait, up to 30 seconds. + +Capture itself doesn't return `429`, but a proxy in front of it can. + +### Check dropped events + +Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only the invalid event, so the rest of the batch is delivered. + +| Event | 8.x.x | 7.x.x | +| --- | --- | --- | +| The name is missing, `None`, empty, or not a string | The SDK drops it and logs one warning per reason per process. | Sent it | +| A `before_send` hook sets `distinct_id` to `None` or a non-string | The SDK drops it, with one warning per reason per process. It doesn't convert the hook's value or make up an ID. | Sent it, and capture would reject the whole batch | +| The name is over 200 UTF-8 bytes | PostHog drops it (`event_name_too_long`), and `on_error` reports it. | Sent it to legacy capture, which had no event name limit | +| It has a `NaN` or infinite `float` value | The SDK sends the value as `null`, as in 7.75.0 and later, and keeps the event. A `NaN` value in `$set` sets that person property to `null`. | Earlier 7.x.x versions sent invalid JSON, which made capture reject the whole batch | +| It can't be serialized to JSON, for example with a circular reference | The SDK drops it and logs it. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. | Logged the same line for a circular reference | +| A property or option key is one JSON can't represent, such as a tuple | The SDK drops the key without a log and sends the rest of the event. | Dropped the event | +| A key is an `int`, finite `float`, `bool`, or `None` | The SDK still converts the key to a string. | Converted the key to a string | +| A key is a `NaN` or infinite `float` | The SDK still drops the event. | Dropped the event | + +Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. The SDK moves them out of `properties` for you, by the value's type: + +- **A string.** The SDK sends it as given, including `""`. +- **`None`.** It counts as unset, and the SDK removes it. +- **Any other value.** The SDK removes it and doesn't send it, because capture would reject the whole batch. 7.x.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. + +Size limits change too: + +- **Analytics events.** The SDK drops and logs an analytics event over 10 MiB, because no capture request can carry it. 7.x.x dropped events over 900 KiB. +- **Capture's own limit.** Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. +- **AI events.** The SDK drops and logs an AI event over `capture_ai_max_event_bytes`. +- **Batches.** A batch stops before an event that would take it past 5 MiB. A larger event is sent alone. +- **Queues.** `flush()` and `shutdown()` drain the analytics and AI queues at the same time. + +### Check exceptions + +- **The `level=` argument.** `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. +- **Reserved properties.** `capture_exception` ignores reserved exception properties in `properties`, such as `$exception_list`, `$exception_level`, and `$exception_source`. In 7.x.x they overrode the SDK's values, with a `DeprecationWarning`. Use `level=` instead of `$exception_level`. +- **Async level.** `AsyncPosthog.capture_exception` now sends `$exception_level`. +- **Async trace IDs.** `AsyncPosthog.capture_exception` now adds `$trace_id` and `$span_id` from the active OpenTelemetry span, like `Client.capture_exception`. In 7.x.x it added them only with `capture_trace_context=True`. + +### Check tracing headers + +The Django, Flask, and ASGI integrations read the `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers by default, as in 7.71.0 and later. The headers only fill a missing distinct ID or session ID, and any client can set them, so never use them for authentication. + +- **Turn it off.** Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, or pass `capture_tracing_headers=False` to the Flask or ASGI integration. +- **Upgrades from 7.68.x, 7.69.x, or 7.70.x.** If you never turned these headers on, they're now read. + +## Follow ups + +### Move to event options + +Capture v1 sends processing options in an `options` object, next to `properties`. `capture`, `capture_ai`, `set`, `set_once`, `alias`, `group_identify`, and `capture_exception` take an `options` argument: + +```python +posthog.capture("signed_up", distinct_id="user-1", options={"cookieless_mode": True}) +``` + +The MCP capture methods take `event_options` instead (see [Add MCP event options](#add-mcp-event-options)). The AI integrations take `posthog_options` (see [Send AI events with capture_ai](#send-ai-events-with-capture_ai)). + +- **Every event.** `super_options`, a new `Client`, `AsyncPosthog`, and module setting, sets options on every event, like `super_properties`. +- **One context.** `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. +- **Async contexts.** `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. +- **Celery workers.** The Celery integration propagates context options to workers, like tags. A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. +- **Celery eager mode.** In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags, and options don't reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)` or `task.apply_async(headers=...)`. +- **Methods reached.** Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias`, and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. +- **Validation.** Options are sent as given. PostHog validates them. It drops an event with an option value it can't read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. +- **Values that aren't a dict.** The SDK logs an `options` value that isn't a dict as an error and ignores it. It still sends the event. +- **Boolean options.** Pass `True` or `False` for `cookieless_mode`, `disable_skew_correction`, and `process_person_profile`. PostHog also reads common forms such as `"false"`, `"off"`, or `0`. + +Each option replaces a legacy option property: + +| Option | Replaces | +| --- | --- | +| `cookieless_mode` | `$cookieless_mode` | +| `disable_skew_correction` | `$ignore_sent_at` | +| `product_tour_id` | `$product_tour_id` | +| `process_person_profile` | `$process_person_profile` | + +Legacy option properties still work, but an option overrides its legacy property. Use options. + +These four options are the full set. PostHog ignores any other key in `options`. + +Session and window IDs stay `$session_id` and `$window_id` properties (see [Check dropped events](#check-dropped-events)). + +### Send AI events with capture_ai + +Send `$ai_` events with `capture_ai`. `capture()` sends every event to `/i/v1/analytics/events`, including `$ai_` events. + +PostHog accepts them there for now, but this is temporary. + +The AI integrations take capture options: + +- **Wrapper methods.** Every wrapper method that takes `posthog_properties` also takes `posthog_options`, for the events of that call. This covers the sync and async OpenAI, Anthropic, and Gemini wrappers, and the Claude Agent SDK `query()` and `PostHogClaudeSDKClient`. The name keeps it apart from the Claude Agent SDK's own `options`. +- **Gemini clients.** The Gemini clients take `posthog_options` as a default for every call. A call's `posthog_options` win key by key. +- **Handlers and processors.** LangChain's `CallbackHandler` and `PostHogMiddleware` take `options=` for every event. So do OpenAI Agents' `instrument()` and `PostHogTracingProcessor`, and the Claude Agent SDK's `instrument()` and `PostHogClaudeAgentProcessor`. +- **Run metadata.** LangChain also reads `posthog_options` from run metadata, like `posthog_properties`. Like properties, the handler's or processor's options win over a run's or a query's. +- **Failed runs.** The `$exception` event LangChain captures for a failed run gets the same options. +- **Precedence.** These are the event's own options, so they win over context options and `super_options`, which still fill the keys they leave unset. `before_send` can still change them. +- **Values that aren't a dict.** The SDK logs a value that isn't a dict as a warning and ignores it. The provider call still runs, and the provider never receives `posthog_options`. + +Pass `posthog_options` to a wrapper call, or `options` to a handler: + +```python +from posthog.ai.langchain import CallbackHandler +from posthog.ai.openai import OpenAI + +client = OpenAI(posthog_client=posthog_client) +client.chat.completions.create( + model="gpt-5-mini", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="user-1", + posthog_options={"cookieless_mode": True}, +) + +handler = CallbackHandler( + posthog_client, distinct_id="user-1", options={"cookieless_mode": True} +) +``` + +### Add MCP event options MCP events take options like other events, through an `event_options` argument and an `event_options` callback. -- The `PostHogMCP` capture methods (`capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability` and `capture_feedback`) and `McpAnalytics.capture`, on the handle `instrument()` returns, take an `event_options` argument. It sets options on that event. For a failed tool call, it also sets them on the `$exception` event. -- `MCPAnalyticsOptions(event_options=...)` sets options on every event `instrument()` captures automatically, including `$exception` events. It does not apply to `McpAnalytics.capture`. Like `event_properties`, it is a function of `(request, extra)` that returns a dict or `None`, and it can be sync or async. -- `PostHogMCP.capture()` is the core client's `capture`, so it takes `options`. The MCP capture methods take `event_options`. -- The SDK passes these options as the event's own options, so they win over context options and `super_options`. -- An event without an identity always sends `process_person_profile: False`, even when `event_options` sets it to `True`. Its distinct ID is the session ID, so person processing would create a person for each session. To process a person, identify the user with `identify` or pass `distinct_id`. -- An `event_options` value that is not a dict is logged and ignored. The event is still sent. +- **The argument.** The `PostHogMCP` capture methods and `McpAnalytics.capture`, on the handle `instrument()` returns, take an `event_options` argument. The capture methods are `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, and `capture_feedback`. +- **What the argument sets.** It sets options on that event. For a failed tool call, it also sets them on the `$exception` event. +- **The callback.** `MCPAnalyticsOptions(event_options=...)` sets options on every event `instrument()` captures automatically, including `$exception` events. It doesn't apply to `McpAnalytics.capture`. +- **The callback's shape.** Like `event_properties`, it's a function of `(request, extra)` that returns a dict or `None`, and it can be sync or async. +- **The core `capture`.** `PostHogMCP.capture()` is the core client's `capture`, so it takes `options`. The MCP capture methods take `event_options`. +- **Precedence.** The SDK passes these options as the event's own options, so they win over context options and `super_options`. +- **Events without an identity.** An event without an identity always sends `process_person_profile: False`, even when `event_options` sets it to `True`. Its distinct ID is the session ID, so person processing would create a person for each session. To process a person, identify the user with `identify` or pass `distinct_id`. +- **Values that aren't a dict.** The SDK logs an `event_options` value that isn't a dict and ignores it. It still sends the event. + +Set the callback in `instrument()`, or pass the argument to a capture method: ```python from posthog.mcp import MCPAnalyticsOptions, PostHogMCP, instrument @@ -333,22 +460,17 @@ mcp_client.capture_tool_call( ) ``` -`instrument()` captures `$mcp_tool_call` after the tool handler returns. -A context you open inside the handler has ended by then, so options you set in it with `set_context_option` do not reach `$mcp_tool_call`. -Events you capture inside that context still get them. -To set options on `$mcp_tool_call`, use the `event_options` callback. - -MCP's `before_send` gets each event's `options` in an `options` key of `PostHogCaptureEvent`. -A hook that returns a new dict must copy them, or the event is sent without the anonymous `process_person_profile: False`. +`instrument()` captures `$mcp_tool_call` after the tool handler returns. A context you open inside the handler has ended by then, so options you set in it with `set_context_option` don't reach `$mcp_tool_call`. -## Batching and size limits +Events you capture inside that context still get them. To set options on `$mcp_tool_call`, use the `event_options` callback. -- An analytics event over 10 MiB is dropped and logged, because no capture request can carry it. 7.x dropped events over 900 KiB. Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. -- An AI event over `capture_ai_max_event_bytes` is dropped and logged. -- A batch stops before an event that would take it past 5 MiB. A larger event is sent alone. -- `flush()` and `shutdown()` drain the analytics and AI queues at the same time. +### Rename deprecated settings -## OpenFeature provider +These old names still work or have no effect in 8.x.x: -`openfeature-provider-posthog` 0.2.0 works with posthog 7.x and 8.x. -Older provider versions require posthog below 8.0, so upgrade the provider before or with posthog. +| Old | New | +| --- | --- | +| `trust_tracing_headers`, `POSTHOG_MW_TRUST_TRACING_HEADERS` | `capture_tracing_headers`, `POSTHOG_MW_CAPTURE_TRACING_HEADERS`. The 7.68.x names still work, but are deprecated. | +| `_use_ai_lane`, `_enable_multimodal_capture` | `enable_full_ai_capture`. The old names still work as aliases. | +| `posthog.capture_mode` | Nothing. Setting it on the module has no effect. | +| A `gzip=` keyword on `request.post` or `request.flags` | Nothing. It raises no error, and it's sent as a field in the request body. | From 9c61c79de14b9fdd02751df5ab7b8cbc88ba3046 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 11:59:05 -0500 Subject: [PATCH 04/27] docs: replace filler lead-ins in the 8.0.0 migration guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index aac0fe284..0b4ce71ad 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -161,8 +161,6 @@ The module-level client has no AI lane settings. To change them, create a `Clien ### Fix identity calls -Identity calls change in 8.x.x: - - **Empty IDs.** An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x.x sent a whitespace-only ID, and capture dropped the event. - **Generated IDs.** Generated personless distinct IDs are UUIDv7. 7.x.x generated UUIDv4. - **Group fallback.** `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$_`. 7.x.x fell back to a random ID, which created a new person on every call. @@ -249,8 +247,6 @@ You can set them in a call's `properties`, a context tag, or `super_properties`. If you set `$lib` without `$lib_version`, the SDK adds its own version only when `$lib` is `posthog-python`. The SDK replaces a `$lib_version` that has no `$lib` with its own. -This changes what your events carry: - | You set | 8.x.x sends | 7.x.x sent | | --- | --- | --- | | `$lib` or `$lib_version` in a call or a context tag | Your value | The SDK's value. The SDK overwrote yours. | @@ -298,7 +294,7 @@ Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `end - **Event content.** The SDK's own log lines no longer contain event content. The debug line for a queued event names the event and the lane. The invalid timestamp warning no longer includes the value. To inspect events, use `before_send`. - **Hook errors.** When your `before_send` hook raises, the error line names the event and the exception type, not the exception message. The message can hold the data the hook was removing. The SDK logs the traceback at debug level. -Retries change too: +Retries differ from 7.x.x in four ways: | Behavior | 7.x.x | 8.x.x | | --- | --- | --- | @@ -334,7 +330,7 @@ Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not a - **`None`.** It counts as unset, and the SDK removes it. - **Any other value.** The SDK removes it and doesn't send it, because capture would reject the whole batch. 7.x.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. -Size limits change too: +8.x.x applies these size and batch rules: - **Analytics events.** The SDK drops and logs an analytics event over 10 MiB, because no capture request can carry it. 7.x.x dropped events over 900 KiB. - **Capture's own limit.** Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. From fac1c959c36ad8a3e3b9cbdb0cb7aa1db789073d Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:03:08 -0500 Subject: [PATCH 05/27] docs: keep the 8.0.0 guide's table cells to one value Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 76 ++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 0b4ce71ad..db8a140b0 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -89,13 +89,21 @@ A personal API key (`phx_...`) passed as the project token disables the client a | Removed | Use instead | | --- | --- | -| `capture_mode`, `CaptureMode`, `POSTHOG_CAPTURE_MODE` | Nothing. Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no effect. | -| `gzip=True` | `capture_compression=CaptureCompression.GZIP`. The default is no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. | -| `CaptureV1Error` (`from posthog.capture_v1 import CaptureV1Error`) | `CaptureError` (`from posthog import CaptureError`). There is no alias. | +| `capture_mode`, `CaptureMode`, `POSTHOG_CAPTURE_MODE` | Nothing | +| `gzip=True` | `capture_compression` | +| `CaptureV1Error` | `CaptureError` | | `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 parameters after `path` in `post` and after `host` in `flags` are keyword-only, so a 7.x.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error: it is sent as a field in the request body. | -| The `backoff` dependency | Add it to your own requirements if your code imports it. | +| `request.batch_post`, `async_batch_post`, `EVENTS_ENDPOINT`, `AI_EVENTS_ENDPOINT` | Send events through a client | +| The `gzip` parameter of `request.post` and `request.flags` | Nothing | +| The `backoff` dependency | Your own requirements, if your code imports it | + +Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no effect. + +For gzip, set `capture_compression=CaptureCompression.GZIP`. `capture_compression` defaults to no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. + +Replace `from posthog.capture_v1 import CaptureV1Error` with `from posthog import CaptureError`. There is no alias. + +The parameters after `path` in `request.post` and after `host` in `request.flags` are keyword-only, so a 7.x.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error. It's sent as a field in the request body. The `posthoganalytics` package has the same changes. For example, import `CaptureError` from `posthoganalytics`. @@ -150,10 +158,12 @@ An MCP hook that returns a new dict must copy them, or the event is sent without | Argument | Default | | --- | --- | -| `capture_ai_compression` | No compression. `POSTHOG_CAPTURE_COMPRESSION` doesn't apply to it. | +| `capture_ai_compression` | No compression | | `capture_ai_max_queue_size` | 1000 | | `capture_ai_timeout` | 30 seconds | -| `capture_ai_max_event_bytes` | 8 MiB plus 64 KiB. You can only lower it. | +| `capture_ai_max_event_bytes` | 8 MiB plus 64 KiB | + +`POSTHOG_CAPTURE_COMPRESSION` doesn't apply to `capture_ai_compression`. You can only lower `capture_ai_max_event_bytes`. The AI lane doesn't use `max_queue_size`, `timeout`, or `capture_compression`. An invalid `capture_ai_*` value raises `ValueError` when you create the client. @@ -249,7 +259,7 @@ If you set `$lib` without `$lib_version`, the SDK adds its own version only when | You set | 8.x.x sends | 7.x.x sent | | --- | --- | --- | -| `$lib` or `$lib_version` in a call or a context tag | Your value | The SDK's value. The SDK overwrote yours. | +| `$lib` or `$lib_version` in a call or a context tag | Your value | The SDK's value | | `super_properties={"$lib": "my-lib"}` | No `$lib_version` | The SDK version | | `super_properties={"$lib_version": "1.0"}` without a `$lib` | The SDK's `$lib` and version | `1.0` | @@ -275,12 +285,16 @@ Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `end | Field | Holds | | --- | --- | -| `status` | The HTTP status, or `0` when the request never got a response. With `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. | +| `status` | The HTTP status, or `0` when the request never got a response | | `drops` | `(uuid, details)` pairs for the events capture dropped in a 2xx response | | `retry_exhausted` | The UUIDs still waiting for a retry after the last attempt | -| `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response, from `posthog.capture_send`. An event missing from it never got a result. | +| `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response | | `verdict_summary()` | Counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` | +When `status` is `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. + +`CaptureEventResult` comes from `posthog.capture_send`. An event missing from `event_results` never got a result. + `on_error` and the SDK's log lines change with it: - **Every failure.** `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. @@ -298,11 +312,13 @@ Retries differ from 7.x.x in four ways: | Behavior | 7.x.x | 8.x.x | | --- | --- | --- | -| Statuses retried | `429` and every 5xx, but not `404` | `404`, `408`, `429`, `500`, `502`, `503`, and `504` responses, and network errors. Other error statuses fail the batch at once, including the other 5xx codes. | -| Connection tries in each attempt | One, plus up to two retries of a failed connection. A capture request could block for up to three times the `timeout` per attempt. | One | -| First retry wait | Up to 1 second | Retries start at 100 ms | +| Statuses retried | `429` and every 5xx, but not `404` | `404`, `408`, `429`, `500`, `502`, `503`, `504`, and network errors | +| Connection tries in each attempt | Up to three | One | +| First retry wait | Up to 1 second | 100 ms | | `Consumer` default retries | 10 | 3 | +In 8.x.x, any other error status fails the batch at once, including the other 5xx codes. In 7.x.x, a capture request could block for up to three times the `timeout` per attempt. + In 8.x.x the wait doubles each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients don't retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. @@ -315,14 +331,18 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t | Event | 8.x.x | 7.x.x | | --- | --- | --- | -| The name is missing, `None`, empty, or not a string | The SDK drops it and logs one warning per reason per process. | Sent it | -| A `before_send` hook sets `distinct_id` to `None` or a non-string | The SDK drops it, with one warning per reason per process. It doesn't convert the hook's value or make up an ID. | Sent it, and capture would reject the whole batch | -| The name is over 200 UTF-8 bytes | PostHog drops it (`event_name_too_long`), and `on_error` reports it. | Sent it to legacy capture, which had no event name limit | -| It has a `NaN` or infinite `float` value | The SDK sends the value as `null`, as in 7.75.0 and later, and keeps the event. A `NaN` value in `$set` sets that person property to `null`. | Earlier 7.x.x versions sent invalid JSON, which made capture reject the whole batch | -| It can't be serialized to JSON, for example with a circular reference | The SDK drops it and logs it. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. | Logged the same line for a circular reference | -| A property or option key is one JSON can't represent, such as a tuple | The SDK drops the key without a log and sends the rest of the event. | Dropped the event | -| A key is an `int`, finite `float`, `bool`, or `None` | The SDK still converts the key to a string. | Converted the key to a string | -| A key is a `NaN` or infinite `float` | The SDK still drops the event. | Dropped the event | +| The name is missing, `None`, empty, or not a string | Dropped, with a warning | Sent | +| A `before_send` hook sets `distinct_id` to `None` or a non-string | Dropped, with a warning | Sent, and capture rejected the whole batch | +| The name is over 200 UTF-8 bytes | Dropped by PostHog as `event_name_too_long` | Sent, with no name limit | +| A `NaN` or infinite `float` value | Sent as `null` | Invalid JSON before 7.75.0, so capture rejected the whole batch | +| It can't be serialized to JSON, for example with a circular reference | Dropped and logged | Logged the same line | +| A property or option key JSON can't represent, such as a tuple | Key dropped without a log | Event dropped | +| A key is an `int`, finite `float`, `bool`, or `None` | Key converted to a string | Key converted to a string | +| A key is a `NaN` or infinite `float` | Event dropped | Event dropped | + +The SDK logs one warning per reason per process for a dropped name or `distinct_id`. It doesn't convert the hook's `distinct_id` or make up an ID. + +`on_error` reports an event that PostHog drops for its name. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. The SDK moves them out of `properties` for you, by the value's type: @@ -466,7 +486,11 @@ These old names still work or have no effect in 8.x.x: | Old | New | | --- | --- | -| `trust_tracing_headers`, `POSTHOG_MW_TRUST_TRACING_HEADERS` | `capture_tracing_headers`, `POSTHOG_MW_CAPTURE_TRACING_HEADERS`. The 7.68.x names still work, but are deprecated. | -| `_use_ai_lane`, `_enable_multimodal_capture` | `enable_full_ai_capture`. The old names still work as aliases. | -| `posthog.capture_mode` | Nothing. Setting it on the module has no effect. | -| A `gzip=` keyword on `request.post` or `request.flags` | Nothing. It raises no error, and it's sent as a field in the request body. | +| `trust_tracing_headers`, `POSTHOG_MW_TRUST_TRACING_HEADERS` | `capture_tracing_headers`, `POSTHOG_MW_CAPTURE_TRACING_HEADERS` | +| `_use_ai_lane`, `_enable_multimodal_capture` | `enable_full_ai_capture` | +| `posthog.capture_mode` | Nothing | +| A `gzip=` keyword on `request.post` or `request.flags` | Nothing | + +The 7.68.x tracing header names still work, but are deprecated. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. + +Setting `posthog.capture_mode` on the module has no effect. A `gzip=` keyword raises no error, and it's sent as a field in the request body. From b49870b3c2d3e468b322a6c7b9075fed12aec5f3 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 11:48:17 -0500 Subject: [PATCH 06/27] docs: title a single migration guide's page with its H1 Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f1432836222b72785be22133b326648703f056f5) --- bin/docs_scripts/generate_markdown.py | 13 +++++++++++-- posthog/test/test_reference_markdown.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 8ade17f58..773928315 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -214,6 +214,16 @@ def nest(markdown: str) -> str: return "\n".join(out) +def migration_page(guides: list[str]) -> list[str]: + """The migration page. One guide's H1 becomes the page title, so the page shows one title.""" + title = "PostHog Python SDK migration guides" + if len(guides) == 1 and guides[0].startswith("# "): + heading, _, rest = guides[0].partition("\n") + title, guides = heading[2:].strip(), [rest.strip()] + head = f"---\ntitle: {json.dumps(title)}\nsidebar: Docs\nshowTitle: true\n---" + return [head, *guides] + + def write(name: str, sections: list[str]) -> None: (ROOT / "references" / name).write_text("\n\n".join(sections) + "\n") print(f"wrote references/{name}") @@ -255,10 +265,9 @@ def main() -> None: [front_matter("PostHog Python SDK", body), *body], ) if guides := sorted((ROOT / "docs").glob("migration-*.md"), reverse=True): - head = "---\ntitle: PostHog Python SDK migration guides\nsidebar: Docs\nshowTitle: true\n---" write( "posthog-python-migration.md", - [head, *(g.read_text().strip() for g in guides)], + migration_page([g.read_text().strip() for g in guides]), ) diff --git a/posthog/test/test_reference_markdown.py b/posthog/test/test_reference_markdown.py index bba347504..d6e872fe6 100644 --- a/posthog/test/test_reference_markdown.py +++ b/posthog/test/test_reference_markdown.py @@ -65,3 +65,17 @@ def test_summaries_keep_the_whole_first_paragraph(pkg): generator.clean(cls) md = generator.entry(cls, "FeatureFlagEvaluations") assert "on the first access per (distinct_id, flag, value) tuple" in md + + +def test_one_migration_guide_titles_the_page_with_its_h1(): + head, body = generator.migration_page( + ["# Migrate the Python SDK from 7.x.x to 8.0.0\n\nIntro."] + ) + assert 'title: "Migrate the Python SDK from 7.x.x to 8.0.0"' in head + assert body == "Intro." + + +def test_several_migration_guides_keep_their_h1s(): + head, *guides = generator.migration_page(["# Guide 2\n\nB.", "# Guide 1\n\nA."]) + assert 'title: "PostHog Python SDK migration guides"' in head + assert guides == ["# Guide 2\n\nB.", "# Guide 1\n\nA."] From bc7bc9c66639618a761c111942e813ab00b9b3e0 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:33:16 -0500 Subject: [PATCH 07/27] docs: move the posthog.com Python SDK pages into the reference The posthog.com Python SDK pages redirect to this reference once 8.0.0 ships, so their setup, how-to, and configuration sections now live in docs/getting-started.md. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 9db701400ac87f16c48f982ddfc9fee769debb89) --- docs/getting-started.md | 1035 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1035 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index 27d91f176..1c17bd2c3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,3 +37,1038 @@ posthog.shutdown() ``` In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [AsyncClient](#asyncclient). + +## Install with the wizard + +Run this command in your project directory to add PostHog to your Python project. The wizard detects your framework, installs the SDK, and sets up your first events. + +```bash +npx -y @posthog/wizard@latest +``` + +Or set it up by hand with the steps above. + +## Use the asyncio client + +The SDK includes an asyncio-native client, available since 7.45.0. Keep using `Posthog` in synchronous apps. For an asyncio app, install the optional async dependencies: + +```bash +pip install "posthog[async]>=8,<9" +``` + +Import `AsyncPosthog`, the customer-facing name for `AsyncClient`. Both names provide the same async context manager and lifecycle methods. Keep one client for the lifetime of your app. For example, use a FastAPI lifespan handler: + +```python +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from posthog import AsyncPosthog + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with AsyncPosthog( + os.environ["POSTHOG_PROJECT_TOKEN"], + host=os.environ["POSTHOG_HOST"], + secret_key=os.environ.get("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY"), + ) as posthog: + app.state.posthog = posthog + yield + +app = FastAPI(lifespan=lifespan) +``` + +Exiting the context calls `shutdown()`. This flushes buffered events, waits for in-flight operations, stops the workers, and closes the HTTP transport. + +If you don't use the context manager, call `await posthog.shutdown()` during app shutdown. `await posthog.join()` has the same effect. + +## Capture without blocking + +With `AsyncPosthog`, `capture()` queues an event and returns without waiting for a network request. Don't await it: + +```python +posthog.capture( + "event_name", + distinct_id="user-distinct-id", + properties={"source": "fastapi"}, +) +``` + +Use `capture_immediate()` when your code must wait for that event's delivery attempt: + +```python +capture_id = await posthog.capture_immediate( + "event_name", + distinct_id="user-distinct-id", +) +``` + +## Evaluate flags with asyncio + +Await `evaluate_flags()` once, then use its snapshot with synchronous in-memory accessors. Pass the same snapshot to `capture()` to attach the exact values used for branching without another feature flag request: + +```python +flags = await posthog.evaluate_flags("user-distinct-id") + +if flags.is_enabled("new-checkout"): + # Show the new checkout + pass + +posthog.capture( + "checkout started", + distinct_id="user-distinct-id", + flags=flags, +) +``` + +The snapshot provides synchronous `is_enabled()`, `get_flag()`, and `get_flag_payload()` accessors. The awaited `evaluate_flags()` call also accepts `groups`, `person_properties`, `group_properties`, `disable_geoip`, `flag_keys`, and `device_id` arguments. + +## Fetch remote config + +Initialize the client with a server-side [secret API key](https://posthog.com/docs/feature-flags/remote-config#step-1-get-a-secret-api-key) as `secret_key`, then await the remote config request: + +```python +config = await posthog.get_remote_config_payload("landing-page-config") +``` + +See [Remote config](https://posthog.com/docs/feature-flags/remote-config) for setup and security details. + +## Identify users + +> **Identifying users is required.** Backend events need a `distinct_id` to associate events with the correct user. + +In Python, you can do this through a context. All event captures in the same context are tagged with the correct `distinct_id`. Typically, you set a fresh context and identify at the top of each route: + +```python +from posthog import new_context, identify_context, capture + +@app.get("/foo") +def foo(current_user: User = Depends(get_current_user)): + with new_context(): # Set context at the top of a route + identify_context(current_user.id) + capture("foo_viewed") + return {"status": "ok"} +``` + +When possible, write a small piece of middleware that resolves your authenticated user, wraps a context around the request, and identifies it. Every `capture()` downstream is then attributed to that user. The SDK's Django middleware does this for you, and you can replicate it in plain Python. + +## Send custom events + +Send custom events with `capture`. Events captured with no context and no explicit `distinct_id` are marked as personless and get an auto-generated `distinct_id`. The [Capture](#capture) entry has the examples. + +> **Name events as `[object] [verb]`.** `[object]` is the entity the behavior relates to, and `[verb]` is the behavior itself. For example, `project created`, `user signed up`, or `invite sent`. + +You can define event schemas with typed properties and generate type-safe code with [schema management](https://posthog.com/docs/product-analytics/schema-management). + +To include more information with an event, pass a [properties](https://posthog.com/docs/data/events#event-properties) dictionary. + +If you run PostHog on your backend only and don't capture events from your frontend, send `$pageview` events from your backend with a `$current_url` property. + +## Set person properties + +The SDK captures identified events if the current context is identified or if you pass a distinct ID explicitly. These create [person profiles](https://posthog.com/docs/data/persons). To set [person properties](https://posthog.com/docs/product-analytics/person-properties) in these profiles, include them when you capture an event: + +```python +# Passing a distinct id explicitly +posthog.capture( + 'event_name', + distinct_id='user-distinct-id', + properties={ + '$set': {'name': 'Max Hedgehog'}, + '$set_once': {'initial_url': '/blog'} + } +) + +# Using contexts +from posthog import new_context, identify_context +with new_context(): + identify_context('user-distinct-id') + posthog.capture('event_name') +``` + +The person properties docs explain [the difference between `$set` and `$set_once`](https://posthog.com/docs/product-analytics/person-properties#what-is-the-difference-between-set-and-set_once). + +## Capture anonymous events + +To capture [anonymous events](https://posthog.com/docs/data/anonymous-vs-identified-events) without person profiles, set the event's `process_person_profile` option to `False`. Events captured with no context or explicit `distinct_id` are marked as personless, and have an auto-generated `distinct_id`: + +```python +posthog.capture( + event='event_name', + options={ + 'process_person_profile': False + } +) +``` + +## Alias a user + +Sometimes you want to assign multiple distinct IDs to a single user. This helps when your primary distinct ID is inaccessible. For example, a distinct ID used on the frontend isn't available in your backend. + +In this case, use `alias` to assign another distinct ID to the same user: + +```python +posthog.alias(previous_id='distinct_id', distinct_id='alias_id') +``` + +Read the docs on [alias](https://posthog.com/docs/product-analytics/identify#alias-assigning-multiple-distinct-ids-to-the-same-user) before you use this method. + +## Use contexts + +The SDK uses nested contexts to manage state that's shared across events. Contexts are the recommended way to manage things like "which user is taking this action" (through `identify_context`), rather than passing user state through your app's stack by hand. + +When events (including exceptions) are captured in a context, the event uses the user [distinct ID](https://posthog.com/docs/getting-started/identify-users), [session ID](https://posthog.com/docs/data/sessions), and tags that are set in the context. This is useful for adding properties to multiple events during a single user's interaction with your product. + +Enter a context with the `with` statement: + +```python +from posthog import new_context, tag, set_context_session, identify_context + +with new_context(): + tag("transaction_id", "abc123") + tag("some_arbitrary_value", {"tags": "can be dicts"}) + + # Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session + # See https://posthog.com/docs/data/sessions + set_context_session(session_id) + + # Setting the context-level distinct ID. See below for more details. + identify_context(user_id) + + # This event is captured with the distinct ID, session ID, and tags set above + posthog.capture("order_processed") +``` + +Contexts persist across function calls. If you enter one, call a function, and capture an event in the called function, the event uses the tags and session ID set in the parent context: + +```python +from posthog import new_context, tag + +def some_function(): + # When called from `outer_function`, this event is captured with the property some-key="value-4" + posthog.capture("order_processed") + + +def outer_function(): + with new_context(): + tag("some-key", "value-4") + some_function() + +``` + +Contexts are nested, so child contexts inherit tags added to a parent context. If you set the same tag in both a parent and a child context, the child's value overrides the parent's at event capture, and the parent context isn't affected. This nesting also applies to session IDs and distinct IDs. + +```python +from posthog import new_context, tag + +with new_context(): + tag("some-key", "value-1") + tag("some-other-key", "another-value") + with new_context(): + tag("some-key", "value-2") + # This event is captured with some-key="value-2" and some-other-key="another-value" + posthog.capture("order_processed") + + # This event is captured with some-key="value-1" and some-other-key="another-value" + posthog.capture("order_processed") + +``` + +To turn off this nesting, pass `fresh=True` to `new_context`: + +```python +from posthog import new_context, tag + +with new_context(fresh=True): + tag("some-key", "value-2") + # This event only has the property some-key="value-2" from the fresh context + posthog.capture("order_processed") + +``` + +> **Direct arguments win.** Distinct IDs, session IDs, and properties passed directly to `capture` and related functions override context state in the final event. + +## Identify a context + +Associate a context with a distinct ID by calling `posthog.identify_context`: + +```python +from posthog import identify_context + +identify_context("distinct-id") +``` + +Within a context associated with a distinct ID, all captured events are associated with that user. To override the distinct ID for a specific event, pass a `distinct_id` argument to `capture`: + +```python +from posthog import new_context, identify_context + +with new_context(): + identify_context("distinct-id") + posthog.capture("order_processed") # will be associated with distinct-id + posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-id +``` + +Pass the currently active distinct ID from the frontend to the backend with the `X-POSTHOG-DISTINCT-ID` header. The Django middleware extracts it and associates it with the request handler context for you. + +Read more in the [user identification docs](https://posthog.com/docs/product-analytics/identify). + +## Link a session + +Associate a context with a session ID by calling `posthog.set_context_session`. To link backend events to frontend sessions, use the session ID from the frontend SDK. PostHog session IDs are UUIDv7 strings. + +```python +from posthog import new_context, set_context_session +with new_context(): + set_context_session(request.get_header("X-POSTHOG-SESSION-ID")) +``` + +> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/config#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. + +A context with a session gives you two things: + +- **Backend events on the timeline.** You see them on the session timeline when you watch session replays. +- **Replays for backend errors.** You can watch the session replay of a user who triggered a backend exception in error tracking. + +Read more in the [sessions docs](https://posthog.com/docs/data/sessions). + +## Capture context exceptions + +By default, exceptions raised within a context are captured and show up in [error tracking](https://posthog.com/docs/error-tracking). To turn this off, pass `capture_exceptions=False` to `new_context`: + +```python +from posthog import new_context, tag + +with new_context(capture_exceptions=False): + tag("transaction_id", "abc123") + tag("some_arbitrary_value", {"tags": "can be dicts"}) + + # This event will be captured with the tags set above + posthog.capture("order_processed") + # This exception will not be captured + raise Exception("Order processing failed") +``` + +## Decorate a function + +The `scoped` decorator marks a whole function as being in a new context. It takes the same `fresh` and `capture_exceptions` arguments as `new_context`: + +```python +from posthog import scoped, identify_context + +@scoped(fresh=True) +def process_order(user, order_id): + identify_context(user.distinct_id) + posthog.capture("order_processed") # Associated with the user + raise Exception("Order processing failed") # This exception is also captured and associated with the user +``` + +## Use group analytics + +Group analytics lets you associate an event with a group, like a team or an organization. Read the [group analytics guide](https://posthog.com/docs/product-analytics/group-analytics) for more. + +> **Group analytics is a paid feature.** It isn't available on the free cloud plan. See the [pricing page](https://posthog.com/pricing). + +To capture an event and associate it with a group: + +```python +posthog.capture('some_event', groups={'company': 'company_id_in_your_db'}) +``` + +To update properties on a group: + +```python +posthog.group_identify('company', 'company_id_in_your_db', { + 'name': 'Awesome Inc.', + 'employees': 11 +}) +``` + +`name` is a special property. The PostHog UI uses it as the name of the group. If you don't set a `name` property, the UI shows the group ID instead. + +## Evaluate feature flags + +PostHog's [feature flags](https://posthog.com/docs/feature-flags) let you safely deploy and roll back new features, and target specific users and groups with them. + +The examples in the feature flag sections use the synchronous `Posthog` client. For `AsyncPosthog`, see [Evaluate flags with asyncio](#evaluate-flags-with-asyncio). The returned snapshot uses the same accessors. + +Call `posthog.evaluate_flags()` once for the user, then read values from the returned snapshot. + +For a boolean feature flag: + +```python +flags = posthog.evaluate_flags("distinct_id_of_your_user") + +if flags.is_enabled("flag-key"): + # Do something differently for this user + # Optional: fetch the payload + matched_flag_payload = flags.get_flag_payload("flag-key") +``` + +For a multivariate feature flag: + +```python +flags = posthog.evaluate_flags("distinct_id_of_your_user") + +enabled_variant = flags.get_flag("flag-key") + +if enabled_variant == "variant-key": # replace "variant-key" with the key of your variant + # Do something differently for this user + # Optional: fetch the payload + matched_flag_payload = flags.get_flag_payload("flag-key") +``` + +`flags.get_flag()` returns the variant string for multivariate flags, `True` for enabled boolean flags, `False` for disabled flags, and `None` when the evaluation didn't return the flag. + +> **Prefer `evaluate_flags()` for new code.** `posthog.feature_enabled()`, `posthog.get_feature_flag()`, `posthog.get_feature_flag_payload()`, and `posthog.capture(send_feature_flags=True)` still work during the migration period, but they're deprecated. + +## Attach flags to events + +To break down or filter events by a feature flag in your [insights](https://posthog.com/docs/product-analytics/insights), include feature flag information in those events. This attributes the flag value correctly to the event. + +> **Server-side only.** This step is only required for events captured with the server-side SDKs or the [API](https://posthog.com/docs/api). + +There are two ways to do it. The first is to pass the same `flags` object that you used for branching to `capture()`. This attaches the exact flag values from that evaluation and doesn't make another `/flags` request. + +```python +flags = posthog.evaluate_flags("distinct_id_of_your_user") + +if flags.is_enabled("flag-key"): + # Do something differently for this user + pass + +posthog.capture( + "event_name", + distinct_id="distinct_id_of_your_user", + flags=flags, +) +``` + +By default, this attaches every flag in the snapshot as `$feature/` properties and `$active_feature_flags`. + +To reduce event property bloat, pass a filtered snapshot: + +```python +# Attach only flags accessed with is_enabled() or get_flag() before this call +posthog.capture( + "event_name", + distinct_id="distinct_id_of_your_user", + flags=flags.only_accessed(), +) + +# Attach only specific flags +posthog.capture( + "event_name", + distinct_id="distinct_id_of_your_user", + flags=flags.only(["checkout-flow", "new-dashboard"]), +) +``` + +`only_accessed()` is order-dependent. If you call it before you access any flags with `is_enabled()` or `get_flag()`, no feature flag properties are attached. + +The second way is to include a `$feature/feature_flag_name` property by hand, with the variant key as its value: + +```python +posthog.capture( + "event_name", + distinct_id="distinct_id_of_the_user", + properties={ + # Replace feature-flag-key with your flag key and "variant-key" with the key of your variant + "$feature/feature-flag-key": "variant-key", + }, +) +``` + +## Evaluate specific flags + +By default, `posthog.evaluate_flags()` evaluates every flag for the user. If you only need a few flags, pass `flag_keys` to request only those flags: + +```python +flags = posthog.evaluate_flags( + "distinct_id_of_your_user", + flag_keys=["checkout-flow", "new-dashboard"], +) +``` + +## The flag called event + +`$feature_flag_called` events tell PostHog when a user accessed a flag, which powers [analytics and insights](https://posthog.com/docs/product-analytics/insights) on the flag. With `posthog.evaluate_flags()`, the SDK sends this event when you call `flags.is_enabled()` or `flags.get_flag()` for a flag. + +The SDK deduplicates these events per `(distinct_id, flag, value)` in a local cache. If you reinitialize the PostHog client, the cache resets and the SDK may send `$feature_flag_called` events again. PostHog handles duplicates, so they don't affect your analytics. + +`flags.get_flag_payload()` doesn't send `$feature_flag_called` events and doesn't count as an access for `only_accessed()`. + +## Override flag properties + +Sometimes you want to evaluate feature flags with [person properties](https://posthog.com/docs/product-analytics/person-properties), [groups](https://posthog.com/docs/product-analytics/group-analytics), or group properties that haven't been ingested yet, or were set incorrectly earlier. + +Pass them in the `person_properties`, `groups`, and `group_properties` arguments. PostHog then evaluates the flag with these values, rather than the properties stored in your project. + +```python +flags = posthog.evaluate_flags( + "distinct_id_of_the_user", + person_properties={"property_name": "value"}, + groups={ + "your_group_type": "your_group_id", + "another_group_type": "your_group_id", + }, + group_properties={ + "your_group_type": {"group_property_name": "value"}, + "another_group_type": {"group_property_name": "value"}, + }, +) + +if flags.is_enabled("flag-key"): + # Do something differently for this user +``` + +## Override GeoIP properties + +By default, a user's GeoIP properties come from the IP address they use to capture events on the frontend. You may want to override them when you evaluate feature flags. A common reason is that you don't use PostHog on your frontend, so the user has no GeoIP properties. + +To override them, include them in the `person_properties` argument next to your other person properties. This lets your backend evaluate flags with the client's location rather than your server's location. + +You can override these GeoIP properties: + +- `$geoip_country_code` +- `$geoip_country_name` +- `$geoip_city_name` +- `$geoip_city_confidence` +- `$geoip_continent_code` +- `$geoip_continent_name` +- `$geoip_latitude` +- `$geoip_longitude` +- `$geoip_postal_code` +- `$geoip_subdivision_1_code` +- `$geoip_subdivision_1_name` +- `$geoip_subdivision_2_code` +- `$geoip_subdivision_2_name` +- `$geoip_subdivision_3_code` +- `$geoip_subdivision_3_name` +- `$geoip_time_zone` + +## Set a flag timeout + +Set `feature_flags_request_timeout_seconds` when you initialize the client to set a flag request timeout. This keeps your code from blocking when PostHog's servers are slow to respond. The default is 3 seconds. + +```python +posthog = Posthog( + "", + host="", + feature_flags_request_timeout_seconds=3, # Time in seconds. Defaults to 3. +) +``` + +## Evaluate flags locally + +Evaluating feature flags takes a request to PostHog. With local evaluation, the SDK requests feature flag definitions on an interval and stores them locally, so you evaluate flags without more requests. + +Use local evaluation when you can. It resolves flags faster and with fewer API calls. The [local evaluation guide](https://posthog.com/docs/feature-flags/local-evaluation) shows how to set it up. + +In multi-worker or edge environments, you can cache flag definitions in Redis, Cloudflare KV, or another storage backend. This shares definitions across workers and coordinates fetches. See [local evaluation in distributed environments](https://posthog.com/docs/feature-flags/local-evaluation/distributed-environments?tab=Python). + +## Run experiments + +[Experiments](https://posthog.com/docs/experiments/start-here) use feature flags, so the code for an experiment is close to the feature flag code. This example uses the synchronous `Posthog` client: + +```python +flags = posthog.evaluate_flags("user_distinct_id") +variant = flags.get_flag("experiment-feature-flag-key") + +if variant == "variant-name": + # Do something +``` + +With `AsyncPosthog`, await the evaluation: `flags = await posthog.evaluate_flags("user_distinct_id")`. The rest of the snapshot access is the same. + +You can also [run experiments without feature flags](https://posthog.com/docs/experiments/running-experiments-without-feature-flags). + +## Capture LLM usage + +The SDK includes built-in AI observability. It captures LLM usage, performance, and more. See the [AI observability docs](https://posthog.com/docs/ai-observability) to set it up. + +## Track errors + +To [autocapture exceptions](https://posthog.com/docs/error-tracking/installation), set `enable_exception_autocapture` to `True` when you initialize the client: + +```python +from posthog import Posthog + +posthog = Posthog("", enable_exception_autocapture=True, ...) +``` + +You can also capture exceptions by hand with `capture_exception`: + +```python +posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties) +``` + +Contexts capture exceptions thrown inside them, unless you pass `capture_exceptions=False` to `new_context()`. + +## Capture code variables + +The SDK can capture the state of local variables when an exception occurs. This gives you a debugger-like view of your app's state at the time of the error: + +```python +posthog = Posthog( + "", + enable_exception_autocapture=True, + capture_exception_code_variables=True, +) +``` + +You can configure which variables are captured, masked, or ignored. See the [code variables docs](https://posthog.com/docs/error-tracking/code-variables/python) for the options. + +## Trace your services + +> **The span API is experimental.** Tracing is new in the SDK, available since 7.58.0, and its API can still change in a minor release. Spans you send are kept. It's the SDK surface that isn't frozen yet. + +Tracing records spans, which are timed units of work, so you can see where time went in a request and how work fans out across your services. Spans created inside a [context](#use-contexts) carry the person and session they belong to, so a slow trace links back to the person who experienced it. + +Tracing is off until you set the `traces` option. You don't need an OpenTelemetry dependency. For what you can do with spans once they arrive, see [Distributed tracing](https://posthog.com/docs/distributed-tracing/start-here). + +```python +from posthog import Posthog + +posthog = Posthog( + "", + host="", + traces={"service_name": "checkout-api"}, +) +``` + +If you use the module-level API instead, set `posthog.traces = {"service_name": "checkout-api"}` next to your other options, before you start the first span. + +Set `service_name`. PostHog groups operations by service and span name. + +Tracing works on the synchronous `Posthog` client and the module-level API. `AsyncPosthog` doesn't support it yet. + +## Create spans + +`start_span` returns a span. Use it in a `with` block to make it the active span for the block and end it when the block exits. Spans started inside the block nest under it. + +```python +with posthog.start_span("POST /checkout", kind="server") as span: + span.set_attribute("plan", user.plan) + + with posthog.start_span("create-order"): + order = create_order(cart) + with posthog.start_span("charge-card"): + stripe.charge(order) +``` + +If an exception escapes the block, the span records it, its status is set to `error`, and the exception propagates unchanged. `KeyboardInterrupt`, `GeneratorExit`, and `asyncio.CancelledError` still end the span but aren't recorded as failures. + +The recorded exception includes the stack trace, which contains file paths from your server. To keep those inside your process, remove `exception.stacktrace` in [`before_span_send`](#scrub-and-drop-spans). + +For work that can't wrap a block, call `start_span` without `with`. **A span started this way isn't active**, so spans started afterwards aren't its children unless you pass `parent` explicitly. You must also call `end()` yourself. + +```python +span = posthog.start_span("background-sync", attributes={"queue": "emails"}) +try: + # Explicitly parent a child to a span that isn't active. + child = posthog.start_span("send-batch", parent=span) + child.end() +finally: + span.end() +``` + +`get_active_span()` returns the span active in the current context, or `None` when there isn't one. + +The active span is tracked with [`contextvars`](https://docs.python.org/3/library/contextvars.html), so it carries across `await` in asyncio code. Threads don't reliably inherit it. Pass `parent=span` to continue the trace in a thread you start or a `ThreadPoolExecutor` task. A forked child process starts with no active span, so pass `parent` there too. + +`start_span` always returns a usable span, even when tracing is off, so your code never needs to check whether tracing is enabled. + +The `kind` argument says what the work is: `"internal"` (the default), `"server"` for an inbound request, `"client"` for an outbound call, and `"producer"` or `"consumer"` for queue work. + +`start_time` backdates the span's start. The server clamps a start more than 24 hours old to receive time. With `debug` on, the SDK prints a debug message when you pass one. + +## Name spans and attributes + +Span names are low-cardinality operation names, like `GET /users/:id`, not `GET /users/123`. Variable values belong in attributes. + +Strings, booleans, integers, and floats keep their type. Lists and dictionaries are sent as arrays and maps, but PostHog stores them as serialized strings. Setting an attribute to `None` removes it, and any other value is converted to a string. + +```python +with posthog.start_span("GET /users/:id", kind="server") as span: + span.set_attributes({"user.id": user_id, "db.rows": len(rows)}) + span.add_event("cache-miss") + + if not rows: + span.set_status("error", "user not found") +``` + +The [`Span`](#span) entry lists every method. Every method except `traceparent()`, `tracestate()`, and `end()` returns the span, so calls chain. Calls after `end()` are ignored. + +Use `record_exception(exception)` for exceptions you catch and handle. Use `update_name(name)` to replace the span name, for example once a route template resolves. + +## Trace across services + +Spans use [W3C Trace Context](https://www.w3.org/TR/trace-context/), so a trace can span several services. Pass an inbound `traceparent` header as `parent` to continue a trace another service started, and send `span.traceparent()` onward when you call out. For example, in a Flask `app.py`: + +```python +import requests +from flask import request + + +@app.post("/checkout") +def checkout(): + with posthog.start_span( + "POST /checkout", + kind="server", + parent=request.headers.get("traceparent"), + ) as span: + traceparent = span.traceparent() + + requests.post( + "https://payments.internal/charge", + headers={"traceparent": traceparent} if traceparent else {}, + ) + + return {"status": "ok"} +``` + +A malformed `traceparent` starts a new trace rather than raising. A missing one (`None`) falls back to the active span, if there is one. + +A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn't sample. A span is recorded and exported whichever way that flag is set. + +## Link traces to people + +Spans created inside a [context](#use-contexts) that has a distinct ID or session ID carry `posthogDistinctId` and `sessionId` attributes. These make a trace reachable from a person or a Session Replay recording. + +In Django, the [contexts middleware](https://posthog.com/docs/libraries/django#django-contexts-middleware) sets these for every request. Elsewhere, set them yourself: + +```python +from posthog import new_context, identify_context, set_context_session + +with new_context(): + identify_context(user.id) + set_context_session(session_id) + + with posthog.start_span("POST /checkout"): + process_order() +``` + +Spans created outside a context with those values omit the attributes. + +## Scrub and drop spans + +`before_span_send` runs on every finished span before it's queued for export. It receives the span as a dict with `name`, `kind`, `status`, `attributes`, `events`, `start_time_ns`, `end_time_ns`, `trace_id`, `span_id`, and `parent_span_id`. Edit it and return it, or return `None` to drop the span entirely. + +```python +def scrub_spans(span): + if span["attributes"].get("http.route") == "/health": + return None + + span["attributes"].pop("http.request.header.authorization", None) + return span + + +posthog = Posthog( + "", + host="", + traces={ + "service_name": "checkout-api", + "before_span_send": scrub_spans, + }, +) +``` + +Attributes are plain Python values, not the OTLP wire encoding. The hook runs after PostHog attaches `posthogDistinctId` and `sessionId`, so the hook can see and scrub those too. An exception's stack trace is on its event, under `event["attributes"]["exception.stacktrace"]`. + +- **IDs are read-only.** Rewriting `trace_id`, `span_id`, or `parent_span_id` would orphan child spans that have already been exported, so changes are reverted. +- **A raising hook drops the span.** The span isn't exported without scrubbing. +- **Hooks can chain.** Pass a list to run several hooks in order. The first one to return `None` stops the chain. +- **Hooks are regular functions.** An `async` hook drops every span. +- **A non-callable entry turns tracing off.** The client stops tracing rather than exporting spans the hook was meant to scrub. + +## The span limits + +A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there. + +Past the cap, the earliest attributes and events are kept. The number dropped is reported with the span, so a truncated span reads as truncated rather than as quietly incomplete. + +The attributes PostHog attaches itself, `posthogDistinctId` and `sessionId`, don't count toward the cap and are never dropped. A span at the limit still links back to its person and session. + +The event cap is absolute. An `exception` event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then raises keeps its `error` status but not the exception detail, and reports the loss as a dropped event. Raise `max_events_per_span` on spans that record many events and can also fail. + +The length bound reaches inside a value, including strings nested in lists and dictionaries. It applies to `exception.stacktrace` like any other attribute, so a long stack trace keeps its last 8192 characters. All four caps are re-applied after `before_span_send`, so a hook that enriches a span can't push it back over. + +## Configure tracing + +The `traces` dict takes these options: + +| Option | Default | Sets | +|---|---|---| +| `service_name` | Unset | Name of the service producing spans | +| `service_version` | Unset | Version of the service | +| `environment` | Unset | Deployment environment, like `production` | +| `resource_attributes` | Unset | Extra OTLP resource attributes | +| `flush_interval` | `5` | Seconds between exports of queued spans | +| `max_export_batch_size` | `512` | Maximum spans per request | +| `max_queue_size` | `2048` | Maximum spans held in memory | +| `max_live_spans` | `10000` | Maximum spans open at once | +| `max_span_age` | `3600` | Seconds before an open span counts as leaked | +| `before_span_send` | Unset | Hook to edit or drop each finished span | +| `max_attributes_per_span` | `128` | Maximum attributes you set on one span | +| `max_events_per_span` | `128` | Maximum events on one span | +| `max_attribute_value_length` | `8192` | Maximum characters in a string attribute value | + +Always set `service_name`. `resource_attributes` takes precedence over `service_name`, `service_version`, and `environment`. + +Spans beyond `max_queue_size` are dropped. At the `max_live_spans` limit, `start_span` returns a span that isn't recorded. Once that limit is reached, spans open longer than `max_span_age` seconds are treated as leaked and never exported. + +An invalid value falls back to its default with a warning. The exception is `before_span_send`: an entry that isn't callable turns tracing off. + +## Flush spans on exit + +Spans are exported on a background interval, even with `sync_mode` on. Both `flush()` and `shutdown()` export spans that have already ended. + +A span still open at `flush()` is exported once it ends. A span still open at `shutdown()` is discarded with a warning, so end your spans before you shut down. A `with` block does this for you. `shutdown()` gives queued spans up to 30 seconds to send. + +If your serverless handler reuses a client across invocations, call `flush()` before returning. It flushes events and ended spans concurrently without stopping the client. Reserve `shutdown()` for a disposable client or process exit. + +```python +def handler(event, context): + try: + with posthog.start_span("handler"): + do_work() + finally: + posthog.flush() +``` + +A script that exits without calling `shutdown()` still gets a brief best-effort flush at exit, but don't rely on it for spans you need. + +## Enable GeoIP properties + +By default, the SDK disregards the server IP, doesn't add GeoIP properties to events, and doesn't use them for feature flag evaluation. This has been the default since 3.0.0. GeoIP properties from a backend are based on your server's IP address rather than the user's, which resolves to the wrong location. + +To add GeoIP properties anyway, set `disable_geoip` to `False` when you initialize the client: + +```python +posthog = Posthog('api_key', disable_geoip=False) +``` + +This setting controls these properties: + +1. `$geoip_city_name` +2. `$geoip_country_name` +3. `$geoip_country_code` +4. `$geoip_continent_name` +5. `$geoip_continent_code` +6. `$geoip_postal_code` +7. `$geoip_time_zone` +8. `$geoip_subdivision_1_code` +9. `$geoip_subdivision_1_name` +10. `$geoip_subdivision_2_code` +11. `$geoip_subdivision_2_name` + +You can also enable or disable GeoIP for a single capture request: + +```python +posthog.capture('test_event', disable_geoip=True|False) +``` + +## Turn on debug mode + +If you don't see the events or the feature flag evaluations you expect, turn on debug mode to see what's happening. Set the `debug` option to `True`. This turns on verbose logs about the inner workings of the SDK. + +```python +posthog.debug = True +``` + +## Disable requests in tests + +To stop requests during tests, set the `disabled` option to `True`. The SDK then captures no events and sends no requests to PostHog. + +```python +if settings.TEST: + posthog.disabled = True +``` + +## Configure connections + +The SDK uses HTTP connection pooling for better performance. You typically don't need to change these settings. In some environments, like behind NAT gateways, pooled connections can be terminated non-gracefully, which causes request failures. + +Call the following settings during initialization, before the SDK makes any API requests. + +TCP keepalive probes help keep network infrastructure from dropping idle connections. This is the recommended fix for most cases where idle connections are terminated. + +```python +import posthog + +posthog.enable_keep_alive() +``` + +This turns on TCP keepalive with a 60 second idle time, a 60 second probe interval, and 3 probes before timeout. + +If you need each request to use a fresh connection, turn off connection reuse entirely. This adds overhead per request. + +```python +import posthog + +posthog.disable_connection_reuse() +``` + +For advanced cases, set arbitrary socket options on the underlying HTTP connection: + +```python +import socket +import posthog + +posthog.set_socket_options([ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + # Add additional socket options as needed +]) +``` + +Pass `None` to `set_socket_options()` to reset to the default behavior. + +## Filter events before sending + +Use `before_send` to modify or drop events before they're queued for delivery. Return the modified event dictionary to send it, or `None` to drop it. + +```python +from typing import Any + +import posthog + + +def scrub_pii(event: dict[str, Any]) -> dict[str, Any] | None: + properties = event.get("properties", {}) + + if "email" in properties: + email = properties["email"] + properties["email"] = f"***@{email.split('@', 1)[1]}" if "@" in email else "***" + + if event.get("event") == "test_event": + return None + + return event + + +client = posthog.Client( + "", + before_send=scrub_pii, +) +``` + +If your callback raises an exception, the SDK logs the error and drops the event. + +## Run historical migrations + +You can use the SDK to run [historical migrations](https://posthog.com/docs/migrate) of data into PostHog. Set the `historical_migration` option to `True` when you initialize the client: + +```python +from posthog import Posthog +from datetime import datetime + +posthog = Posthog( + '', + host='', + debug=True, + historical_migration=True +) + +events = [ + { + "event": "batched_event_name", + "distinct_id": "user_id", + "timestamp": datetime.fromisoformat("2024-04-02T12:00:00+00:00"), + "properties": {"account_type": "pro"} + }, + { + "event": "batched_event_name", + "distinct_id": "user_id", + "timestamp": datetime.fromisoformat("2024-04-03T09:30:00+00:00"), + "properties": {"account_type": "pro"} + } +] + +for event in events: + posthog.capture( + event["event"], + distinct_id=event["distinct_id"], + properties=event["properties"], + timestamp=event["timestamp"], + ) + +posthog.shutdown() +``` + +## Run in serverless + +By default, the synchronous `Posthog` client buffers events before it sends them to the capture endpoint. A serverless platform, like Render or AWS Lambda, can freeze or terminate the runtime before background delivery finishes. You have three options: + +- **A client shared across invocations.** Call `posthog.flush()` before the handler returns, including when your code throws. This blocking call attempts to deliver queued events without stopping the client. Its `timeout_seconds` argument defaults to `10` seconds. +- **A disposable client or process exit.** Call `posthog.shutdown()` after its final work. It attempts to deliver queued events and cleans up the client. Don't reuse the client afterward. `shutdown()` takes no timeout argument. +- **Sync mode.** Set `sync_mode` when you initialize the client, so each `posthog.capture()` call attempts delivery before it returns. + +These calls don't guarantee that PostHog received every event. + +If you [trace your services](#trace-your-services), `sync_mode` doesn't apply to spans. For a shared client, call `posthog.flush()` before the handler returns. See [Flush spans on exit](#flush-spans-on-exit). + +With `AsyncPosthog`, keep one client for the lifetime of your app. Use buffered `capture()` by default, or `await capture_immediate()` when one invocation must wait for an event's delivery attempt. Call `await posthog.shutdown()` once during app cleanup. Don't shut down the client after each request. + +## Use Django or Flask + +See the [Django docs](https://posthog.com/docs/libraries/django) to set up PostHog in Django. The [Django contexts middleware](https://posthog.com/docs/libraries/django#django-contexts-middleware) adds request properties and the authenticated user's identity to events captured during a request. + +Since 7.71.0, the middleware also reads the browser session and distinct ID headers by default. Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` to turn off header extraction. + +For Flask, see the [Flask docs](https://posthog.com/docs/libraries/flask). + +## Add ASGI middleware + +The SDK includes framework-independent ASGI middleware for FastAPI, Starlette, Litestar, and raw ASGI apps, available since 7.69.0. It creates a PostHog context for each HTTP or WebSocket request, adds request properties to captured events, and captures exceptions that escape the app. + +For FastAPI or Starlette, add the middleware to your app: + +```python +from fastapi import FastAPI +from posthog import Posthog +from posthog.integrations.asgi import PosthogASGIMiddleware + +posthog = Posthog( + "", + host="", + enable_exception_autocapture=True, +) + +app = FastAPI() +app.add_middleware(PosthogASGIMiddleware, client=posthog) +``` + +The middleware's `capture_exceptions` option defaults to `None`, which inherits the client's `enable_exception_autocapture` setting. Set it to `True` or `False` to override the client. + +Use `request_filter` to skip selected requests and `extra_properties` to add properties from the ASGI scope. Both callbacks can be synchronous or asynchronous. + +The middleware reads `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` by default, since 7.71.0. Set `capture_tracing_headers=False` to turn off header extraction. Clients can control these headers. Use them only as analytics context, never for authentication or authorization. + +To instrument another ASGI framework or a raw ASGI app, wrap the app directly: + +```python +app = PosthogASGIMiddleware(app, client=posthog) +``` + +## The package names + +The open source [PostHog](https://github.com/PostHog/posthog) project shares the `posthog` module name. To avoid a module collision, there's a `posthoganalytics` package, mostly for internal use. It's the exact same SDK. + +This library is largely based on the `analytics-python` package. + +## The supported versions + +The Python SDK 8.x.x needs Python 3.10 or later. To pin to 8.x.x, run `pip install "posthog>=8,<9"`. + +8.0.0 sends events with [capture v1](https://posthog.com/docs/api/capture-v1) and adds event options, like `options={"process_person_profile": False}`. Earlier versions don't take `options`. Read the [7.x.x to 8.0.0 migration guide](https://posthog.com/docs/libraries/python/migration) before you upgrade. If you stay on 7.x.x, see the [7.x.x reference](https://posthog.com/docs/libraries/python/7.x.x). + +Python 3.9 isn't supported on 7.0.0 and later. Pin to the 6.x.x line with `pip install 'posthog<7'`, where 6.9.3 is the final release. + +Event capture, the context API (`new_context`, `identify_context`, `set_context_session`), and `PosthogContextMiddleware` are identical on 6.9.3 and 7.0.0. 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. The middleware identifies the request context from the `X-POSTHOG-DISTINCT-ID` header and falls back to the authenticated user on both lines. + +Later 7.x.x releases add what the 6.x.x line doesn't get, like the Celery integration, tracing header sanitization, and `set_context_device_id`. + +They also changed the middleware's own captured properties. 7.x.x sends the request IP as `$ip`, where 6.9.3 sends it as `$ip_address`. 7.x.x also captures `$request_path`, `$raw_user_agent`, and the authenticated user's `email`. + +6.0.0 introduced the contexts API and breaking changes. If you upgrade from 5.x.x to 6.x.x, read the [6.x.x migration guide](https://posthog.com/tutorials/python-v6-migration) first. From 7abca08d29143558fa43e5b7fbc9e1cb95bee451 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:39:10 -0500 Subject: [PATCH 08/27] docs: link the JavaScript Web reference instead of its retired page Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 0e9f7554bf59b590c017293752ae622912374e6d) --- docs/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 1c17bd2c3..865eaec0a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -323,7 +323,7 @@ with new_context(): set_context_session(request.get_header("X-POSTHOG-SESSION-ID")) ``` -> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/config#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. +> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/reference#add-tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. A context with a session gives you two things: From c8843cd251da005a898f19a6f506f44ec511331f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:51:20 -0500 Subject: [PATCH 09/27] Revert "docs: link the JavaScript Web reference instead of its retired page" This reverts commit 7abca08d29143558fa43e5b7fbc9e1cb95bee451. --- docs/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 865eaec0a..1c17bd2c3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -323,7 +323,7 @@ with new_context(): set_context_session(request.get_header("X-POSTHOG-SESSION-ID")) ``` -> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/reference#add-tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. +> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/config#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. A context with a session gives you two things: From 15faa609bfad589160cd98fd0156eec9d93f1880 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:51:21 -0500 Subject: [PATCH 10/27] Revert "docs: move the posthog.com Python SDK pages into the reference" This reverts commit bc7bc9c66639618a761c111942e813ab00b9b3e0. --- docs/getting-started.md | 1035 --------------------------------------- 1 file changed, 1035 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 1c17bd2c3..27d91f176 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,1038 +37,3 @@ posthog.shutdown() ``` In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [AsyncClient](#asyncclient). - -## Install with the wizard - -Run this command in your project directory to add PostHog to your Python project. The wizard detects your framework, installs the SDK, and sets up your first events. - -```bash -npx -y @posthog/wizard@latest -``` - -Or set it up by hand with the steps above. - -## Use the asyncio client - -The SDK includes an asyncio-native client, available since 7.45.0. Keep using `Posthog` in synchronous apps. For an asyncio app, install the optional async dependencies: - -```bash -pip install "posthog[async]>=8,<9" -``` - -Import `AsyncPosthog`, the customer-facing name for `AsyncClient`. Both names provide the same async context manager and lifecycle methods. Keep one client for the lifetime of your app. For example, use a FastAPI lifespan handler: - -```python -import os -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from posthog import AsyncPosthog - -@asynccontextmanager -async def lifespan(app: FastAPI): - async with AsyncPosthog( - os.environ["POSTHOG_PROJECT_TOKEN"], - host=os.environ["POSTHOG_HOST"], - secret_key=os.environ.get("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY"), - ) as posthog: - app.state.posthog = posthog - yield - -app = FastAPI(lifespan=lifespan) -``` - -Exiting the context calls `shutdown()`. This flushes buffered events, waits for in-flight operations, stops the workers, and closes the HTTP transport. - -If you don't use the context manager, call `await posthog.shutdown()` during app shutdown. `await posthog.join()` has the same effect. - -## Capture without blocking - -With `AsyncPosthog`, `capture()` queues an event and returns without waiting for a network request. Don't await it: - -```python -posthog.capture( - "event_name", - distinct_id="user-distinct-id", - properties={"source": "fastapi"}, -) -``` - -Use `capture_immediate()` when your code must wait for that event's delivery attempt: - -```python -capture_id = await posthog.capture_immediate( - "event_name", - distinct_id="user-distinct-id", -) -``` - -## Evaluate flags with asyncio - -Await `evaluate_flags()` once, then use its snapshot with synchronous in-memory accessors. Pass the same snapshot to `capture()` to attach the exact values used for branching without another feature flag request: - -```python -flags = await posthog.evaluate_flags("user-distinct-id") - -if flags.is_enabled("new-checkout"): - # Show the new checkout - pass - -posthog.capture( - "checkout started", - distinct_id="user-distinct-id", - flags=flags, -) -``` - -The snapshot provides synchronous `is_enabled()`, `get_flag()`, and `get_flag_payload()` accessors. The awaited `evaluate_flags()` call also accepts `groups`, `person_properties`, `group_properties`, `disable_geoip`, `flag_keys`, and `device_id` arguments. - -## Fetch remote config - -Initialize the client with a server-side [secret API key](https://posthog.com/docs/feature-flags/remote-config#step-1-get-a-secret-api-key) as `secret_key`, then await the remote config request: - -```python -config = await posthog.get_remote_config_payload("landing-page-config") -``` - -See [Remote config](https://posthog.com/docs/feature-flags/remote-config) for setup and security details. - -## Identify users - -> **Identifying users is required.** Backend events need a `distinct_id` to associate events with the correct user. - -In Python, you can do this through a context. All event captures in the same context are tagged with the correct `distinct_id`. Typically, you set a fresh context and identify at the top of each route: - -```python -from posthog import new_context, identify_context, capture - -@app.get("/foo") -def foo(current_user: User = Depends(get_current_user)): - with new_context(): # Set context at the top of a route - identify_context(current_user.id) - capture("foo_viewed") - return {"status": "ok"} -``` - -When possible, write a small piece of middleware that resolves your authenticated user, wraps a context around the request, and identifies it. Every `capture()` downstream is then attributed to that user. The SDK's Django middleware does this for you, and you can replicate it in plain Python. - -## Send custom events - -Send custom events with `capture`. Events captured with no context and no explicit `distinct_id` are marked as personless and get an auto-generated `distinct_id`. The [Capture](#capture) entry has the examples. - -> **Name events as `[object] [verb]`.** `[object]` is the entity the behavior relates to, and `[verb]` is the behavior itself. For example, `project created`, `user signed up`, or `invite sent`. - -You can define event schemas with typed properties and generate type-safe code with [schema management](https://posthog.com/docs/product-analytics/schema-management). - -To include more information with an event, pass a [properties](https://posthog.com/docs/data/events#event-properties) dictionary. - -If you run PostHog on your backend only and don't capture events from your frontend, send `$pageview` events from your backend with a `$current_url` property. - -## Set person properties - -The SDK captures identified events if the current context is identified or if you pass a distinct ID explicitly. These create [person profiles](https://posthog.com/docs/data/persons). To set [person properties](https://posthog.com/docs/product-analytics/person-properties) in these profiles, include them when you capture an event: - -```python -# Passing a distinct id explicitly -posthog.capture( - 'event_name', - distinct_id='user-distinct-id', - properties={ - '$set': {'name': 'Max Hedgehog'}, - '$set_once': {'initial_url': '/blog'} - } -) - -# Using contexts -from posthog import new_context, identify_context -with new_context(): - identify_context('user-distinct-id') - posthog.capture('event_name') -``` - -The person properties docs explain [the difference between `$set` and `$set_once`](https://posthog.com/docs/product-analytics/person-properties#what-is-the-difference-between-set-and-set_once). - -## Capture anonymous events - -To capture [anonymous events](https://posthog.com/docs/data/anonymous-vs-identified-events) without person profiles, set the event's `process_person_profile` option to `False`. Events captured with no context or explicit `distinct_id` are marked as personless, and have an auto-generated `distinct_id`: - -```python -posthog.capture( - event='event_name', - options={ - 'process_person_profile': False - } -) -``` - -## Alias a user - -Sometimes you want to assign multiple distinct IDs to a single user. This helps when your primary distinct ID is inaccessible. For example, a distinct ID used on the frontend isn't available in your backend. - -In this case, use `alias` to assign another distinct ID to the same user: - -```python -posthog.alias(previous_id='distinct_id', distinct_id='alias_id') -``` - -Read the docs on [alias](https://posthog.com/docs/product-analytics/identify#alias-assigning-multiple-distinct-ids-to-the-same-user) before you use this method. - -## Use contexts - -The SDK uses nested contexts to manage state that's shared across events. Contexts are the recommended way to manage things like "which user is taking this action" (through `identify_context`), rather than passing user state through your app's stack by hand. - -When events (including exceptions) are captured in a context, the event uses the user [distinct ID](https://posthog.com/docs/getting-started/identify-users), [session ID](https://posthog.com/docs/data/sessions), and tags that are set in the context. This is useful for adding properties to multiple events during a single user's interaction with your product. - -Enter a context with the `with` statement: - -```python -from posthog import new_context, tag, set_context_session, identify_context - -with new_context(): - tag("transaction_id", "abc123") - tag("some_arbitrary_value", {"tags": "can be dicts"}) - - # Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session - # See https://posthog.com/docs/data/sessions - set_context_session(session_id) - - # Setting the context-level distinct ID. See below for more details. - identify_context(user_id) - - # This event is captured with the distinct ID, session ID, and tags set above - posthog.capture("order_processed") -``` - -Contexts persist across function calls. If you enter one, call a function, and capture an event in the called function, the event uses the tags and session ID set in the parent context: - -```python -from posthog import new_context, tag - -def some_function(): - # When called from `outer_function`, this event is captured with the property some-key="value-4" - posthog.capture("order_processed") - - -def outer_function(): - with new_context(): - tag("some-key", "value-4") - some_function() - -``` - -Contexts are nested, so child contexts inherit tags added to a parent context. If you set the same tag in both a parent and a child context, the child's value overrides the parent's at event capture, and the parent context isn't affected. This nesting also applies to session IDs and distinct IDs. - -```python -from posthog import new_context, tag - -with new_context(): - tag("some-key", "value-1") - tag("some-other-key", "another-value") - with new_context(): - tag("some-key", "value-2") - # This event is captured with some-key="value-2" and some-other-key="another-value" - posthog.capture("order_processed") - - # This event is captured with some-key="value-1" and some-other-key="another-value" - posthog.capture("order_processed") - -``` - -To turn off this nesting, pass `fresh=True` to `new_context`: - -```python -from posthog import new_context, tag - -with new_context(fresh=True): - tag("some-key", "value-2") - # This event only has the property some-key="value-2" from the fresh context - posthog.capture("order_processed") - -``` - -> **Direct arguments win.** Distinct IDs, session IDs, and properties passed directly to `capture` and related functions override context state in the final event. - -## Identify a context - -Associate a context with a distinct ID by calling `posthog.identify_context`: - -```python -from posthog import identify_context - -identify_context("distinct-id") -``` - -Within a context associated with a distinct ID, all captured events are associated with that user. To override the distinct ID for a specific event, pass a `distinct_id` argument to `capture`: - -```python -from posthog import new_context, identify_context - -with new_context(): - identify_context("distinct-id") - posthog.capture("order_processed") # will be associated with distinct-id - posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-id -``` - -Pass the currently active distinct ID from the frontend to the backend with the `X-POSTHOG-DISTINCT-ID` header. The Django middleware extracts it and associates it with the request handler context for you. - -Read more in the [user identification docs](https://posthog.com/docs/product-analytics/identify). - -## Link a session - -Associate a context with a session ID by calling `posthog.set_context_session`. To link backend events to frontend sessions, use the session ID from the frontend SDK. PostHog session IDs are UUIDv7 strings. - -```python -from posthog import new_context, set_context_session -with new_context(): - set_context_session(request.get_header("X-POSTHOG-SESSION-ID")) -``` - -> **Using PostHog on your frontend too?** The JavaScript Web SDK generates a session ID for you. Configure [`tracing_headers`](https://posthog.com/docs/libraries/js/config#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests. You then extract the header in your request handler. The Django middleware does this for you. - -A context with a session gives you two things: - -- **Backend events on the timeline.** You see them on the session timeline when you watch session replays. -- **Replays for backend errors.** You can watch the session replay of a user who triggered a backend exception in error tracking. - -Read more in the [sessions docs](https://posthog.com/docs/data/sessions). - -## Capture context exceptions - -By default, exceptions raised within a context are captured and show up in [error tracking](https://posthog.com/docs/error-tracking). To turn this off, pass `capture_exceptions=False` to `new_context`: - -```python -from posthog import new_context, tag - -with new_context(capture_exceptions=False): - tag("transaction_id", "abc123") - tag("some_arbitrary_value", {"tags": "can be dicts"}) - - # This event will be captured with the tags set above - posthog.capture("order_processed") - # This exception will not be captured - raise Exception("Order processing failed") -``` - -## Decorate a function - -The `scoped` decorator marks a whole function as being in a new context. It takes the same `fresh` and `capture_exceptions` arguments as `new_context`: - -```python -from posthog import scoped, identify_context - -@scoped(fresh=True) -def process_order(user, order_id): - identify_context(user.distinct_id) - posthog.capture("order_processed") # Associated with the user - raise Exception("Order processing failed") # This exception is also captured and associated with the user -``` - -## Use group analytics - -Group analytics lets you associate an event with a group, like a team or an organization. Read the [group analytics guide](https://posthog.com/docs/product-analytics/group-analytics) for more. - -> **Group analytics is a paid feature.** It isn't available on the free cloud plan. See the [pricing page](https://posthog.com/pricing). - -To capture an event and associate it with a group: - -```python -posthog.capture('some_event', groups={'company': 'company_id_in_your_db'}) -``` - -To update properties on a group: - -```python -posthog.group_identify('company', 'company_id_in_your_db', { - 'name': 'Awesome Inc.', - 'employees': 11 -}) -``` - -`name` is a special property. The PostHog UI uses it as the name of the group. If you don't set a `name` property, the UI shows the group ID instead. - -## Evaluate feature flags - -PostHog's [feature flags](https://posthog.com/docs/feature-flags) let you safely deploy and roll back new features, and target specific users and groups with them. - -The examples in the feature flag sections use the synchronous `Posthog` client. For `AsyncPosthog`, see [Evaluate flags with asyncio](#evaluate-flags-with-asyncio). The returned snapshot uses the same accessors. - -Call `posthog.evaluate_flags()` once for the user, then read values from the returned snapshot. - -For a boolean feature flag: - -```python -flags = posthog.evaluate_flags("distinct_id_of_your_user") - -if flags.is_enabled("flag-key"): - # Do something differently for this user - # Optional: fetch the payload - matched_flag_payload = flags.get_flag_payload("flag-key") -``` - -For a multivariate feature flag: - -```python -flags = posthog.evaluate_flags("distinct_id_of_your_user") - -enabled_variant = flags.get_flag("flag-key") - -if enabled_variant == "variant-key": # replace "variant-key" with the key of your variant - # Do something differently for this user - # Optional: fetch the payload - matched_flag_payload = flags.get_flag_payload("flag-key") -``` - -`flags.get_flag()` returns the variant string for multivariate flags, `True` for enabled boolean flags, `False` for disabled flags, and `None` when the evaluation didn't return the flag. - -> **Prefer `evaluate_flags()` for new code.** `posthog.feature_enabled()`, `posthog.get_feature_flag()`, `posthog.get_feature_flag_payload()`, and `posthog.capture(send_feature_flags=True)` still work during the migration period, but they're deprecated. - -## Attach flags to events - -To break down or filter events by a feature flag in your [insights](https://posthog.com/docs/product-analytics/insights), include feature flag information in those events. This attributes the flag value correctly to the event. - -> **Server-side only.** This step is only required for events captured with the server-side SDKs or the [API](https://posthog.com/docs/api). - -There are two ways to do it. The first is to pass the same `flags` object that you used for branching to `capture()`. This attaches the exact flag values from that evaluation and doesn't make another `/flags` request. - -```python -flags = posthog.evaluate_flags("distinct_id_of_your_user") - -if flags.is_enabled("flag-key"): - # Do something differently for this user - pass - -posthog.capture( - "event_name", - distinct_id="distinct_id_of_your_user", - flags=flags, -) -``` - -By default, this attaches every flag in the snapshot as `$feature/` properties and `$active_feature_flags`. - -To reduce event property bloat, pass a filtered snapshot: - -```python -# Attach only flags accessed with is_enabled() or get_flag() before this call -posthog.capture( - "event_name", - distinct_id="distinct_id_of_your_user", - flags=flags.only_accessed(), -) - -# Attach only specific flags -posthog.capture( - "event_name", - distinct_id="distinct_id_of_your_user", - flags=flags.only(["checkout-flow", "new-dashboard"]), -) -``` - -`only_accessed()` is order-dependent. If you call it before you access any flags with `is_enabled()` or `get_flag()`, no feature flag properties are attached. - -The second way is to include a `$feature/feature_flag_name` property by hand, with the variant key as its value: - -```python -posthog.capture( - "event_name", - distinct_id="distinct_id_of_the_user", - properties={ - # Replace feature-flag-key with your flag key and "variant-key" with the key of your variant - "$feature/feature-flag-key": "variant-key", - }, -) -``` - -## Evaluate specific flags - -By default, `posthog.evaluate_flags()` evaluates every flag for the user. If you only need a few flags, pass `flag_keys` to request only those flags: - -```python -flags = posthog.evaluate_flags( - "distinct_id_of_your_user", - flag_keys=["checkout-flow", "new-dashboard"], -) -``` - -## The flag called event - -`$feature_flag_called` events tell PostHog when a user accessed a flag, which powers [analytics and insights](https://posthog.com/docs/product-analytics/insights) on the flag. With `posthog.evaluate_flags()`, the SDK sends this event when you call `flags.is_enabled()` or `flags.get_flag()` for a flag. - -The SDK deduplicates these events per `(distinct_id, flag, value)` in a local cache. If you reinitialize the PostHog client, the cache resets and the SDK may send `$feature_flag_called` events again. PostHog handles duplicates, so they don't affect your analytics. - -`flags.get_flag_payload()` doesn't send `$feature_flag_called` events and doesn't count as an access for `only_accessed()`. - -## Override flag properties - -Sometimes you want to evaluate feature flags with [person properties](https://posthog.com/docs/product-analytics/person-properties), [groups](https://posthog.com/docs/product-analytics/group-analytics), or group properties that haven't been ingested yet, or were set incorrectly earlier. - -Pass them in the `person_properties`, `groups`, and `group_properties` arguments. PostHog then evaluates the flag with these values, rather than the properties stored in your project. - -```python -flags = posthog.evaluate_flags( - "distinct_id_of_the_user", - person_properties={"property_name": "value"}, - groups={ - "your_group_type": "your_group_id", - "another_group_type": "your_group_id", - }, - group_properties={ - "your_group_type": {"group_property_name": "value"}, - "another_group_type": {"group_property_name": "value"}, - }, -) - -if flags.is_enabled("flag-key"): - # Do something differently for this user -``` - -## Override GeoIP properties - -By default, a user's GeoIP properties come from the IP address they use to capture events on the frontend. You may want to override them when you evaluate feature flags. A common reason is that you don't use PostHog on your frontend, so the user has no GeoIP properties. - -To override them, include them in the `person_properties` argument next to your other person properties. This lets your backend evaluate flags with the client's location rather than your server's location. - -You can override these GeoIP properties: - -- `$geoip_country_code` -- `$geoip_country_name` -- `$geoip_city_name` -- `$geoip_city_confidence` -- `$geoip_continent_code` -- `$geoip_continent_name` -- `$geoip_latitude` -- `$geoip_longitude` -- `$geoip_postal_code` -- `$geoip_subdivision_1_code` -- `$geoip_subdivision_1_name` -- `$geoip_subdivision_2_code` -- `$geoip_subdivision_2_name` -- `$geoip_subdivision_3_code` -- `$geoip_subdivision_3_name` -- `$geoip_time_zone` - -## Set a flag timeout - -Set `feature_flags_request_timeout_seconds` when you initialize the client to set a flag request timeout. This keeps your code from blocking when PostHog's servers are slow to respond. The default is 3 seconds. - -```python -posthog = Posthog( - "", - host="", - feature_flags_request_timeout_seconds=3, # Time in seconds. Defaults to 3. -) -``` - -## Evaluate flags locally - -Evaluating feature flags takes a request to PostHog. With local evaluation, the SDK requests feature flag definitions on an interval and stores them locally, so you evaluate flags without more requests. - -Use local evaluation when you can. It resolves flags faster and with fewer API calls. The [local evaluation guide](https://posthog.com/docs/feature-flags/local-evaluation) shows how to set it up. - -In multi-worker or edge environments, you can cache flag definitions in Redis, Cloudflare KV, or another storage backend. This shares definitions across workers and coordinates fetches. See [local evaluation in distributed environments](https://posthog.com/docs/feature-flags/local-evaluation/distributed-environments?tab=Python). - -## Run experiments - -[Experiments](https://posthog.com/docs/experiments/start-here) use feature flags, so the code for an experiment is close to the feature flag code. This example uses the synchronous `Posthog` client: - -```python -flags = posthog.evaluate_flags("user_distinct_id") -variant = flags.get_flag("experiment-feature-flag-key") - -if variant == "variant-name": - # Do something -``` - -With `AsyncPosthog`, await the evaluation: `flags = await posthog.evaluate_flags("user_distinct_id")`. The rest of the snapshot access is the same. - -You can also [run experiments without feature flags](https://posthog.com/docs/experiments/running-experiments-without-feature-flags). - -## Capture LLM usage - -The SDK includes built-in AI observability. It captures LLM usage, performance, and more. See the [AI observability docs](https://posthog.com/docs/ai-observability) to set it up. - -## Track errors - -To [autocapture exceptions](https://posthog.com/docs/error-tracking/installation), set `enable_exception_autocapture` to `True` when you initialize the client: - -```python -from posthog import Posthog - -posthog = Posthog("", enable_exception_autocapture=True, ...) -``` - -You can also capture exceptions by hand with `capture_exception`: - -```python -posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties) -``` - -Contexts capture exceptions thrown inside them, unless you pass `capture_exceptions=False` to `new_context()`. - -## Capture code variables - -The SDK can capture the state of local variables when an exception occurs. This gives you a debugger-like view of your app's state at the time of the error: - -```python -posthog = Posthog( - "", - enable_exception_autocapture=True, - capture_exception_code_variables=True, -) -``` - -You can configure which variables are captured, masked, or ignored. See the [code variables docs](https://posthog.com/docs/error-tracking/code-variables/python) for the options. - -## Trace your services - -> **The span API is experimental.** Tracing is new in the SDK, available since 7.58.0, and its API can still change in a minor release. Spans you send are kept. It's the SDK surface that isn't frozen yet. - -Tracing records spans, which are timed units of work, so you can see where time went in a request and how work fans out across your services. Spans created inside a [context](#use-contexts) carry the person and session they belong to, so a slow trace links back to the person who experienced it. - -Tracing is off until you set the `traces` option. You don't need an OpenTelemetry dependency. For what you can do with spans once they arrive, see [Distributed tracing](https://posthog.com/docs/distributed-tracing/start-here). - -```python -from posthog import Posthog - -posthog = Posthog( - "", - host="", - traces={"service_name": "checkout-api"}, -) -``` - -If you use the module-level API instead, set `posthog.traces = {"service_name": "checkout-api"}` next to your other options, before you start the first span. - -Set `service_name`. PostHog groups operations by service and span name. - -Tracing works on the synchronous `Posthog` client and the module-level API. `AsyncPosthog` doesn't support it yet. - -## Create spans - -`start_span` returns a span. Use it in a `with` block to make it the active span for the block and end it when the block exits. Spans started inside the block nest under it. - -```python -with posthog.start_span("POST /checkout", kind="server") as span: - span.set_attribute("plan", user.plan) - - with posthog.start_span("create-order"): - order = create_order(cart) - with posthog.start_span("charge-card"): - stripe.charge(order) -``` - -If an exception escapes the block, the span records it, its status is set to `error`, and the exception propagates unchanged. `KeyboardInterrupt`, `GeneratorExit`, and `asyncio.CancelledError` still end the span but aren't recorded as failures. - -The recorded exception includes the stack trace, which contains file paths from your server. To keep those inside your process, remove `exception.stacktrace` in [`before_span_send`](#scrub-and-drop-spans). - -For work that can't wrap a block, call `start_span` without `with`. **A span started this way isn't active**, so spans started afterwards aren't its children unless you pass `parent` explicitly. You must also call `end()` yourself. - -```python -span = posthog.start_span("background-sync", attributes={"queue": "emails"}) -try: - # Explicitly parent a child to a span that isn't active. - child = posthog.start_span("send-batch", parent=span) - child.end() -finally: - span.end() -``` - -`get_active_span()` returns the span active in the current context, or `None` when there isn't one. - -The active span is tracked with [`contextvars`](https://docs.python.org/3/library/contextvars.html), so it carries across `await` in asyncio code. Threads don't reliably inherit it. Pass `parent=span` to continue the trace in a thread you start or a `ThreadPoolExecutor` task. A forked child process starts with no active span, so pass `parent` there too. - -`start_span` always returns a usable span, even when tracing is off, so your code never needs to check whether tracing is enabled. - -The `kind` argument says what the work is: `"internal"` (the default), `"server"` for an inbound request, `"client"` for an outbound call, and `"producer"` or `"consumer"` for queue work. - -`start_time` backdates the span's start. The server clamps a start more than 24 hours old to receive time. With `debug` on, the SDK prints a debug message when you pass one. - -## Name spans and attributes - -Span names are low-cardinality operation names, like `GET /users/:id`, not `GET /users/123`. Variable values belong in attributes. - -Strings, booleans, integers, and floats keep their type. Lists and dictionaries are sent as arrays and maps, but PostHog stores them as serialized strings. Setting an attribute to `None` removes it, and any other value is converted to a string. - -```python -with posthog.start_span("GET /users/:id", kind="server") as span: - span.set_attributes({"user.id": user_id, "db.rows": len(rows)}) - span.add_event("cache-miss") - - if not rows: - span.set_status("error", "user not found") -``` - -The [`Span`](#span) entry lists every method. Every method except `traceparent()`, `tracestate()`, and `end()` returns the span, so calls chain. Calls after `end()` are ignored. - -Use `record_exception(exception)` for exceptions you catch and handle. Use `update_name(name)` to replace the span name, for example once a route template resolves. - -## Trace across services - -Spans use [W3C Trace Context](https://www.w3.org/TR/trace-context/), so a trace can span several services. Pass an inbound `traceparent` header as `parent` to continue a trace another service started, and send `span.traceparent()` onward when you call out. For example, in a Flask `app.py`: - -```python -import requests -from flask import request - - -@app.post("/checkout") -def checkout(): - with posthog.start_span( - "POST /checkout", - kind="server", - parent=request.headers.get("traceparent"), - ) as span: - traceparent = span.traceparent() - - requests.post( - "https://payments.internal/charge", - headers={"traceparent": traceparent} if traceparent else {}, - ) - - return {"status": "ok"} -``` - -A malformed `traceparent` starts a new trace rather than raising. A missing one (`None`) falls back to the active span, if there is one. - -A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn't sample. A span is recorded and exported whichever way that flag is set. - -## Link traces to people - -Spans created inside a [context](#use-contexts) that has a distinct ID or session ID carry `posthogDistinctId` and `sessionId` attributes. These make a trace reachable from a person or a Session Replay recording. - -In Django, the [contexts middleware](https://posthog.com/docs/libraries/django#django-contexts-middleware) sets these for every request. Elsewhere, set them yourself: - -```python -from posthog import new_context, identify_context, set_context_session - -with new_context(): - identify_context(user.id) - set_context_session(session_id) - - with posthog.start_span("POST /checkout"): - process_order() -``` - -Spans created outside a context with those values omit the attributes. - -## Scrub and drop spans - -`before_span_send` runs on every finished span before it's queued for export. It receives the span as a dict with `name`, `kind`, `status`, `attributes`, `events`, `start_time_ns`, `end_time_ns`, `trace_id`, `span_id`, and `parent_span_id`. Edit it and return it, or return `None` to drop the span entirely. - -```python -def scrub_spans(span): - if span["attributes"].get("http.route") == "/health": - return None - - span["attributes"].pop("http.request.header.authorization", None) - return span - - -posthog = Posthog( - "", - host="", - traces={ - "service_name": "checkout-api", - "before_span_send": scrub_spans, - }, -) -``` - -Attributes are plain Python values, not the OTLP wire encoding. The hook runs after PostHog attaches `posthogDistinctId` and `sessionId`, so the hook can see and scrub those too. An exception's stack trace is on its event, under `event["attributes"]["exception.stacktrace"]`. - -- **IDs are read-only.** Rewriting `trace_id`, `span_id`, or `parent_span_id` would orphan child spans that have already been exported, so changes are reverted. -- **A raising hook drops the span.** The span isn't exported without scrubbing. -- **Hooks can chain.** Pass a list to run several hooks in order. The first one to return `None` stops the chain. -- **Hooks are regular functions.** An `async` hook drops every span. -- **A non-callable entry turns tracing off.** The client stops tracing rather than exporting spans the hook was meant to scrub. - -## The span limits - -A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there. - -Past the cap, the earliest attributes and events are kept. The number dropped is reported with the span, so a truncated span reads as truncated rather than as quietly incomplete. - -The attributes PostHog attaches itself, `posthogDistinctId` and `sessionId`, don't count toward the cap and are never dropped. A span at the limit still links back to its person and session. - -The event cap is absolute. An `exception` event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then raises keeps its `error` status but not the exception detail, and reports the loss as a dropped event. Raise `max_events_per_span` on spans that record many events and can also fail. - -The length bound reaches inside a value, including strings nested in lists and dictionaries. It applies to `exception.stacktrace` like any other attribute, so a long stack trace keeps its last 8192 characters. All four caps are re-applied after `before_span_send`, so a hook that enriches a span can't push it back over. - -## Configure tracing - -The `traces` dict takes these options: - -| Option | Default | Sets | -|---|---|---| -| `service_name` | Unset | Name of the service producing spans | -| `service_version` | Unset | Version of the service | -| `environment` | Unset | Deployment environment, like `production` | -| `resource_attributes` | Unset | Extra OTLP resource attributes | -| `flush_interval` | `5` | Seconds between exports of queued spans | -| `max_export_batch_size` | `512` | Maximum spans per request | -| `max_queue_size` | `2048` | Maximum spans held in memory | -| `max_live_spans` | `10000` | Maximum spans open at once | -| `max_span_age` | `3600` | Seconds before an open span counts as leaked | -| `before_span_send` | Unset | Hook to edit or drop each finished span | -| `max_attributes_per_span` | `128` | Maximum attributes you set on one span | -| `max_events_per_span` | `128` | Maximum events on one span | -| `max_attribute_value_length` | `8192` | Maximum characters in a string attribute value | - -Always set `service_name`. `resource_attributes` takes precedence over `service_name`, `service_version`, and `environment`. - -Spans beyond `max_queue_size` are dropped. At the `max_live_spans` limit, `start_span` returns a span that isn't recorded. Once that limit is reached, spans open longer than `max_span_age` seconds are treated as leaked and never exported. - -An invalid value falls back to its default with a warning. The exception is `before_span_send`: an entry that isn't callable turns tracing off. - -## Flush spans on exit - -Spans are exported on a background interval, even with `sync_mode` on. Both `flush()` and `shutdown()` export spans that have already ended. - -A span still open at `flush()` is exported once it ends. A span still open at `shutdown()` is discarded with a warning, so end your spans before you shut down. A `with` block does this for you. `shutdown()` gives queued spans up to 30 seconds to send. - -If your serverless handler reuses a client across invocations, call `flush()` before returning. It flushes events and ended spans concurrently without stopping the client. Reserve `shutdown()` for a disposable client or process exit. - -```python -def handler(event, context): - try: - with posthog.start_span("handler"): - do_work() - finally: - posthog.flush() -``` - -A script that exits without calling `shutdown()` still gets a brief best-effort flush at exit, but don't rely on it for spans you need. - -## Enable GeoIP properties - -By default, the SDK disregards the server IP, doesn't add GeoIP properties to events, and doesn't use them for feature flag evaluation. This has been the default since 3.0.0. GeoIP properties from a backend are based on your server's IP address rather than the user's, which resolves to the wrong location. - -To add GeoIP properties anyway, set `disable_geoip` to `False` when you initialize the client: - -```python -posthog = Posthog('api_key', disable_geoip=False) -``` - -This setting controls these properties: - -1. `$geoip_city_name` -2. `$geoip_country_name` -3. `$geoip_country_code` -4. `$geoip_continent_name` -5. `$geoip_continent_code` -6. `$geoip_postal_code` -7. `$geoip_time_zone` -8. `$geoip_subdivision_1_code` -9. `$geoip_subdivision_1_name` -10. `$geoip_subdivision_2_code` -11. `$geoip_subdivision_2_name` - -You can also enable or disable GeoIP for a single capture request: - -```python -posthog.capture('test_event', disable_geoip=True|False) -``` - -## Turn on debug mode - -If you don't see the events or the feature flag evaluations you expect, turn on debug mode to see what's happening. Set the `debug` option to `True`. This turns on verbose logs about the inner workings of the SDK. - -```python -posthog.debug = True -``` - -## Disable requests in tests - -To stop requests during tests, set the `disabled` option to `True`. The SDK then captures no events and sends no requests to PostHog. - -```python -if settings.TEST: - posthog.disabled = True -``` - -## Configure connections - -The SDK uses HTTP connection pooling for better performance. You typically don't need to change these settings. In some environments, like behind NAT gateways, pooled connections can be terminated non-gracefully, which causes request failures. - -Call the following settings during initialization, before the SDK makes any API requests. - -TCP keepalive probes help keep network infrastructure from dropping idle connections. This is the recommended fix for most cases where idle connections are terminated. - -```python -import posthog - -posthog.enable_keep_alive() -``` - -This turns on TCP keepalive with a 60 second idle time, a 60 second probe interval, and 3 probes before timeout. - -If you need each request to use a fresh connection, turn off connection reuse entirely. This adds overhead per request. - -```python -import posthog - -posthog.disable_connection_reuse() -``` - -For advanced cases, set arbitrary socket options on the underlying HTTP connection: - -```python -import socket -import posthog - -posthog.set_socket_options([ - (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), - # Add additional socket options as needed -]) -``` - -Pass `None` to `set_socket_options()` to reset to the default behavior. - -## Filter events before sending - -Use `before_send` to modify or drop events before they're queued for delivery. Return the modified event dictionary to send it, or `None` to drop it. - -```python -from typing import Any - -import posthog - - -def scrub_pii(event: dict[str, Any]) -> dict[str, Any] | None: - properties = event.get("properties", {}) - - if "email" in properties: - email = properties["email"] - properties["email"] = f"***@{email.split('@', 1)[1]}" if "@" in email else "***" - - if event.get("event") == "test_event": - return None - - return event - - -client = posthog.Client( - "", - before_send=scrub_pii, -) -``` - -If your callback raises an exception, the SDK logs the error and drops the event. - -## Run historical migrations - -You can use the SDK to run [historical migrations](https://posthog.com/docs/migrate) of data into PostHog. Set the `historical_migration` option to `True` when you initialize the client: - -```python -from posthog import Posthog -from datetime import datetime - -posthog = Posthog( - '', - host='', - debug=True, - historical_migration=True -) - -events = [ - { - "event": "batched_event_name", - "distinct_id": "user_id", - "timestamp": datetime.fromisoformat("2024-04-02T12:00:00+00:00"), - "properties": {"account_type": "pro"} - }, - { - "event": "batched_event_name", - "distinct_id": "user_id", - "timestamp": datetime.fromisoformat("2024-04-03T09:30:00+00:00"), - "properties": {"account_type": "pro"} - } -] - -for event in events: - posthog.capture( - event["event"], - distinct_id=event["distinct_id"], - properties=event["properties"], - timestamp=event["timestamp"], - ) - -posthog.shutdown() -``` - -## Run in serverless - -By default, the synchronous `Posthog` client buffers events before it sends them to the capture endpoint. A serverless platform, like Render or AWS Lambda, can freeze or terminate the runtime before background delivery finishes. You have three options: - -- **A client shared across invocations.** Call `posthog.flush()` before the handler returns, including when your code throws. This blocking call attempts to deliver queued events without stopping the client. Its `timeout_seconds` argument defaults to `10` seconds. -- **A disposable client or process exit.** Call `posthog.shutdown()` after its final work. It attempts to deliver queued events and cleans up the client. Don't reuse the client afterward. `shutdown()` takes no timeout argument. -- **Sync mode.** Set `sync_mode` when you initialize the client, so each `posthog.capture()` call attempts delivery before it returns. - -These calls don't guarantee that PostHog received every event. - -If you [trace your services](#trace-your-services), `sync_mode` doesn't apply to spans. For a shared client, call `posthog.flush()` before the handler returns. See [Flush spans on exit](#flush-spans-on-exit). - -With `AsyncPosthog`, keep one client for the lifetime of your app. Use buffered `capture()` by default, or `await capture_immediate()` when one invocation must wait for an event's delivery attempt. Call `await posthog.shutdown()` once during app cleanup. Don't shut down the client after each request. - -## Use Django or Flask - -See the [Django docs](https://posthog.com/docs/libraries/django) to set up PostHog in Django. The [Django contexts middleware](https://posthog.com/docs/libraries/django#django-contexts-middleware) adds request properties and the authenticated user's identity to events captured during a request. - -Since 7.71.0, the middleware also reads the browser session and distinct ID headers by default. Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` to turn off header extraction. - -For Flask, see the [Flask docs](https://posthog.com/docs/libraries/flask). - -## Add ASGI middleware - -The SDK includes framework-independent ASGI middleware for FastAPI, Starlette, Litestar, and raw ASGI apps, available since 7.69.0. It creates a PostHog context for each HTTP or WebSocket request, adds request properties to captured events, and captures exceptions that escape the app. - -For FastAPI or Starlette, add the middleware to your app: - -```python -from fastapi import FastAPI -from posthog import Posthog -from posthog.integrations.asgi import PosthogASGIMiddleware - -posthog = Posthog( - "", - host="", - enable_exception_autocapture=True, -) - -app = FastAPI() -app.add_middleware(PosthogASGIMiddleware, client=posthog) -``` - -The middleware's `capture_exceptions` option defaults to `None`, which inherits the client's `enable_exception_autocapture` setting. Set it to `True` or `False` to override the client. - -Use `request_filter` to skip selected requests and `extra_properties` to add properties from the ASGI scope. Both callbacks can be synchronous or asynchronous. - -The middleware reads `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` by default, since 7.71.0. Set `capture_tracing_headers=False` to turn off header extraction. Clients can control these headers. Use them only as analytics context, never for authentication or authorization. - -To instrument another ASGI framework or a raw ASGI app, wrap the app directly: - -```python -app = PosthogASGIMiddleware(app, client=posthog) -``` - -## The package names - -The open source [PostHog](https://github.com/PostHog/posthog) project shares the `posthog` module name. To avoid a module collision, there's a `posthoganalytics` package, mostly for internal use. It's the exact same SDK. - -This library is largely based on the `analytics-python` package. - -## The supported versions - -The Python SDK 8.x.x needs Python 3.10 or later. To pin to 8.x.x, run `pip install "posthog>=8,<9"`. - -8.0.0 sends events with [capture v1](https://posthog.com/docs/api/capture-v1) and adds event options, like `options={"process_person_profile": False}`. Earlier versions don't take `options`. Read the [7.x.x to 8.0.0 migration guide](https://posthog.com/docs/libraries/python/migration) before you upgrade. If you stay on 7.x.x, see the [7.x.x reference](https://posthog.com/docs/libraries/python/7.x.x). - -Python 3.9 isn't supported on 7.0.0 and later. Pin to the 6.x.x line with `pip install 'posthog<7'`, where 6.9.3 is the final release. - -Event capture, the context API (`new_context`, `identify_context`, `set_context_session`), and `PosthogContextMiddleware` are identical on 6.9.3 and 7.0.0. 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. The middleware identifies the request context from the `X-POSTHOG-DISTINCT-ID` header and falls back to the authenticated user on both lines. - -Later 7.x.x releases add what the 6.x.x line doesn't get, like the Celery integration, tracing header sanitization, and `set_context_device_id`. - -They also changed the middleware's own captured properties. 7.x.x sends the request IP as `$ip`, where 6.9.3 sends it as `$ip_address`. 7.x.x also captures `$request_path`, `$raw_user_agent`, and the authenticated user's `email`. - -6.0.0 introduced the contexts API and breaking changes. If you upgrade from 5.x.x to 6.x.x, read the [6.x.x migration guide](https://posthog.com/tutorials/python-v6-migration) first. From caaf61e40f3555ad989250816c3591a62bd850e1 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 12:55:03 -0500 Subject: [PATCH 11/27] docs: add the Python facts from the retired posthog.com page that no other page has Co-Authored-By: Claude Opus 5.5 --- docs/getting-started.md | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index 27d91f176..6d30c3d27 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,3 +37,74 @@ posthog.shutdown() ``` In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [AsyncClient](#asyncclient). + +## Use AsyncPosthog in FastAPI + +Keep one `AsyncPosthog` client for the lifetime of your app. A FastAPI lifespan handler does this. Leaving the `async with` block calls `shutdown()`. + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from posthog import AsyncPosthog + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with AsyncPosthog("", host="") as posthog: + app.state.posthog = posthog + yield + +app = FastAPI(lifespan=lifespan) +``` + +## Add the ASGI middleware + +`PosthogASGIMiddleware` creates a context for each HTTP or WebSocket request and captures exceptions that escape the app. In FastAPI or Starlette, add it to the app: + +```python +from posthog.integrations.asgi import PosthogASGIMiddleware + +app.add_middleware(PosthogASGIMiddleware, client=posthog) +``` + +For Litestar, another ASGI framework or a raw ASGI app, wrap the app instead: + +```python +app = PosthogASGIMiddleware(app, client=posthog) +``` + +## TCP keepalive defaults + +`enable_keep_alive()` uses a 60 second idle time, a 60 second probe interval and 3 probes before timeout. + +## The posthoganalytics package + +`posthoganalytics` is the same SDK under another module name. Use it when another module named `posthog` is on your import path. + +## Tracing details + +- The active span is tracked with `contextvars`, so it carries across `await`. Threads don't reliably inherit it. Pass `parent=span` in a thread or a `ThreadPoolExecutor` task. +- A malformed `traceparent` starts a new trace and doesn't raise. +- A continued trace propagates the sampled flag it received. PostHog doesn't sample, so the span is exported whichever way the flag is set. +- PostHog clamps a `start_time` more than 24 hours old to the receive time. +- A recorded exception's stack trace contains file paths from your server. Remove `exception.stacktrace` in `before_span_send` to keep them in your process. +- A script that exits without `shutdown()` gets a brief best-effort span flush. Don't rely on it. + +The `before_span_send` hook follows these rules: + +- The span dict has `name`, `kind`, `status`, `attributes`, `events`, `start_time_ns`, `end_time_ns`, `trace_id`, `span_id` and `parent_span_id`. Attributes are plain Python values. +- The hook runs after `posthogDistinctId` and `sessionId` are attached, so it can scrub them. +- The hook must be a regular function. An `async` hook drops every span. +- In a list of hooks, the first one to return `None` stops the chain. +- An entry that isn't callable turns tracing off for the client. + +The span limits follow these rules: + +- Past a cap, the earliest attributes and events are kept. The span reports how many were dropped. +- `posthogDistinctId` and `sessionId` don't count toward the attribute cap and are never dropped. +- An `exception` event the SDK records uses an event slot. A span that fills its events and then raises keeps its `error` status and loses the exception detail. +- The value length cap also applies to strings nested in lists and dictionaries. +- All caps are applied again after `before_span_send`. +- At `max_live_spans`, `start_span` returns a span that isn't recorded. Spans open longer than `max_span_age` seconds are then treated as leaked and never exported. +- Spans beyond `max_queue_size` are dropped. +- An invalid `traces` value falls back to its default with a warning. From 20201463103f4b9059edca58d8eae92607c71d24 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 13:00:28 -0500 Subject: [PATCH 12/27] docs: move the Python one-API facts from getting-started into docstrings The reference renders each docstring under its entry, per major, so the tracing, ASGI, keepalive, and AsyncPosthog facts now live there. Co-Authored-By: Claude Opus 5.5 --- docs/getting-started.md | 50 +----------------------------------- posthog/async_client.py | 1 + posthog/client.py | 41 ++++++++++++++++++++++++----- posthog/integrations/asgi.py | 6 +++++ posthog/request.py | 2 ++ posthog/tracing/span.py | 6 +++++ 6 files changed, 51 insertions(+), 55 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 6d30c3d27..a85ce43e2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,7 +40,7 @@ In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [ ## Use AsyncPosthog in FastAPI -Keep one `AsyncPosthog` client for the lifetime of your app. A FastAPI lifespan handler does this. Leaving the `async with` block calls `shutdown()`. +Keep one `AsyncPosthog` client for the lifetime of your app. A FastAPI lifespan handler does this. ```python from contextlib import asynccontextmanager @@ -57,54 +57,6 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) ``` -## Add the ASGI middleware - -`PosthogASGIMiddleware` creates a context for each HTTP or WebSocket request and captures exceptions that escape the app. In FastAPI or Starlette, add it to the app: - -```python -from posthog.integrations.asgi import PosthogASGIMiddleware - -app.add_middleware(PosthogASGIMiddleware, client=posthog) -``` - -For Litestar, another ASGI framework or a raw ASGI app, wrap the app instead: - -```python -app = PosthogASGIMiddleware(app, client=posthog) -``` - -## TCP keepalive defaults - -`enable_keep_alive()` uses a 60 second idle time, a 60 second probe interval and 3 probes before timeout. - ## The posthoganalytics package `posthoganalytics` is the same SDK under another module name. Use it when another module named `posthog` is on your import path. - -## Tracing details - -- The active span is tracked with `contextvars`, so it carries across `await`. Threads don't reliably inherit it. Pass `parent=span` in a thread or a `ThreadPoolExecutor` task. -- A malformed `traceparent` starts a new trace and doesn't raise. -- A continued trace propagates the sampled flag it received. PostHog doesn't sample, so the span is exported whichever way the flag is set. -- PostHog clamps a `start_time` more than 24 hours old to the receive time. -- A recorded exception's stack trace contains file paths from your server. Remove `exception.stacktrace` in `before_span_send` to keep them in your process. -- A script that exits without `shutdown()` gets a brief best-effort span flush. Don't rely on it. - -The `before_span_send` hook follows these rules: - -- The span dict has `name`, `kind`, `status`, `attributes`, `events`, `start_time_ns`, `end_time_ns`, `trace_id`, `span_id` and `parent_span_id`. Attributes are plain Python values. -- The hook runs after `posthogDistinctId` and `sessionId` are attached, so it can scrub them. -- The hook must be a regular function. An `async` hook drops every span. -- In a list of hooks, the first one to return `None` stops the chain. -- An entry that isn't callable turns tracing off for the client. - -The span limits follow these rules: - -- Past a cap, the earliest attributes and events are kept. The span reports how many were dropped. -- `posthogDistinctId` and `sessionId` don't count toward the attribute cap and are never dropped. -- An `exception` event the SDK records uses an event slot. A span that fills its events and then raises keeps its `error` status and loses the exception detail. -- The value length cap also applies to strings nested in lists and dictionaries. -- All caps are applied again after `before_span_send`. -- At `max_live_spans`, `start_span` returns a span that isn't recorded. Spans open longer than `max_span_age` seconds are then treated as leaked and never exported. -- Spans beyond `max_queue_size` are dropped. -- An invalid `traces` value falls back to its default with a warning. diff --git a/posthog/async_client.py b/posthog/async_client.py index 5914a1720..53744a83e 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -135,6 +135,7 @@ class AsyncClient: ``capture_ai()`` and ``await capture_ai_immediate()`` do the same for AI events, on a separate queue that posts to the AI capture endpoint. ``flush()``, ``join()``, and ``shutdown()`` are awaitable lifecycle methods. + Leaving an ``async with`` block on the client calls ``shutdown()``. The ``capture_ai_*``, ``privacy_mode`` and ``enable_full_ai_capture`` options work the same way as on :class:`posthog.Client`. diff --git a/posthog/client.py b/posthog/client.py index fb3e1bce0..2d0006036 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1354,10 +1354,26 @@ def __init__( list run in order, that receives each finished span as a dict (``trace_id``, ``span_id`` and ``parent_span_id`` are read-only) and returns it, edited, or ``None`` to drop it; a hook that - raises drops the span. Tracing is off until this is provided. - Spans export on a background timer even with ``sync_mode``; - serverless handlers should call ``flush()`` before returning. - Defaults to None. + raises drops the span. The span dict has ``name``, ``kind``, + ``status``, ``attributes``, ``events``, ``start_time_ns``, + ``end_time_ns``, ``trace_id``, ``span_id`` and + ``parent_span_id``, and its attributes are plain Python values. + The hook runs after ``posthogDistinctId`` and ``sessionId`` are + attached, so it can scrub them. It must be a regular function. + An ``async`` hook drops every span. In a list of hooks, the + first one to return ``None`` stops the chain. An entry that + isn't callable turns tracing off for the client. Past a + per-span cap, the earliest attributes and events are kept and + the span reports how many were dropped. ``posthogDistinctId`` + and ``sessionId`` don't count toward the attribute cap and are + never dropped. ``max_attribute_value_length`` also applies to + strings nested in lists and dictionaries. All caps are applied + again after ``before_span_send``. Spans beyond + ``max_queue_size`` are dropped. An invalid value falls back to + its default with a warning. Tracing is off until this is + provided. Spans export on a background timer even with + ``sync_mode``; serverless handlers should call ``flush()`` + before returning. Defaults to None. code_variables_mask_patterns: Variable-name patterns to mask when capturing code variables. code_variables_ignore_patterns: Variable-name patterns to omit when @@ -3437,7 +3453,9 @@ def start_span( the way out); or call ``end()`` yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling - code never branches. + code never branches. Once ``max_live_spans`` spans are open, the + returned span isn't recorded. Spans open longer than ``max_span_age`` + seconds are then treated as leaked and never exported. Args: name: A low-cardinality operation name, e.g. ``GET /users/:id``. @@ -3448,10 +3466,19 @@ def start_span( parent: A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span. A forked child starts with no active span; pass the parent - span to continue a trace across a fork. + span to continue a trace across a fork. The active span is + tracked with ``contextvars``, so it carries across ``await``. + Threads don't reliably inherit it, so pass ``parent=span`` in + a thread or a ``ThreadPoolExecutor`` task. A malformed + ``traceparent`` starts a new trace and doesn't raise. A + continued trace propagates the sampled flag it received. + PostHog doesn't sample, so the span is exported whichever way + the flag is set. tracestate: The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated. start_time: A ``datetime`` or epoch seconds, to backdate the span. + PostHog clamps a start time more than 24 hours old to the + receive time. Examples: ```python @@ -4041,6 +4068,8 @@ def shutdown(self) -> None: flush with up to 30 seconds waiting for an in-flight flush, then a fresh 30-second drain budget; the last HTTP request is not cut short. Any spans it cannot send are discarded with a warning, as are spans still open. + A script that exits without calling ``shutdown()`` gets only a brief + best-effort span flush, so don't rely on it. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. When called directly from an SDK callback such as ``on_error``, shutdown is deferred to avoid blocking the worker that diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index d58f818db..d07bdbb7d 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -182,6 +182,12 @@ async def _resolve_callback_result(value): class PosthogASGIMiddleware: """Add PostHog context and automatic exception capture to an ASGI app. + It creates a context for each HTTP or WebSocket request and captures + exceptions that escape the app. In FastAPI or Starlette, add it with + ``app.add_middleware(PosthogASGIMiddleware, client=posthog)``. For Litestar, + another ASGI framework or a raw ASGI app, wrap the app with + ``app = PosthogASGIMiddleware(app, client=posthog)``. + Args: app: The downstream ASGI application. client: Optional destination for automatically captured exceptions. The diff --git a/posthog/request.py b/posthog/request.py index ee8702253..efd749f3c 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -178,6 +178,8 @@ def enable_keep_alive() -> None: This helps prevent idle pooled connections from being dropped by network infrastructure. Call during initialization, before making API requests. + It uses a 60 second idle time, a 60 second probe interval and 3 probes + before timeout. """ set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS) diff --git a/posthog/tracing/span.py b/posthog/tracing/span.py index 8bd401ecf..6c6d03655 100644 --- a/posthog/tracing/span.py +++ b/posthog/tracing/span.py @@ -89,6 +89,12 @@ def record_exception(self, exception: BaseException) -> "Span": ``with span:`` a raised ``Exception`` is recorded automatically, so this is for exceptions that are caught and handled. + The stack trace contains file paths from your server. Remove + ``exception.stacktrace`` in ``before_span_send`` to keep them in your + process. The ``exception`` event uses one of the span's event slots. A + span that fills its events and then raises keeps its ``error`` status + and loses the exception detail. + Examples: ```python try: From 629f54cafe5a81927ffb9a12d7425d182b6efa42 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 12 Nov 2026 13:36:11 -0500 Subject: [PATCH 13/27] docs: preserve Python reference details --- posthog/client.py | 10 +++++-- posthog/request.py | 5 ++-- references/posthog-python-8.md | 53 +++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 2d0006036..9e3083d84 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1344,8 +1344,11 @@ def __init__( ``super_properties`` stops the SDK adding either ID. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False. - traces: Config dict for distributed tracing: ``service_name``, - ``service_version``, ``environment``, ``resource_attributes``, + traces: Config dict for distributed tracing. Non-empty string values for + ``service.name``, ``service.version`` and ``deployment.environment`` + in ``resource_attributes`` take precedence over ``service_name``, + ``service_version`` and ``environment``, respectively. Options include + ``service_name``, ``service_version``, ``environment``, ``resource_attributes``, ``flush_interval`` (5 s), ``max_queue_size`` (2048), ``max_export_batch_size`` (512), ``max_live_spans`` (10000), ``max_span_age`` (3600 s), ``max_attributes_per_span`` (128), @@ -3448,6 +3451,9 @@ def start_span( """ Start a span for distributed tracing. Alpha. + The span API is experimental and can change in a minor release. Spans + already sent are kept; the SDK API isn't frozen yet. + Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call ``end()`` yourself for a span that cannot wrap a diff --git a/posthog/request.py b/posthog/request.py index efd749f3c..e3aa43a97 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -178,8 +178,9 @@ def enable_keep_alive() -> None: This helps prevent idle pooled connections from being dropped by network infrastructure. Call during initialization, before making API requests. - It uses a 60 second idle time, a 60 second probe interval and 3 probes - before timeout. + Where the platform supports the corresponding socket options, it uses + a 60 second idle time, a 60 second probe interval and 3 probes before + timeout. Unsupported settings keep their system defaults. """ set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS) diff --git a/references/posthog-python-8.md b/references/posthog-python-8.md index 8f9198f3a..dbec7fac7 100644 --- a/references/posthog-python-8.md +++ b/references/posthog-python-8.md @@ -120,6 +120,29 @@ posthog.shutdown() In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [AsyncClient](#asyncclient). +### Use AsyncPosthog in FastAPI + +Keep one `AsyncPosthog` client for the lifetime of your app. A FastAPI lifespan handler does this. + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from posthog import AsyncPosthog + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with AsyncPosthog("", host="") as posthog: + app.state.posthog = posthog + yield + +app = FastAPI(lifespan=lifespan) +``` + +### The posthoganalytics package + +`posthoganalytics` is the same SDK under another module name. Use it when another module named `posthog` is on your import path. + ## Setup ### `Client(...)` @@ -172,7 +195,7 @@ Name | Type | Description | Default `flag_definition_cache_provider` | `Optional[FlagDefinitionCacheProvider]` | Optional external cache provider for sharing feature flag definitions across workers. | `None` `capture_exception_code_variables` | | Capture local variable values on exception stack frames. | `False` `capture_trace_context` | | When OpenTelemetry is installed and a valid span is active at capture time, add its trace and span IDs as `$trace_id` and `$span_id` properties to events captured with `capture()` and `capture_ai()`, so they can be correlated with backend traces. They fill after context tags and `super_properties`, before `before_send`. A `$trace_id` or `$span_id` from the event, a context tag or `super_properties` stops the SDK adding either ID. Exception events (`capture_exception`) always attach these IDs regardless of this setting. Defaults to False. | `False` -`traces` | `Optional[dict]` | Config dict for distributed tracing: `service_name`, `service_version`, `environment`, `resource_attributes`, `flush_interval` (5 s), `max_queue_size` (2048), `max_export_batch_size` (512), `max_live_spans` (10000), `max_span_age` (3600 s), `max_attributes_per_span` (128), `max_events_per_span` (128), `max_attribute_value_length` (8192). `before_span_send` is a callable, or a list run in order, that receives each finished span as a dict (`trace_id`, `span_id` and `parent_span_id` are read-only) and returns it, edited, or `None` to drop it; a hook that raises drops the span. Tracing is off until this is provided. Spans export on a background timer even with `sync_mode`; serverless handlers should call `flush()` before returning. Defaults to None. | `None` +`traces` | `Optional[dict]` | Config dict for distributed tracing. Non-empty string values for `service.name`, `service.version` and `deployment.environment` in `resource_attributes` take precedence over `service_name`, `service_version` and `environment`, respectively. Options include `service_name`, `service_version`, `environment`, `resource_attributes`, `flush_interval` (5 s), `max_queue_size` (2048), `max_export_batch_size` (512), `max_live_spans` (10000), `max_span_age` (3600 s), `max_attributes_per_span` (128), `max_events_per_span` (128), `max_attribute_value_length` (8192). `before_span_send` is a callable, or a list run in order, that receives each finished span as a dict (`trace_id`, `span_id` and `parent_span_id` are read-only) and returns it, edited, or `None` to drop it; a hook that raises drops the span. The span dict has `name`, `kind`, `status`, `attributes`, `events`, `start_time_ns`, `end_time_ns`, `trace_id`, `span_id` and `parent_span_id`, and its attributes are plain Python values. The hook runs after `posthogDistinctId` and `sessionId` are attached, so it can scrub them. It must be a regular function. An `async` hook drops every span. In a list of hooks, the first one to return `None` stops the chain. An entry that isn't callable turns tracing off for the client. Past a per-span cap, the earliest attributes and events are kept and the span reports how many were dropped. `posthogDistinctId` and `sessionId` don't count toward the attribute cap and are never dropped. `max_attribute_value_length` also applies to strings nested in lists and dictionaries. All caps are applied again after `before_span_send`. Spans beyond `max_queue_size` are dropped. An invalid value falls back to its default with a warning. Tracing is off until this is provided. Spans export on a background timer even with `sync_mode`; serverless handlers should call `flush()` before returning. Defaults to None. | `None` `code_variables_mask_patterns` | | Variable-name patterns to mask when capturing code variables. | `None` `code_variables_ignore_patterns` | | Variable-name patterns to omit when capturing code variables. | `None` `code_variables_mask_url_credentials` | | Scrub credentials embedded in URLs/DSNs (e.g. `user:pass@host`) from captured code variables, regardless of the surrounding variable name. Defaults to True. | `None` @@ -317,12 +340,17 @@ start_span(name: str, *, kind: Optional[str] = None, attributes: Optional[Mappin Start a span for distributed tracing. Alpha. +The span API is experimental and can change in a minor release. Spans +already sent are kept; the SDK API isn't frozen yet. + Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call `end()` yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling -code never branches. +code never branches. Once `max_live_spans` spans are open, the +returned span isn't recorded. Spans open longer than `max_span_age` +seconds are then treated as leaked and never exported. **Parameters:** @@ -331,9 +359,9 @@ Name | Type | Description | Default `name` | `str` | A low-cardinality operation name, e.g. `GET /users/:id`. Variable values belong in attributes, not the name. | *required* `kind` | `Optional[str]` | `internal` (default), `server`, `client`, `producer` or `consumer`. | `None` `attributes` | `Optional[Mapping[str, Any]]` | Initial attributes. | `None` -`parent` | `Union[Span, str, None]` | A span handle, or an inbound W3C `traceparent` header value to continue a remote trace. Defaults to the active span. A forked child starts with no active span; pass the parent span to continue a trace across a fork. | `None` +`parent` | `Union[Span, str, None]` | A span handle, or an inbound W3C `traceparent` header value to continue a remote trace. Defaults to the active span. A forked child starts with no active span; pass the parent span to continue a trace across a fork. The active span is tracked with `contextvars`, so it carries across `await`. Threads don't reliably inherit it, so pass `parent=span` in a thread or a `ThreadPoolExecutor` task. A malformed `traceparent` starts a new trace and doesn't raise. A continued trace propagates the sampled flag it received. PostHog doesn't sample, so the span is exported whichever way the flag is set. | `None` `tracestate` | `Optional[str]` | The inbound `tracestate` header accompanying a `traceparent` string `parent`; preserved and propagated. | `None` -`start_time` | `Union[datetime, float, None]` | A `datetime` or epoch seconds, to backdate the span. | `None` +`start_time` | `Union[datetime, float, None]` | A `datetime` or epoch seconds, to backdate the span. PostHog clamps a start time more than 24 hours old to the receive time. | `None` **Examples:** @@ -902,6 +930,8 @@ server receipt. Event draining has no timeout. Queued spans get one final flush with up to 30 seconds waiting for an in-flight flush, then a fresh 30-second drain budget; the last HTTP request is not cut short. Any spans it cannot send are discarded with a warning, as are spans still open. +A script that exits without calling `shutdown()` gets only a brief +best-effort span flush, so don't rely on it. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. When called directly from an SDK callback such as `on_error`, shutdown is deferred to avoid blocking the worker that @@ -1619,6 +1649,12 @@ PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_ex Add PostHog context and automatic exception capture to an ASGI app. +It creates a context for each HTTP or WebSocket request and captures +exceptions that escape the app. In FastAPI or Starlette, add it with +`app.add_middleware(PosthogASGIMiddleware, client=posthog)`. For Litestar, +another ASGI framework or a raw ASGI app, wrap the app with +`app = PosthogASGIMiddleware(app, client=posthog)`. + **Parameters:** Name | Type | Description | Default @@ -1827,6 +1863,9 @@ Enable TCP keepalive for SDK HTTP connections. This helps prevent idle pooled connections from being dropped by network infrastructure. Call during initialization, before making API requests. +Where the platform supports the corresponding socket options, it uses +a 60 second idle time, a 60 second probe interval and 3 probes before +timeout. Unsupported settings keep their system defaults. ### `posthog.request.disable_connection_reuse` @@ -3789,6 +3828,12 @@ may lose its cause). Returns the span, so calls chain. Inside `with span:` a raised `Exception` is recorded automatically, so this is for exceptions that are caught and handled. +The stack trace contains file paths from your server. Remove +`exception.stacktrace` in `before_span_send` to keep them in your +process. The `exception` event uses one of the span's event slots. A +span that fills its events and then raises keeps its `error` status +and loses the exception detail. + **Examples:** ```python From af3bde77a5bbc025daaaf9768c511cad3912c0a3 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 14:55:54 -0800 Subject: [PATCH 14/27] docs: restore the self-hosted, 404 and duplicate-uuid notes in the sorted guide The sorted 8.0.0 guide dropped facts the v1 guide states: the self-hosted capture v1 route check, the 404 hint, the 400 duplicate_event_uuid on older PostHog, the legacy path removal, and NaN as null since 7.75.0. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index db8a140b0..f4a50da08 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -1,6 +1,6 @@ # Migrate the Python SDK from 7.x.x to 8.0.0 -The Python SDK 8.x.x sends every event to the [capture v1](https://posthog.com/docs/api/capture-v1) endpoints, `/i/v1/analytics/events` and `/i/v1/ai/events`. +The Python SDK 8.x.x sends every event to the [capture v1](https://posthog.com/docs/api/capture-v1) endpoints, `/i/v1/analytics/events` and `/i/v1/ai/events`, and removes the legacy capture path. Most apps upgrade without code changes. Use the top right copy page button to work through this with your agents. @@ -16,6 +16,7 @@ Read the checklist first, then the sections that apply to you. **Before you upgrade** +- [ ] You send events to a self-hosted PostHog that doesn't serve the capture v1 endpoints → [Prepare your proxy](#prepare-your-proxy) - [ ] You send events through your own proxy → [Prepare your proxy](#prepare-your-proxy) - [ ] A proxy redirects capture requests to another host → [Prepare your proxy](#prepare-your-proxy) - [ ] You pass a personal API key (`phx_...`) as the project token → [Use a project token](#use-a-project-token) @@ -59,6 +60,8 @@ Read the checklist first, then the sections that apply to you. | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | +If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. An endpoint that isn't served drops every event sent to it. + If you send events through your own reverse proxy, update it before you upgrade: - **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. @@ -201,7 +204,7 @@ posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_ ### Check event IDs -- **Duplicates.** Each event needs its own UUID. Capture keeps the first event for each UUID in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x.x sent every copy. +- **Duplicates.** Each event needs its own UUID. Capture keeps the first event for each UUID in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x.x sent every copy. Older PostHog versions reject the whole batch with a 400 `duplicate_event_uuid` instead. - **Accepted forms.** 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`. - **Other values.** The SDK replaces any other value with a generated UUID. It 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.** Generated UUIDs are UUIDv7. @@ -325,6 +328,8 @@ A `Retry-After` header sets a minimum wait, up to 30 seconds. Capture itself doesn't return `429`, but a proxy in front of it can. +A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. + ### Check dropped events Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only the invalid event, so the rest of the batch is delivered. @@ -334,7 +339,7 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t | The name is missing, `None`, empty, or not a string | Dropped, with a warning | Sent | | A `before_send` hook sets `distinct_id` to `None` or a non-string | Dropped, with a warning | Sent, and capture rejected the whole batch | | The name is over 200 UTF-8 bytes | Dropped by PostHog as `event_name_too_long` | Sent, with no name limit | -| A `NaN` or infinite `float` value | Sent as `null` | Invalid JSON before 7.75.0, so capture rejected the whole batch | +| A `NaN` or infinite `float` value | Sent as `null`, as in 7.75.0 and later | Invalid JSON before 7.75.0, so capture rejected the whole batch | | It can't be serialized to JSON, for example with a circular reference | Dropped and logged | Logged the same line | | A property or option key JSON can't represent, such as a tuple | Key dropped without a log | Event dropped | | A key is an `int`, finite `float`, `bool`, or `None` | Key converted to a string | Key converted to a string | From f93e3732037722aebc3afe4d453876304c50f9c2 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 14:56:17 -0800 Subject: [PATCH 15/27] docs: describe the identify-style person processing default in the sorted guide Identify-style events now default process_person_profile to true unless the call sets it or the SDK generated the distinct ID, so layered false no longer reaches them. Replaces the old reach rules and opt-in sample, and updates the checklist row, precedence step 1 and the before_send section. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index f4a50da08..ce9afc646 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -32,7 +32,7 @@ Read the checklist first, then the sections that apply to you. - [ ] You test the AI integrations with a mock client and assert on `capture` → [Route AI events](#route-ai-events) - [ ] You pass your own client object to the AI integrations → [Route AI events](#route-ai-events) - [ ] You call `alias()` without a `distinct_id` and rely on the context distinct ID → [Fix identity calls](#fix-identity-calls) -- [ ] You turn person processing off in bulk and call `alias`, `group_identify`, `set`, or `set_once` → [Fix identity calls](#fix-identity-calls) +- [ ] You turn person processing off with `super_options`, `super_properties`, a context option, or a context tag, and expect identify-style events, such as those from `alias`, `group_identify`, `set`, or `set_once`, to follow it → [Fix identity calls](#fix-identity-calls) - [ ] You reuse one event `uuid` for more than one event → [Check event IDs](#check-event-ids) - [ ] You expect `super_properties` to override a call's properties, including `$set` and `$groups` → [Check property precedence](#check-property-precedence) - [ ] You set `$is_server`, `$geoip_disable`, or a system property such as `$os`, and expect the SDK's value to win → [Check property precedence](#check-property-precedence) @@ -128,6 +128,8 @@ To change an option, edit `options`. A hook that returns a new dict must copy `o That includes the `process_person_profile = false` the SDK sets for an event without a distinct ID. Without it, PostHog would create a person for the event's generated ID. +A hook that builds an identify-style event, or renames an event to one, must set the `process_person_profile` option itself (see [Fix identity calls](#fix-identity-calls)). + A hook that spreads the event into the new dict keeps `options`: ```python @@ -183,24 +185,18 @@ The module-level client has no AI lane settings. To change them, create a `Clien - **Alias.** `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the SDK drops the call with a warning. - **Context values.** Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. -`process_person_profile: False` reaches identity calls too. Where you set it decides which calls it reaches: - -- **A context option, `super_options`, or a `$process_person_profile` entry in `super_properties`.** It reaches `alias`, `group_identify`, `set`, and `set_once`. -- **A `$process_person_profile` context tag.** It reaches only `alias` and `group_identify`, because `set` and `set_once` add context tags to the person properties they set. - -Context tags and options are new on `alias` and `group_identify`. `super_properties` already reached them in 7.x. +Identify-style events default to person processing on. These are `$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set`, and `$set_once`, whether `alias`, `group_identify`, `set`, `set_once`, `capture`, or `capture_ai` sends them. -PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning. They never merge a person or update a group. +The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option or a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. -PostHog keeps the event from `set` or `set_once` but ignores its person update. Once the person exists, events sent with `process_person_profile: False` are still linked to it and billed as identified events. +- **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. +- **Legacy property.** When the call sets only the `$process_person_profile` property, the SDK moves it into the option. +- **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. +- **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. +- **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. +- **Built events.** A hook that builds an identify-style event, or renames an event to one, must set the option itself. -Capture accepts these events, so `on_error` doesn't report the drop. To keep them, pass the option on the call, which wins over context and `super_options`, or set it in `before_send`: - -```python -posthog.alias(previous_id="anon-1", distinct_id="user-1", options={"process_person_profile": True}) -posthog.group_identify("company", "acme", {"name": "Acme"}, options={"process_person_profile": True}) -posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_person_profile": True}) -``` +To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`. ### Check event IDs @@ -213,7 +209,7 @@ posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_ An event's options and properties come from five steps, 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, then the call's `disable_geoip` argument, which fills `$geoip_disable`. +1. The `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable`. An identify-style event also gets `process_person_profile: True` here when the call leaves it unset and the SDK didn't generate its distinct ID (see [Fix identity calls](#fix-identity-calls)). 2. Context options and tags. 3. `super_options` and `super_properties`. 4. Values the SDK sets: From c40da124a7ed13fb116f2dbdeea3afb0011a9e0b Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 14:56:30 -0800 Subject: [PATCH 16/27] docs: correct the span limit order and the record_exception loss note start_span evicts spans older than max_span_age first and returns an unrecorded span only if still at max_live_spans. A full span keeps the exception message in its status and loses only the type and stack trace. Co-Authored-By: Claude Opus 5.5 --- posthog/client.py | 6 +++--- posthog/tracing/span.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 9e3083d84..2ff2c4506 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -3459,9 +3459,9 @@ def start_span( the way out); or call ``end()`` yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling - code never branches. Once ``max_live_spans`` spans are open, the - returned span isn't recorded. Spans open longer than ``max_span_age`` - seconds are then treated as leaked and never exported. + code never branches. At ``max_live_spans`` open spans, the SDK first + evicts spans open longer than ``max_span_age`` seconds, which are never + exported. If it is still at the limit, the returned span isn't recorded. Args: name: A low-cardinality operation name, e.g. ``GET /users/:id``. diff --git a/posthog/tracing/span.py b/posthog/tracing/span.py index 6c6d03655..ce9db69c3 100644 --- a/posthog/tracing/span.py +++ b/posthog/tracing/span.py @@ -92,8 +92,8 @@ def record_exception(self, exception: BaseException) -> "Span": The stack trace contains file paths from your server. Remove ``exception.stacktrace`` in ``before_span_send`` to keep them in your process. The ``exception`` event uses one of the span's event slots. A - span that fills its events and then raises keeps its ``error`` status - and loses the exception detail. + span whose event slots are full keeps its ``error`` status and the + exception message, but loses the exception type and stack trace. Examples: ```python From 709f2a0c25c3f220f092fa0f713b99be5908a482 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 14:56:55 -0800 Subject: [PATCH 17/27] docs: regenerate the 8.x and migration references Generated with bin/docs_scripts/generate_markdown.py --major 8. The migration page now carries the sorted guide, which it had not before. Co-Authored-By: Claude Opus 5.5 --- references/posthog-python-8.md | 10 +- references/posthog-python-migration.md | 699 +++++++++++++++---------- 2 files changed, 425 insertions(+), 284 deletions(-) diff --git a/references/posthog-python-8.md b/references/posthog-python-8.md index b33fbf7cd..899a391d4 100644 --- a/references/posthog-python-8.md +++ b/references/posthog-python-8.md @@ -348,9 +348,9 @@ span for the block and end it on exit (recording a raised exception on the way out); or call `end()` yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling -code never branches. Once `max_live_spans` spans are open, the -returned span isn't recorded. Spans open longer than `max_span_age` -seconds are then treated as leaked and never exported. +code never branches. At `max_live_spans` open spans, the SDK first +evicts spans open longer than `max_span_age` seconds, which are never +exported. If it is still at the limit, the returned span isn't recorded. **Parameters:** @@ -3831,8 +3831,8 @@ this is for exceptions that are caught and handled. The stack trace contains file paths from your server. Remove `exception.stacktrace` in `before_span_send` to keep them in your process. The `exception` event uses one of the span's event slots. A -span that fills its events and then raises keeps its `error` status -and loses the exception detail. +span whose event slots are full keeps its `error` status and the +exception message, but loses the exception type and stack trace. **Examples:** diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index 6fce22ee2..377c6c053 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -1,44 +1,62 @@ --- -title: PostHog Python SDK migration guides +title: "Migrate the Python SDK from 7.x.x to 8.0.0" sidebar: Docs showTitle: true --- -# Migrating from posthog 7.x to 8.0 +The Python SDK 8.x.x sends every event to the [capture v1](https://posthog.com/docs/api/capture-v1) endpoints, `/i/v1/analytics/events` and `/i/v1/ai/events`, and removes the legacy capture path. Most apps upgrade without code changes. -posthog 8.0 sends every event through capture v1 and removes the legacy capture path. -Most apps upgrade without code changes. -Read the checklist first, then the sections that apply to you. +Use the top right copy page button to work through this with your agents. + +You don't need to make every change on the day you upgrade your SDK. Required changes fall in these categories: -For the changes every capture v1 SDK shares, such as event options, limits and per-event results, see [Upgrading to capture v1 SDKs](https://posthog.com/docs/libraries/capture-v1). +- **Before you upgrade**: you should make these changes before you upgrade your SDK to 8.x.x. +- **During your upgrade**: These are changes that you need to make in the same PR that upgrades your SDK version. +- **Follow ups**: These are changes that can come in follow up PRs because 8.x.x has limited backward compatibility for methods that are to be deprecated. ## Checklist -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 `CaptureMode`, `posthog.capture_mode`, `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 -- sends events through a proxy that redirects capture requests to another host -- reuses one event `uuid` for more than one event -- expects `super_properties` to override properties passed to a single call, including `$set` and `$groups` -- sets `$is_server`, `$geoip_disable` or a system property such as `$os` and expects the SDK's value to win -- has a `before_send` hook that returns a new dict: copy `options`, or the event is sent without them, including the `process_person_profile=False` the SDK sets for events without a distinct ID (see [Event options](#event-options)). The same applies to MCP's `before_send` (see [MCP analytics](#mcp-analytics)) -- captures `$ai_` events with `capture()`: send them with `capture_ai()` -- tests the AI integrations with a mock client and asserts on `capture` -- passes its own client object to the AI integrations -- sets `$lib` or `$lib_version` in a call's `properties`, a context tag or `super_properties` -- filters insights or dashboards on `$lib = posthog-python-mcp` -- passes a personal API key (`phx_...`) as the project API key -- reads event content from the SDK's debug log -- calls `alias()` without a `distinct_id` and relies on the context distinct ID -- turns person processing off with `super_options`, `super_properties`, a context option or a context tag, and calls `alias`, `group_identify`, `set` or `set_once` (see [Identity and request context](#identity-and-request-context)) -- runs the Django, Flask or ASGI integration on 7.68 to 7.70 without tracing headers turned on (they are now read by default) - -## Endpoints +Read the checklist first, then the sections that apply to you. + +**Before you upgrade** + +- [ ] You send events to a self-hosted PostHog that doesn't serve the capture v1 endpoints → [Prepare your proxy](#prepare-your-proxy) +- [ ] You send events through your own proxy → [Prepare your proxy](#prepare-your-proxy) +- [ ] A proxy redirects capture requests to another host → [Prepare your proxy](#prepare-your-proxy) +- [ ] You pass a personal API key (`phx_...`) as the project token → [Use a project token](#use-a-project-token) +- [ ] You use `openfeature-provider-posthog` below 0.2.0 → [Update OpenFeature](#update-openfeature) + +**During your upgrade** + +- [ ] You pass `Client` or `AsyncPosthog` constructor arguments by position after `host` → [Fix removed APIs](#fix-removed-apis) +- [ ] You call `posthog.request.post` or `posthog.request.flags` with positional arguments after `path` or `host` → [Fix removed APIs](#fix-removed-apis) +- [ ] You set `capture_mode`, `POSTHOG_CAPTURE_MODE`, or `gzip` → [Fix removed APIs](#fix-removed-apis) +- [ ] You import `CaptureMode`, `posthog.capture_mode`, `CaptureV1Error`, `posthog.capture_v1`, `request.batch_post`, `EVENTS_ENDPOINT`, or `AI_EVENTS_ENDPOINT` → [Fix removed APIs](#fix-removed-apis) +- [ ] Your `before_send` hook returns a new dict, in the client or in MCP → [Copy options in before_send](#copy-options-in-before_send) +- [ ] You test the AI integrations with a mock client and assert on `capture` → [Route AI events](#route-ai-events) +- [ ] You pass your own client object to the AI integrations → [Route AI events](#route-ai-events) +- [ ] You call `alias()` without a `distinct_id` and rely on the context distinct ID → [Fix identity calls](#fix-identity-calls) +- [ ] You turn person processing off with `super_options`, `super_properties`, a context option, or a context tag, and expect identify-style events, such as those from `alias`, `group_identify`, `set`, or `set_once`, to follow it → [Fix identity calls](#fix-identity-calls) +- [ ] You reuse one event `uuid` for more than one event → [Check event IDs](#check-event-ids) +- [ ] You expect `super_properties` to override a call's properties, including `$set` and `$groups` → [Check property precedence](#check-property-precedence) +- [ ] You set `$is_server`, `$geoip_disable`, or a system property such as `$os`, and expect the SDK's value to win → [Check property precedence](#check-property-precedence) +- [ ] You set `$lib` or `$lib_version` yourself → [Check SDK identity](#check-sdk-identity) +- [ ] An insight or dashboard filters on `$lib = posthog-python-mcp` → [Check SDK identity](#check-sdk-identity) +- [ ] You read event content from the SDK's debug log, or alert on SDK log lines → [Update error handling](#update-error-handling) +- [ ] You set `$exception_level` or another reserved exception property in `capture_exception(properties=...)` → [Check exceptions](#check-exceptions) +- [ ] You run the Django, Flask, or ASGI integration on 7.68.x to 7.70.x without tracing headers turned on → [Check tracing headers](#check-tracing-headers) + +**Follow ups** + +- [ ] You set legacy option properties like `$process_person_profile` → [Move to event options](#move-to-event-options) +- [ ] You capture `$ai_` events with `capture()` → [Send AI events with capture_ai](#send-ai-events-with-capture_ai) +- [ ] You use `trust_tracing_headers` or `_use_ai_lane`, set `posthog.capture_mode`, or pass a `gzip=` keyword to `request.post` → [Rename deprecated settings](#rename-deprecated-settings) + +## Before you upgrade + +### Prepare your proxy + +8.x.x sends events to these endpoints: | Method | Endpoint | | --- | --- | @@ -46,281 +64,405 @@ You need to change code if your app does any of these: | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. -Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. -An endpoint that is not served drops every event sent to it. +If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. An endpoint that isn't served drops every event sent to it. If you send events through your own reverse proxy, update it before you upgrade: -- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. -- Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. -- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. +- **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. +- **Pass the headers through unchanged.** Pass `Authorization`, every `PostHog-*` header, `Content-Type`, `Content-Encoding`, and `User-Agent`. `Authorization` holds the project token, sent as `Bearer `. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. +- **Accept request bodies up to 20 MB.** AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -The SDK follows a `307` or `308` redirect only to the origin of `host` (same scheme, host and port), at most 5 times. -Any other redirect fails the batch and reaches `on_error`. -In 7.x the sync client followed redirects to any origin. -If a proxy redirects capture requests to another host, point `host` at the final host. +The SDK follows a `307` or `308` redirect only to the origin of `host`, at most 5 times. The origin is the same scheme, host, and port. -## SDK identity +Any other redirect fails the batch and reaches `on_error`. In 7.x.x the sync client followed redirects to any origin. -By default every event carries `$lib: "posthog-python"` and the installed package version in `$lib_version`. -PostHog stores the values the event is sent with. +If a proxy redirects capture requests to another host, point `host` at the final host. -You can set them in a call's `properties`, a context tag or `super_properties`. Like any other property, each one comes from the most specific place that sets it, so set both in the same place. -`$lib` and `$lib_version` count as set only when they are non-empty strings. -If you set `$lib` without `$lib_version`, the SDK adds its own version only when `$lib` is `posthog-python`. -A `$lib_version` without a `$lib` is replaced with the SDK's. -`before_send` sees both and can change or remove them. -In 7.x the SDK overwrote a `$lib` or `$lib_version` set in a call or a context tag. +### Use a project token -This changes `super_properties` too: +A personal API key (`phx_...`) passed as the project token disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. -- `super_properties={"$lib": "my-lib"}` sends no `$lib_version`. In 7.x it sent the SDK version. -- `super_properties={"$lib_version": "1.0"}` without a `$lib` sends the SDK's `$lib` and version. In 7.x it sent `1.0`. +### Update OpenFeature -If an event reaches PostHog without `$lib`, PostHog fills both from the SDK name and version your request sends. -If `$lib` is that SDK name and `$lib_version` is missing, empty or not a string, PostHog fills `$lib_version` the same way. +`openfeature-provider-posthog` 0.2.0 works with `posthog` 7.x.x and 8.x.x. Older provider versions require `posthog` below 8.0.0, so upgrade the provider before or with `posthog`. -MCP analytics events from `posthog.mcp.instrument()` or `PostHogMCP` report `posthog-python-mcp` and the installed package version. -The integration sets both in each event's own properties, so neither a context tag nor `super_properties` replaces them. `before_send` can. -In 7.x the MCP integration relabeled the whole client, or the default client when `instrument()` got none: the app's own events, the request headers and feature flag requests. -In 8.0 those report `posthog-python`, so one client can serve both. +## During your upgrade -Custom events from `PostHogMCP.capture()` now report `posthog-python`. In 7.x they reported `posthog-python-mcp`. -Custom events from `McpAnalytics.capture()`, the handle `instrument()` returns, still report `posthog-python-mcp`. -If an insight or dashboard filters on `$lib = posthog-python-mcp`, check that it still finds the events you expect. +### Fix removed APIs -## Removed options and APIs +8.x.x removes these options and APIs: | Removed | Use instead | | --- | --- | -| `capture_mode`, `CaptureMode`, `POSTHOG_CAPTURE_MODE` | Nothing. Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no effect. | -| `gzip=True` | `capture_compression=CaptureCompression.GZIP`. The default is no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. | -| `CaptureV1Error` (`from posthog.capture_v1 import CaptureV1Error`) | `CaptureError` (`from posthog import CaptureError`). There is no alias. | +| `capture_mode`, `CaptureMode`, `POSTHOG_CAPTURE_MODE` | Nothing | +| `gzip=True` | `capture_compression` | +| `CaptureV1Error` | `CaptureError` | | `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 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`. A `gzip=` keyword raises no error: it is sent as a field in the request body. | -| The `backoff` dependency | Add it to your own requirements if your code imports it. | +| `request.batch_post`, `async_batch_post`, `EVENTS_ENDPOINT`, `AI_EVENTS_ENDPOINT` | Send events through a client | +| The `gzip` parameter of `request.post` and `request.flags` | Nothing | +| The `backoff` dependency | Your own requirements, if your code imports it | + +Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no effect. + +For gzip, set `capture_compression=CaptureCompression.GZIP`. `capture_compression` defaults to no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. + +Replace `from posthog.capture_v1 import CaptureV1Error` with `from posthog import CaptureError`. There is no alias. + +The parameters after `path` in `request.post` and after `host` in `request.flags` are keyword-only, so a 7.x.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error. It's sent as a field in the request body. The `posthoganalytics` package has the same changes. For example, import `CaptureError` from `posthoganalytics`. Constructor arguments after `host` are keyword-only: ```python -# 7.x +# 7.x.x Posthog("", "https://us.i.posthog.com", True) -# 8.0 +# 8.x.x Posthog("", host="https://us.i.posthog.com", debug=True) ``` -## Errors and `on_error` +### Copy options in before_send + +Review your `before_send` hooks for the new event shape. Capture v1 sends processing options in an `options` object, next to `properties`. + +To change an option, edit `options`. A hook that returns a new dict must copy `options`, or the event is sent without them. -Capture v1 returns a result for every event, even when the request succeeds. -An event can be dropped inside a 2xx response, for example when it is over a product quota. -8.0 reports those events as failures. -When the whole request is over the billing limit, capture returns a `402` with no per-event results instead. - -- Every capture failure is a `CaptureError`, a subclass of `APIError`. It has these fields: - - `status`: the HTTP status, or `0` when the request never got a response. With `0`, `__cause__` holds the network error. In 7.x, `on_error` received the raw `requests` exception. - - `endpoint`, `request_id` and `attempts` - - `drops`: `(uuid, details)` pairs for the events capture dropped in a 2xx response - - `retry_exhausted`: the uuids still waiting for a retry after the last attempt - - `event_results`: a `CaptureEventResult(result, details)` for each uuid that got a result in a 2xx response, from `posthog.capture_send`. An event missing from it never got a result. - - `verdict_summary()`: counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` -- `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. -- With `on_error` set, the SDK logs nothing for a failed batch. -- Without `on_error`, the SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. After a 2xx response it also counts the lost events by PostHog's reason, for example `drop/event_too_big=1`. -- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. In `sync_mode`, a failed request no longer logs the 7.x `Error in capture: ...` line, or the same line for another method. Update any log-based alerts that match these lines. -- In `sync_mode`, a failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error. -- In `sync_mode`, a failed request is retried like a background batch, in the calling thread. With the default `max_retries` of 3, a call makes up to 4 attempts, and each attempt can take up to `timeout` (`capture_ai_timeout` for AI events). With the defaults, an analytics event whose requests keep timing out blocks the call for about a minute. 7.x made one attempt. -- If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again. -- The SDK retries `404`, `408`, `429`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including the other 5xx codes. 7.x retried `429` and every 5xx, but not `404`. Capture itself does not return `429`; a proxy in front of it can. A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. -- Each attempt makes one connection try. 7.x also retried a failed connection up to twice inside each attempt, so a capture request could block for up to three times the `timeout` per attempt. -- Retries start at 100 ms and double each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients do not retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10. In 7.x the first retry waited up to 1 second. -- The SDK's own log lines no longer contain event content: the debug line for a queued event names the event and the lane, and the invalid timestamp warning no longer includes the value. When your `before_send` hook raises, the error line names the event and the exception type, not the exception message, which can hold the data the hook was removing. The traceback is logged at debug level. To inspect events, use `before_send`. -- A personal API key (`phx_...`) passed as the project API key disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. - -## Invalid events - -Capture rejects a whole batch when one event cannot be parsed. -8.0 drops only the invalid event, so the rest of the batch is delivered. - -- An event whose name is missing, `None`, empty or not a string is dropped. The SDK logs one warning per reason per process. 7.x sent it. -- An event whose `before_send` hook sets `distinct_id` to `None` or a non-string is dropped, with one warning per reason per process. The SDK does not convert the hook's value or make up an ID. 7.x sent it, and capture would reject the whole batch. -- PostHog drops an event whose name is over 200 UTF-8 bytes (`event_name_too_long`), and `on_error` reports it. 7.x sent events to legacy capture, which had no event name limit. -- A `NaN` or infinite `float` value is sent as `null`, as in 7.75 and later, and the event is kept. Earlier 7.x versions sent invalid JSON, which made capture reject the whole batch. A `NaN` value in `$set` sets that person property to `null`. -- An event that cannot be serialized to JSON, for example one with a circular reference, is dropped and logged. As in 7.x, a circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. -- A property or option key that JSON cannot represent, such as a tuple, is dropped without a log, and the rest of the event is sent. 7.x dropped the event. `int`, finite `float`, `bool` and `None` keys are still converted to strings. A `NaN` or infinite `float` key still drops the event. - -## Event uuids - -- Each event needs its own uuid. Capture keeps the first event for each uuid in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x sent every copy. Older PostHog versions reject the whole batch with a 400 `duplicate_event_uuid` instead. -- 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 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 - -Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. -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. The SDK logs a warning for each one, with the key and the value's type but not the value. - -## Identity and request context - -- An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x sent a whitespace-only ID, and capture dropped the event. -- Generated personless distinct IDs are UUIDv7. 7.x generated UUIDv4. -- `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$_`. 7.x fell back to a random ID, which created a new person on every call. If `$_` is over 200 bytes, pass `distinct_id`: PostHog drops an event whose distinct ID is over 200 bytes. -- PostHog drops an event whose `distinct_id` is over 200 UTF-8 bytes (`distinct_id_too_large`), and `on_error` reports it. 7.x sent events to legacy capture, which shortened the ID to 200 characters and kept the event. -- `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID is not the session's user. -- `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the call is dropped with a warning. -- Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. -- `process_person_profile: False` from a context option, `super_options` or a `$process_person_profile` entry in `super_properties` reaches `alias`, `group_identify`, `set` and `set_once` too. A `$process_person_profile` context tag reaches only `alias` and `group_identify`, because `set` and `set_once` add context tags to the person properties they set. Context tags and options are new on `alias` and `group_identify`; `super_properties` already reached them in 7.x. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, so they never merge a person or update a group. It keeps the event from `set` or `set_once` but ignores its person update. Once the person exists, events sent with `process_person_profile: False` are still linked to it and billed as identified events. Capture accepts these events, so `on_error` does not report the drop. To keep them, pass the option on the call, which wins over context and `super_options`, or set it in `before_send`: - - ```python - posthog.alias(previous_id="anon-1", distinct_id="user-1", options={"process_person_profile": True}) - posthog.group_identify("company", "acme", {"name": "Acme"}, options={"process_person_profile": True}) - posthog.set(distinct_id="user-1", properties={"plan": "pro"}, options={"process_person_profile": True}) - ``` - -## Tracing headers - -The Django, Flask and ASGI integrations read the `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers by default, as in 7.71 and later. -The headers only fill a missing distinct ID or session ID, and any client can set them, so never use them for authentication. - -- To turn this off, set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, or pass `capture_tracing_headers=False` to the Flask or ASGI integration. -- The 7.68 `trust_tracing_headers` and `POSTHOG_MW_TRUST_TRACING_HEADERS` names still work, but are deprecated. -- If you upgrade from 7.68, 7.69 or 7.70 and never turned these headers on, they are now read. - -## 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`. -- `AsyncPosthog.capture_exception` now adds `$trace_id` and `$span_id` from the active OpenTelemetry span, like `Client.capture_exception`. In 7.x it added them only with `capture_trace_context=True`. - -## Event options - -Capture v1 sends processing options in an `options` object, next to `properties`. - -- `capture`, `capture_ai`, `set`, `set_once`, `alias`, `group_identify` and `capture_exception` take an `options` argument: - - ```python - posthog.capture("signed_up", distinct_id="user-1", options={"cookieless_mode": True}) - ``` - - The MCP capture methods take `event_options` instead (see [MCP analytics](#mcp-analytics)), and the AI integrations take `posthog_options` (see [AI capture](#ai-capture)). - -- `super_options`, a new `Client`, `AsyncPosthog` and module setting, sets options on every event, like `super_properties`. -- `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. -- The Celery integration propagates context options to workers, like tags. - - A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. - - In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags and options do not reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)` or `task.apply_async(headers=...)`. -- Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. -- Options are sent as given. PostHog validates them. It drops an event with an option value it cannot read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. -- An `options` value that is not a dict is logged as an error and ignored. The event is still sent. -- Each option replaces a legacy option property: - - | Option | Replaces | - | --- | --- | - | `cookieless_mode` | `$cookieless_mode` | - | `disable_skew_correction` | `$ignore_sent_at` | - | `product_tour_id` | `$product_tour_id` | - | `process_person_profile` | `$process_person_profile` | - - Legacy option properties still work, but an option overrides its legacy property. Use options. -- Pass `True` or `False` for `cookieless_mode`, `disable_skew_correction` and `process_person_profile`. PostHog also reads common forms such as `"false"`, `"off"` or `0`. - -- These four options are the full set. PostHog ignores any other key in `options`. Session and window IDs stay `$session_id` and `$window_id` properties (see [Session and window IDs](#session-and-window-ids)). -- A `None` option counts as unset, so a later step can fill it. +That includes the `process_person_profile = false` the SDK sets for an event without a distinct ID. Without it, PostHog would create a person for the event's generated ID. -Values apply in this order. Steps 2 to 4 fill only the options and properties that the steps before them left unset: +A hook that builds an identify-style event, or renames an event to one, must set the `process_person_profile` option itself (see [Fix identity calls](#fix-identity-calls)). + +A hook that spreads the event into the new dict keeps `options`: + +```python +def remove_email(event): + properties = {key: value for key, value in event["properties"].items() if key != "email"} + return {**event, "properties": properties} +``` -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: `$lib` and `$lib_version` as a pair (see [SDK identity](#sdk-identity)), `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `$trace_id` and `$span_id` from the active OpenTelemetry span (in `capture` and `capture_ai` only with `capture_trace_context=True`, and always in `capture_exception`), `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 +The same applies to MCP's `before_send`. It gets each event's `options` in an `options` key of `PostHogCaptureEvent`. + +An MCP hook that returns a new dict must copy them, or the event is sent without the anonymous `process_person_profile: False`. + +### Route AI events + +8.x.x routes AI events to their own endpoint: + +- **The endpoint.** `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. +- **Event names.** `capture_ai` sends any event name to the AI endpoint. PostHog drops an event whose name doesn't start with `$ai_` as `misrouted_event`, and `on_error` reports it. The SDK writes only a debug log. +- **Custom clients.** The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. +- **Clients without `capture_ai`.** The wrapper, callback handler, or tracing processor logs one error when you create it and captures no AI events. Provider calls still work. Before, such a client got `capture` calls. +- **Mock clients.** Tests that pass a `Mock` client to an AI integration must assert on `mock.capture_ai`, not `mock.capture`. +- **OpenTelemetry.** The OpenTelemetry integration (`PostHogSpanProcessor`, `PostHogTraceExporter`) is unchanged. It still exports spans over OTLP to `/i/v0/ai/otel`. +- **Content capture.** `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. +- **Fallback IDs.** When an AI integration or MCP falls back to a trace, run, or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options`, and context options. `before_send` can still change it. +- **Empty distinct IDs.** An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing. The integration falls back the same way and turns person processing off. +- **Empty distinct IDs in 7.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. +- **Async capture.** `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. It sends events with `requests` in a worker thread, and uses `httpx` only for feature flags and remote config. +- **Async content settings.** `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. + +`Client` and `AsyncPosthog` have new arguments for the AI lane: + +| Argument | Default | +| --- | --- | +| `capture_ai_compression` | No compression | +| `capture_ai_max_queue_size` | 1000 | +| `capture_ai_timeout` | 30 seconds | +| `capture_ai_max_event_bytes` | 8 MiB plus 64 KiB | + +`POSTHOG_CAPTURE_COMPRESSION` doesn't apply to `capture_ai_compression`. You can only lower `capture_ai_max_event_bytes`. + +The AI lane doesn't use `max_queue_size`, `timeout`, or `capture_compression`. An invalid `capture_ai_*` value raises `ValueError` when you create the client. + +The module-level client has no AI lane settings. To change them, create a `Client`. + +### Fix identity calls + +- **Empty IDs.** An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x.x sent a whitespace-only ID, and capture dropped the event. +- **Generated IDs.** Generated personless distinct IDs are UUIDv7. 7.x.x generated UUIDv4. +- **Group fallback.** `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$_`. 7.x.x fell back to a random ID, which created a new person on every call. +- **Long group IDs.** If `$_` is over 200 bytes, pass `distinct_id`. PostHog drops an event whose distinct ID is over 200 bytes. +- **Long distinct IDs.** PostHog drops an event whose `distinct_id` is over 200 UTF-8 bytes (`distinct_id_too_large`), and `on_error` reports it. 7.x.x sent events to legacy capture, which shortened the ID to 200 characters and kept the event. +- **Group sessions.** `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID isn't the session's user. +- **Alias.** `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the SDK drops the call with a warning. +- **Context values.** Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. + +Identify-style events default to person processing on. These are `$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set`, and `$set_once`, whether `alias`, `group_identify`, `set`, `set_once`, `capture`, or `capture_ai` sends them. + +The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option or a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. + +- **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. +- **Legacy property.** When the call sets only the `$process_person_profile` property, the SDK moves it into the option. +- **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. +- **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. +- **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. +- **Built events.** A hook that builds an identify-style event, or renames an event to one, must set the option itself. + +To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`. + +### Check event IDs + +- **Duplicates.** Each event needs its own UUID. Capture keeps the first event for each UUID in a request and drops the later copies. `on_error` reports each dropped copy in `CaptureError.drops` as `(uuid, "duplicate_event_uuid")`. 7.x.x sent every copy. Older PostHog versions reject the whole batch with a 400 `duplicate_event_uuid` instead. +- **Accepted forms.** 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`. +- **Other values.** The SDK replaces any other value with a generated UUID. It 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.** Generated UUIDs are UUIDv7. + +### Check property precedence + +An event's options and properties come from five steps, 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, then the call's `disable_geoip` argument, which fills `$geoip_disable`. An identify-style event also gets `process_person_profile: True` here when the call leaves it unset and the SDK didn't generate its distinct ID (see [Fix identity calls](#fix-identity-calls)). +2. Context options and tags. +3. `super_options` and `super_properties`. +4. Values the SDK sets: + - `$lib` and `$lib_version` as a pair (see [Check SDK identity](#check-sdk-identity)) + - `$is_server` from `is_server` + - `$geoip_disable` from the client's `disable_geoip` setting + - System properties such as `$os` and `$python_version` + - The `$feature/` and `$active_feature_flags` properties from `flags` or `send_feature_flags` + - `$trace_id` and `$span_id` from the active OpenTelemetry span (in `capture` and `capture_ai` only with `capture_trace_context=True`, and always in `capture_exception`) + - `options.process_person_profile = false` for events without a distinct ID + - `$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. + +A property set to `None` counts as set, so no later step fills it. A `None` option counts as unset, so a later step can fill it. + +`$lib` and `$lib_version` are the exception. They count as set only when they're non-empty strings (see [Check SDK identity](#check-sdk-identity)). + +`$set`, `$set_once`, `$groups`, and `$group_set` fill one level deep. When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. -A property set to `None` counts as set, so no later step fills it. -`$lib` and `$lib_version` are the exception: they count as set only when they are non-empty strings (see [SDK identity](#sdk-identity)). -`$set`, `$set_once`, `$groups` and `$group_set` fill one level deep. -When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. These changes follow from this order: -- Properties passed to a call now override `super_properties`. -- A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version` 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`. -- Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag or `super_properties`, as in 7.x. -- A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x the span's IDs beat context tags. Setting either key in the call's `properties`, a context tag or `super_properties` stops the SDK adding both. In 7.x the SDK still added the other key. -- 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`. To turn person processing on for it, set the `process_person_profile` option. -- Review your `before_send` hooks for the new event shape. To change an option, edit `options`. A hook that returns a new dict must copy `options`, or the event is sent without them. That includes the `process_person_profile = false` the SDK sets for an event without a distinct ID, so PostHog would create a person for its generated ID. -- When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id`. Their options still apply, because the trim removes only properties. - -## AI capture - -- `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. -- Send `$ai_` events with `capture_ai`. `capture()` sends every event to `/i/v1/analytics/events`, including `$ai_` events. PostHog accepts them there for now, but this is temporary. -- `capture_ai` sends any event name to the AI endpoint. PostHog drops an event whose name does not start with `$ai_` as `misrouted_event`, and `on_error` reports it. The SDK writes only a debug log. -- The OpenTelemetry integration (`PostHogSpanProcessor`, `PostHogTraceExporter`) is unchanged: it still exports spans over OTLP to `/i/v0/ai/otel`. -- `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. -- The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. If the client has no `capture_ai`, the wrapper, callback handler or tracing processor logs one error when you create it and captures no AI events; provider calls still work. Before, such a client got `capture` calls. -- Tests that pass a `Mock` client to an AI integration must assert on `mock.capture_ai`, not `mock.capture`. -- When an AI integration or MCP falls back to a trace, run or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options` and context options. `before_send` can still change it. -- An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing, so the integration falls back the same way and turns person processing off. In 7.x, an empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. -- The AI integrations take capture options: - - Every wrapper method that takes `posthog_properties` also takes `posthog_options`, for the events of that call. This covers OpenAI, Anthropic and Gemini, sync and async, and the Claude Agent SDK `query()` and `PostHogClaudeSDKClient`. The name keeps it apart from the Claude Agent SDK's own `options`. - - The Gemini clients take `posthog_options` as a default for every call. A call's `posthog_options` win key by key. - - LangChain's `CallbackHandler` and `PostHogMiddleware`, OpenAI Agents' `instrument()` and `PostHogTracingProcessor`, and the Claude Agent SDK's `instrument()` and `PostHogClaudeAgentProcessor` take `options=` for every event. LangChain also reads `posthog_options` from run metadata, like `posthog_properties`. Like properties, the handler's or processor's options win over a run's or a query's. The `$exception` event LangChain captures for a failed run gets the same options. - - These are the event's own options, so they win over context options and `super_options`, which still fill the keys they leave unset. `before_send` can still change them. - - A value that is not a dict is logged as a warning and ignored. The provider call still runs, and the provider never receives `posthog_options`. - - ```python - from posthog.ai.langchain import CallbackHandler - from posthog.ai.openai import OpenAI - - client = OpenAI(posthog_client=posthog_client) - client.chat.completions.create( - model="gpt-5-mini", - messages=[{"role": "user", "content": "Hello"}], - posthog_distinct_id="user-1", - posthog_options={"cookieless_mode": True}, - ) - - handler = CallbackHandler( - posthog_client, distinct_id="user-1", options={"cookieless_mode": True} - ) - ``` -- New `Client` and `AsyncPosthog` arguments for the AI lane: - - `capture_ai_compression`: default none. `POSTHOG_CAPTURE_COMPRESSION` does not apply to it. - - `capture_ai_max_queue_size`: default 1000 - - `capture_ai_timeout`: default 30 seconds - - `capture_ai_max_event_bytes`: default 8 MiB plus 64 KiB. You can only lower it. -- The AI lane does not use `max_queue_size`, `timeout` or `capture_compression`. An invalid `capture_ai_*` value raises `ValueError` when you create the client. -- The module-level client has no AI lane settings. To change them, create a `Client`. -- `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. -- `AsyncPosthog` sends events with `requests` in a worker thread. It uses `httpx` only for feature flags and remote config. -- `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. - -## MCP analytics +- **Call properties.** Properties passed to a call now override `super_properties`. +- **SDK values.** A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version`, 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.x the SDK overwrote it. +- **SDK values and `super_properties`.** `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`. +- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x. +- **Trace IDs.** A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x.x the span's IDs beat context tags. +- **Trace ID pairs.** Setting either key in the call's `properties`, a context tag, or `super_properties` stops the SDK adding both. In 7.x.x the SDK still added the other key. +- **The `disable_geoip` argument.** 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`.** It now sends `$geoip_disable: false`. In 7.x.x it sent no `$geoip_disable` property. +- **The `groups` argument.** It merges into a `$groups` property of the call, and wins key by key. In 7.x.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way. +- **Merged dicts.** 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. +- **Events without a distinct ID.** An event without a distinct ID gets `options.process_person_profile = false`. To turn person processing on for it, set the `process_person_profile` option. +- **Minimal flag events.** When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id`. Their options still apply, because the trim removes only properties. + +### Check SDK identity + +By default every event carries `$lib: "posthog-python"` and the installed package version in `$lib_version`. PostHog stores the values the event is sent with. + +You can set them in a call's `properties`, a context tag, or `super_properties`. Like any other property, each one comes from the most specific place that sets it, so set both in the same place. + +`$lib` and `$lib_version` count as set only when they're non-empty strings. `before_send` sees both and can change or remove them. + +If you set `$lib` without `$lib_version`, the SDK adds its own version only when `$lib` is `posthog-python`. The SDK replaces a `$lib_version` that has no `$lib` with its own. + +| You set | 8.x.x sends | 7.x.x sent | +| --- | --- | --- | +| `$lib` or `$lib_version` in a call or a context tag | Your value | The SDK's value | +| `super_properties={"$lib": "my-lib"}` | No `$lib_version` | The SDK version | +| `super_properties={"$lib_version": "1.0"}` without a `$lib` | The SDK's `$lib` and version | `1.0` | + +If an event reaches PostHog without `$lib`, PostHog fills both from the SDK name and version your request sends. If `$lib` is that SDK name and `$lib_version` is missing, empty, or not a string, PostHog fills `$lib_version` the same way. + +MCP analytics events from `posthog.mcp.instrument()` or `PostHogMCP` report `posthog-python-mcp` and the installed package version. The integration sets both in each event's own properties, so neither a context tag nor `super_properties` replaces them, but `before_send` can. + +In 7.x.x the MCP integration relabeled the whole client, or the default client when `instrument()` got none. That covered the app's own events, the request headers, and feature flag requests. + +In 8.x.x those report `posthog-python`, so one client can serve both. + +Custom events from `PostHogMCP.capture()` now report `posthog-python`. In 7.x.x they reported `posthog-python-mcp`. + +Custom events from `McpAnalytics.capture()`, the handle `instrument()` returns, still report `posthog-python-mcp`. If an insight or dashboard filters on `$lib = posthog-python-mcp`, check that it still finds the events you expect. + +### Update error handling + +Capture v1 returns a result for every event, even when the request succeeds. An event can be dropped inside a 2xx response, for example when it's over a product quota. + +8.x.x reports those events as failures. When the whole request is over the billing limit, capture returns a `402` with no per-event results instead. + +Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `endpoint`, `request_id`, and `attempts`, and these fields: + +| Field | Holds | +| --- | --- | +| `status` | The HTTP status, or `0` when the request never got a response | +| `drops` | `(uuid, details)` pairs for the events capture dropped in a 2xx response | +| `retry_exhausted` | The UUIDs still waiting for a retry after the last attempt | +| `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response | +| `verdict_summary()` | Counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` | + +When `status` is `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. + +`CaptureEventResult` comes from `posthog.capture_send`. An event missing from `event_results` never got a result. + +`on_error` and the SDK's log lines change with it: + +- **Every failure.** `on_error(error, batch)` receives every failure, including in `sync_mode` and from `AsyncPosthog.capture_immediate`. +- **Logs with `on_error`.** With `on_error` set, the SDK logs nothing for a failed batch. +- **Logs without `on_error`.** The SDK logs one line per failed batch, for example `2 event(s) not persisted by /i/v1/analytics/events: ...`. The line never contains event content or the server's response text. After a 2xx response it also counts the lost events by PostHog's reason, for example `drop/event_too_big=1`. +- **Removed log lines.** The 7.x.x lines `error uploading: ...`, `async capture upload failed (...)`, and `Immediate async capture failed (...)` are gone. In `sync_mode`, a failed request no longer logs the 7.x.x `Error in capture: ...` line, or the same line for another method. Update any log-based alerts that match these lines. +- **Failures in `sync_mode`.** A failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error. +- **Retries in `sync_mode`.** The SDK retries a failed request like a background batch, in the calling thread. With the default `max_retries` of 3, a call makes up to 4 attempts. Each attempt can take up to `timeout` (`capture_ai_timeout` for AI events). 7.x.x made one attempt. +- **Blocking in `sync_mode`.** With the defaults, an analytics event whose requests keep timing out blocks the call for about a minute. +- **Captures inside `on_error`.** If a capture inside `on_error` fails, the SDK logs the failure and doesn't call `on_error` again. +- **Event content.** The SDK's own log lines no longer contain event content. The debug line for a queued event names the event and the lane. The invalid timestamp warning no longer includes the value. To inspect events, use `before_send`. +- **Hook errors.** When your `before_send` hook raises, the error line names the event and the exception type, not the exception message. The message can hold the data the hook was removing. The SDK logs the traceback at debug level. + +Retries differ from 7.x.x in four ways: + +| Behavior | 7.x.x | 8.x.x | +| --- | --- | --- | +| Statuses retried | `429` and every 5xx, but not `404` | `404`, `408`, `429`, `500`, `502`, `503`, `504`, and network errors | +| Connection tries in each attempt | Up to three | One | +| First retry wait | Up to 1 second | 100 ms | +| `Consumer` default retries | 10 | 3 | + +In 8.x.x, any other error status fails the batch at once, including the other 5xx codes. In 7.x.x, a capture request could block for up to three times the `timeout` per attempt. + +In 8.x.x the wait doubles each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients don't retry at the same moment. + +A `Retry-After` header sets a minimum wait, up to 30 seconds. + +Capture itself doesn't return `429`, but a proxy in front of it can. + +A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. + +### Check dropped events + +Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only the invalid event, so the rest of the batch is delivered. + +| Event | 8.x.x | 7.x.x | +| --- | --- | --- | +| The name is missing, `None`, empty, or not a string | Dropped, with a warning | Sent | +| A `before_send` hook sets `distinct_id` to `None` or a non-string | Dropped, with a warning | Sent, and capture rejected the whole batch | +| The name is over 200 UTF-8 bytes | Dropped by PostHog as `event_name_too_long` | Sent, with no name limit | +| A `NaN` or infinite `float` value | Sent as `null`, as in 7.75.0 and later | Invalid JSON before 7.75.0, so capture rejected the whole batch | +| It can't be serialized to JSON, for example with a circular reference | Dropped and logged | Logged the same line | +| A property or option key JSON can't represent, such as a tuple | Key dropped without a log | Event dropped | +| A key is an `int`, finite `float`, `bool`, or `None` | Key converted to a string | Key converted to a string | +| A key is a `NaN` or infinite `float` | Event dropped | Event dropped | + +The SDK logs one warning per reason per process for a dropped name or `distinct_id`. It doesn't convert the hook's `distinct_id` or make up an ID. + +`on_error` reports an event that PostHog drops for its name. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. + +Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. The SDK moves them out of `properties` for you, by the value's type: + +- **A string.** The SDK sends it as given, including `""`. +- **`None`.** It counts as unset, and the SDK removes it. +- **Any other value.** The SDK removes it and doesn't send it, because capture would reject the whole batch. 7.x.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. + +8.x.x applies these size and batch rules: + +- **Analytics events.** The SDK drops and logs an analytics event over 10 MiB, because no capture request can carry it. 7.x.x dropped events over 900 KiB. +- **Capture's own limit.** Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. +- **AI events.** The SDK drops and logs an AI event over `capture_ai_max_event_bytes`. +- **Batches.** A batch stops before an event that would take it past 5 MiB. A larger event is sent alone. +- **Queues.** `flush()` and `shutdown()` drain the analytics and AI queues at the same time. + +### Check exceptions + +- **The `level=` argument.** `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. +- **Reserved properties.** `capture_exception` ignores reserved exception properties in `properties`, such as `$exception_list`, `$exception_level`, and `$exception_source`. In 7.x.x they overrode the SDK's values, with a `DeprecationWarning`. Use `level=` instead of `$exception_level`. +- **Async level.** `AsyncPosthog.capture_exception` now sends `$exception_level`. +- **Async trace IDs.** `AsyncPosthog.capture_exception` now adds `$trace_id` and `$span_id` from the active OpenTelemetry span, like `Client.capture_exception`. In 7.x.x it added them only with `capture_trace_context=True`. + +### Check tracing headers + +The Django, Flask, and ASGI integrations read the `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers by default, as in 7.71.0 and later. The headers only fill a missing distinct ID or session ID, and any client can set them, so never use them for authentication. + +- **Turn it off.** Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, or pass `capture_tracing_headers=False` to the Flask or ASGI integration. +- **Upgrades from 7.68.x, 7.69.x, or 7.70.x.** If you never turned these headers on, they're now read. + +## Follow ups + +### Move to event options + +Capture v1 sends processing options in an `options` object, next to `properties`. `capture`, `capture_ai`, `set`, `set_once`, `alias`, `group_identify`, and `capture_exception` take an `options` argument: + +```python +posthog.capture("signed_up", distinct_id="user-1", options={"cookieless_mode": True}) +``` + +The MCP capture methods take `event_options` instead (see [Add MCP event options](#add-mcp-event-options)). The AI integrations take `posthog_options` (see [Send AI events with capture_ai](#send-ai-events-with-capture_ai)). + +- **Every event.** `super_options`, a new `Client`, `AsyncPosthog`, and module setting, sets options on every event, like `super_properties`. +- **One context.** `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. +- **Async contexts.** `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context. +- **Celery workers.** The Celery integration propagates context options to workers, like tags. A propagated `process_person_profile: True` turns person processing on for worker events without a distinct ID. Each of those events creates a person. +- **Celery eager mode.** In eager mode (`task_always_eager`), Celery publishes nothing, so the distinct ID, tags, and options don't reach the task's events. To carry them, pass the task's PostHog headers with `task.apply(headers=...)` or `task.apply_async(headers=...)`. +- **Methods reached.** Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias`, and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method. +- **Validation.** Options are sent as given. PostHog validates them. It drops an event with an option value it can't read, such as a list for `cookieless_mode`, as `invalid_options`, and `on_error` reports it. +- **Values that aren't a dict.** The SDK logs an `options` value that isn't a dict as an error and ignores it. It still sends the event. +- **Boolean options.** Pass `True` or `False` for `cookieless_mode`, `disable_skew_correction`, and `process_person_profile`. PostHog also reads common forms such as `"false"`, `"off"`, or `0`. + +Each option replaces a legacy option property: + +| Option | Replaces | +| --- | --- | +| `cookieless_mode` | `$cookieless_mode` | +| `disable_skew_correction` | `$ignore_sent_at` | +| `product_tour_id` | `$product_tour_id` | +| `process_person_profile` | `$process_person_profile` | + +Legacy option properties still work, but an option overrides its legacy property. Use options. + +These four options are the full set. PostHog ignores any other key in `options`. + +Session and window IDs stay `$session_id` and `$window_id` properties (see [Check dropped events](#check-dropped-events)). + +### Send AI events with capture_ai + +Send `$ai_` events with `capture_ai`. `capture()` sends every event to `/i/v1/analytics/events`, including `$ai_` events. + +PostHog accepts them there for now, but this is temporary. + +The AI integrations take capture options: + +- **Wrapper methods.** Every wrapper method that takes `posthog_properties` also takes `posthog_options`, for the events of that call. This covers the sync and async OpenAI, Anthropic, and Gemini wrappers, and the Claude Agent SDK `query()` and `PostHogClaudeSDKClient`. The name keeps it apart from the Claude Agent SDK's own `options`. +- **Gemini clients.** The Gemini clients take `posthog_options` as a default for every call. A call's `posthog_options` win key by key. +- **Handlers and processors.** LangChain's `CallbackHandler` and `PostHogMiddleware` take `options=` for every event. So do OpenAI Agents' `instrument()` and `PostHogTracingProcessor`, and the Claude Agent SDK's `instrument()` and `PostHogClaudeAgentProcessor`. +- **Run metadata.** LangChain also reads `posthog_options` from run metadata, like `posthog_properties`. Like properties, the handler's or processor's options win over a run's or a query's. +- **Failed runs.** The `$exception` event LangChain captures for a failed run gets the same options. +- **Precedence.** These are the event's own options, so they win over context options and `super_options`, which still fill the keys they leave unset. `before_send` can still change them. +- **Values that aren't a dict.** The SDK logs a value that isn't a dict as a warning and ignores it. The provider call still runs, and the provider never receives `posthog_options`. + +Pass `posthog_options` to a wrapper call, or `options` to a handler: + +```python +from posthog.ai.langchain import CallbackHandler +from posthog.ai.openai import OpenAI + +client = OpenAI(posthog_client=posthog_client) +client.chat.completions.create( + model="gpt-5-mini", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="user-1", + posthog_options={"cookieless_mode": True}, +) + +handler = CallbackHandler( + posthog_client, distinct_id="user-1", options={"cookieless_mode": True} +) +``` + +### Add MCP event options MCP events take options like other events, through an `event_options` argument and an `event_options` callback. -- The `PostHogMCP` capture methods (`capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability` and `capture_feedback`) and `McpAnalytics.capture`, on the handle `instrument()` returns, take an `event_options` argument. It sets options on that event. For a failed tool call, it also sets them on the `$exception` event. -- `MCPAnalyticsOptions(event_options=...)` sets options on every event `instrument()` captures automatically, including `$exception` events. It does not apply to `McpAnalytics.capture`. Like `event_properties`, it is a function of `(request, extra)` that returns a dict or `None`, and it can be sync or async. -- `PostHogMCP.capture()` is the core client's `capture`, so it takes `options`. The MCP capture methods take `event_options`. -- The SDK passes these options as the event's own options, so they win over context options and `super_options`. -- An event without an identity always sends `process_person_profile: False`, even when `event_options` sets it to `True`. Its distinct ID is the session ID, so person processing would create a person for each session. To process a person, identify the user with `identify` or pass `distinct_id`. -- An `event_options` value that is not a dict is logged and ignored. The event is still sent. +- **The argument.** The `PostHogMCP` capture methods and `McpAnalytics.capture`, on the handle `instrument()` returns, take an `event_options` argument. The capture methods are `capture_tool_call`, `capture_initialize`, `capture_tools_list`, `capture_missing_capability`, and `capture_feedback`. +- **What the argument sets.** It sets options on that event. For a failed tool call, it also sets them on the `$exception` event. +- **The callback.** `MCPAnalyticsOptions(event_options=...)` sets options on every event `instrument()` captures automatically, including `$exception` events. It doesn't apply to `McpAnalytics.capture`. +- **The callback's shape.** Like `event_properties`, it's a function of `(request, extra)` that returns a dict or `None`, and it can be sync or async. +- **The core `capture`.** `PostHogMCP.capture()` is the core client's `capture`, so it takes `options`. The MCP capture methods take `event_options`. +- **Precedence.** The SDK passes these options as the event's own options, so they win over context options and `super_options`. +- **Events without an identity.** An event without an identity always sends `process_person_profile: False`, even when `event_options` sets it to `True`. Its distinct ID is the session ID, so person processing would create a person for each session. To process a person, identify the user with `identify` or pass `distinct_id`. +- **Values that aren't a dict.** The SDK logs an `event_options` value that isn't a dict and ignores it. It still sends the event. + +Set the callback in `instrument()`, or pass the argument to a capture method: ```python from posthog.mcp import MCPAnalyticsOptions, PostHogMCP, instrument @@ -339,22 +481,21 @@ mcp_client.capture_tool_call( ) ``` -`instrument()` captures `$mcp_tool_call` after the tool handler returns. -A context you open inside the handler has ended by then, so options you set in it with `set_context_option` do not reach `$mcp_tool_call`. -Events you capture inside that context still get them. -To set options on `$mcp_tool_call`, use the `event_options` callback. +`instrument()` captures `$mcp_tool_call` after the tool handler returns. A context you open inside the handler has ended by then, so options you set in it with `set_context_option` don't reach `$mcp_tool_call`. + +Events you capture inside that context still get them. To set options on `$mcp_tool_call`, use the `event_options` callback. -MCP's `before_send` gets each event's `options` in an `options` key of `PostHogCaptureEvent`. -A hook that returns a new dict must copy them, or the event is sent without the anonymous `process_person_profile: False`. +### Rename deprecated settings -## Batching and size limits +These old names still work or have no effect in 8.x.x: -- An analytics event over 10 MiB is dropped and logged, because no capture request can carry it. 7.x dropped events over 900 KiB. Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. -- An AI event over `capture_ai_max_event_bytes` is dropped and logged. -- A batch stops before an event that would take it past 5 MiB. A larger event is sent alone. -- `flush()` and `shutdown()` drain the analytics and AI queues at the same time. +| Old | New | +| --- | --- | +| `trust_tracing_headers`, `POSTHOG_MW_TRUST_TRACING_HEADERS` | `capture_tracing_headers`, `POSTHOG_MW_CAPTURE_TRACING_HEADERS` | +| `_use_ai_lane`, `_enable_multimodal_capture` | `enable_full_ai_capture` | +| `posthog.capture_mode` | Nothing | +| A `gzip=` keyword on `request.post` or `request.flags` | Nothing | -## OpenFeature provider +The 7.68.x tracing header names still work, but are deprecated. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. -`openfeature-provider-posthog` 0.2.0 works with posthog 7.x and 8.x. -Older provider versions require posthog below 8.0, so upgrade the provider before or with posthog. +Setting `posthog.capture_mode` on the module has no effect. A `gzip=` keyword raises no error, and it's sent as a field in the request body. From 78a09947b9944c457013f10fc172fc11e699d326 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 15:01:08 -0800 Subject: [PATCH 18/27] docs: name the project setting for keeping identify-style events personless Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index ce9afc646..f23458e43 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -196,7 +196,7 @@ The SDK sets `process_person_profile: True` on them unless the call sets the `pr - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. - **Built events.** A hook that builds an identify-style event, or renames an event to one, must set the option itself. -To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`. +To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`, or turn off person processing for the project in PostHog. ### Check event IDs From 564989f0bb787bc3eaa607ea6764d96693f085c3 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 15:01:10 -0800 Subject: [PATCH 19/27] docs: regenerate the migration reference Co-Authored-By: Claude Opus 5.5 --- references/posthog-python-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index 377c6c053..3c1c8294e 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -200,7 +200,7 @@ The SDK sets `process_person_profile: True` on them unless the call sets the `pr - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. - **Built events.** A hook that builds an identify-style event, or renames an event to one, must set the option itself. -To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`. +To keep one of these events personless, set `process_person_profile: False` on the call or in `before_send`, or turn off person processing for the project in PostHog. ### Check event IDs From 17ee74c06bd93d38daaeedeb141070534a2f76b9 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 16:03:44 -0800 Subject: [PATCH 20/27] docs: name the calls whose $process_person_profile property becomes the option Only capture and capture_ai take event properties. The properties of set, set_once and group_identify are person and group properties. Co-Authored-By: Claude Fable 5.2 --- docs/migration-7.x-to-8.0.md | 4 ++-- references/posthog-python-migration.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index f23458e43..510f5e42f 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -187,10 +187,10 @@ The module-level client has no AI lane settings. To change them, create a `Clien Identify-style events default to person processing on. These are `$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set`, and `$set_once`, whether `alias`, `group_identify`, `set`, `set_once`, `capture`, or `capture_ai` sends them. -The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option or a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. +The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option, a `capture` or `capture_ai` call sets a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. - **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. -- **Legacy property.** When the call sets only the `$process_person_profile` property, the SDK moves it into the option. +- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so set the option there. - **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. - **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index 3c1c8294e..6c962738d 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -191,10 +191,10 @@ The module-level client has no AI lane settings. To change them, create a `Clien Identify-style events default to person processing on. These are `$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set`, and `$set_once`, whether `alias`, `group_identify`, `set`, `set_once`, `capture`, or `capture_ai` sends them. -The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option or a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. +The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option, a `capture` or `capture_ai` call sets a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. - **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. -- **Legacy property.** When the call sets only the `$process_person_profile` property, the SDK moves it into the option. +- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so set the option there. - **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. - **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. From 804dc4f584eb006fdc9ed8132b88bc7b4349c48d Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 16:58:49 -0800 Subject: [PATCH 21/27] docs: name the CaptureDroppedEvent entries of CaptureError.drops in the guide Co-Authored-By: Claude Fable 5.2 --- docs/migration-7.x-to-8.0.md | 4 ++-- references/posthog-python-migration.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 510f5e42f..055d3ea90 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -285,14 +285,14 @@ Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `end | Field | Holds | | --- | --- | | `status` | The HTTP status, or `0` when the request never got a response | -| `drops` | `(uuid, details)` pairs for the events capture dropped in a 2xx response | +| `drops` | A `CaptureDroppedEvent(uuid, details)` for each event capture dropped in a 2xx response | | `retry_exhausted` | The UUIDs still waiting for a retry after the last attempt | | `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response | | `verdict_summary()` | Counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` | When `status` is `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. -`CaptureEventResult` comes from `posthog.capture_send`. An event missing from `event_results` never got a result. +`CaptureDroppedEvent` and `CaptureEventResult` come from `posthog.capture_send`. A `CaptureDroppedEvent` is a tuple, so `for uuid, details in error.drops` also works. An event missing from `event_results` never got a result. `on_error` and the SDK's log lines change with it: diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index 6c962738d..b12e19fea 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -289,14 +289,14 @@ Every capture failure is a `CaptureError`, a subclass of `APIError`. It has `end | Field | Holds | | --- | --- | | `status` | The HTTP status, or `0` when the request never got a response | -| `drops` | `(uuid, details)` pairs for the events capture dropped in a 2xx response | +| `drops` | A `CaptureDroppedEvent(uuid, details)` for each event capture dropped in a 2xx response | | `retry_exhausted` | The UUIDs still waiting for a retry after the last attempt | | `event_results` | A `CaptureEventResult(result, details)` for each UUID that got a result in a 2xx response | | `verdict_summary()` | Counts such as `drop/llm_events_over_quota=1, retry/not_persisted=1` | When `status` is `0`, `__cause__` holds the network error. In 7.x.x, `on_error` received the raw `requests` exception. -`CaptureEventResult` comes from `posthog.capture_send`. An event missing from `event_results` never got a result. +`CaptureDroppedEvent` and `CaptureEventResult` come from `posthog.capture_send`. A `CaptureDroppedEvent` is a tuple, so `for uuid, details in error.drops` also works. An event missing from `event_results` never got a result. `on_error` and the SDK's log lines change with it: From 6885b26381cf239b5c518ba05fcff1919f7882ef Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 12 Nov 2026 18:05:48 -0800 Subject: [PATCH 22/27] docs: restore upgrade notes that the retired posthog.com page carried Compression formats including zstd, the proxy pass-through rule, and that a `$set` in a global or context layer reaches every event. Co-Authored-By: Claude Fable 5.2 --- docs/migration-7.x-to-8.0.md | 5 ++++- posthog/client.py | 5 +++-- references/posthog-python-8.md | 2 +- references/posthog-python-migration.md | 5 ++++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 055d3ea90..f90e001ba 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -67,6 +67,7 @@ If you send events through your own reverse proxy, update it before you upgrade: - **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. - **Pass the headers through unchanged.** Pass `Authorization`, every `PostHog-*` header, `Content-Type`, `Content-Encoding`, and `User-Agent`. `Authorization` holds the project token, sent as `Bearer `. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. - **Accept request bodies up to 20 MB.** AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. +- **Pass the capture response through.** Return PostHog's status code and response body unchanged. A proxy that answers the capture path with its own `200` fails the batch. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). @@ -104,6 +105,8 @@ Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no For gzip, set `capture_compression=CaptureCompression.GZIP`. `capture_compression` defaults to no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. +`capture_compression` and `capture_ai_compression` take `CaptureCompression.GZIP`, `DEFLATE`, or `ZSTD`. `ZSTD` needs the `zstandard` package, so install `posthog[zstd]`. Without it, the client raises `ValueError`. + Replace `from posthog.capture_v1 import CaptureV1Error` with `from posthog import CaptureError`. There is no alias. The parameters after `path` in `request.post` and after `host` in `request.flags` are keyword-only, so a 7.x.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error. It's sent as a field in the request body. @@ -229,7 +232,7 @@ A property set to `None` counts as set, so no later step fills it. A `None` opti `$set`, `$set_once`, `$groups`, and `$group_set` fill one level deep. When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. -For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. +For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. A `$set` in `super_properties` or a context tag sets those person properties on every event it reaches. These changes follow from this order: diff --git a/posthog/client.py b/posthog/client.py index e43e89472..7d47f8b02 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1400,8 +1400,9 @@ def __init__( exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. capture_compression: Request-body compression for ``capture()`` - uploads. ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the - strings ``"gzip"``/``"deflate"``). When omitted, the + uploads. ``CaptureCompression.GZIP``, ``DEFLATE`` or ``ZSTD`` + (or the strings ``"gzip"``, ``"deflate"`` or ``"zstd"``). + ``ZSTD`` needs ``pip install posthog[zstd]``. When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then no compression. capture_ai_compression: Request-body compression for diff --git a/references/posthog-python-8.md b/references/posthog-python-8.md index adec60cf5..cbede3a95 100644 --- a/references/posthog-python-8.md +++ b/references/posthog-python-8.md @@ -205,7 +205,7 @@ Name | Type | Description | Default `exception_autocapture_bucket_size` | | Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100). | `DEFAULT_BUCKET_SIZE` `exception_autocapture_refill_rate` | | Tokens restored per refill interval for each exception type's bucket. | `DEFAULT_REFILL_RATE` `exception_autocapture_refill_interval_seconds` | | Seconds between token refills for autocaptured exception rate limiting. | `DEFAULT_REFILL_INTERVAL_SECONDS` -`capture_compression` | `Optional[Union[CaptureCompression, str]]` | Request-body compression for `capture()` uploads. `CaptureCompression.GZIP` or `DEFLATE` (or the strings `"gzip"`/`"deflate"`). When omitted, the `POSTHOG_CAPTURE_COMPRESSION` env var is consulted, then no compression. | `None` +`capture_compression` | `Optional[Union[CaptureCompression, str]]` | Request-body compression for `capture()` uploads. `CaptureCompression.GZIP`, `DEFLATE` or `ZSTD` (or the strings `"gzip"`, `"deflate"` or `"zstd"`). `ZSTD` needs `pip install posthog[zstd]`. When omitted, the `POSTHOG_CAPTURE_COMPRESSION` env var is consulted, then no compression. | `None` `capture_ai_compression` | `Optional[Union[CaptureCompression, str]]` | Request-body compression for `capture_ai()` uploads, set independently of `capture_compression`. Defaults to no compression, and the env var does not apply. `CaptureCompression.ZSTD` suits large AI payloads. | `None` `capture_ai_max_queue_size` | `int` | Maximum number of AI events buffered before upload. Defaults to 1000, lower than `max_queue_size` because AI events are much larger. | `1000` `capture_ai_timeout` | `float` | Seconds allowed for one AI upload request. Defaults to 30, longer than `timeout` because AI batches are much larger. | `30` diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index b12e19fea..d0e1522d4 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -71,6 +71,7 @@ If you send events through your own reverse proxy, update it before you upgrade: - **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. - **Pass the headers through unchanged.** Pass `Authorization`, every `PostHog-*` header, `Content-Type`, `Content-Encoding`, and `User-Agent`. `Authorization` holds the project token, sent as `Bearer `. PostHog answers `401` without `Authorization` and `400` without the others, and the batch fails. - **Accept request bodies up to 20 MB.** AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy fails the batch. +- **Pass the capture response through.** Return PostHog's status code and response body unchanged. A proxy that answers the capture path with its own `200` fails the batch. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). @@ -108,6 +109,8 @@ Capture v1 is the only path. Setting `posthog.capture_mode` on the module has no For gzip, set `capture_compression=CaptureCompression.GZIP`. `capture_compression` defaults to no compression. `POSTHOG_CAPTURE_COMPRESSION` still works. +`capture_compression` and `capture_ai_compression` take `CaptureCompression.GZIP`, `DEFLATE`, or `ZSTD`. `ZSTD` needs the `zstandard` package, so install `posthog[zstd]`. Without it, the client raises `ValueError`. + Replace `from posthog.capture_v1 import CaptureV1Error` with `from posthog import CaptureError`. There is no alias. The parameters after `path` in `request.post` and after `host` in `request.flags` are keyword-only, so a 7.x.x call that passes them by position raises `TypeError`. A `gzip=` keyword raises no error. It's sent as a field in the request body. @@ -233,7 +236,7 @@ A property set to `None` counts as set, so no later step fills it. A `None` opti `$set`, `$set_once`, `$groups`, and `$group_set` fill one level deep. When the call and a later step both set one of them to a dict, the later step adds only the keys the call left out. -For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. +For example, `super_properties={"$set": {"plan": "free", "source": "web"}}` and a call with `properties={"$set": {"plan": "pro"}}` send `{"plan": "pro", "source": "web"}`. A `$set` in `super_properties` or a context tag sets those person properties on every event it reaches. These changes follow from this order: From 3c7d3edb1860fc477af1ba7689ff6ba4a0715b80 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 13 Nov 2026 11:11:13 -0500 Subject: [PATCH 23/27] docs: name the SDK in versions, cut self-hosted notes, and split dashes and semicolons Co-Authored-By: Claude Opus 5.5 --- .../ai-integrations-require-capture-ai.md | 2 +- ...capture-hook-log-and-connection-retries.md | 2 +- .../capture-same-origin-redirects.md | 2 +- ...exception-level-and-reserved-properties.md | 2 +- .../changesets/identity-and-context-rules.md | 2 +- .sampo/changesets/lib-is-an-sdk-value.md | 2 +- .sampo/changesets/mcp-keeps-sdk-identity.md | 2 +- .../openfeature-provider-posthog-8.md | 2 +- .sampo/changesets/request-keyword-only.md | 2 +- .sampo/changesets/session-id-drop-warning.md | 2 +- README.md | 14 ++-- docs/migration-7.x-to-8.0.md | 13 ++-- posthog/mcp/README.md | 74 +++++++++---------- references/posthog-python-migration.md | 13 ++-- 14 files changed, 67 insertions(+), 67 deletions(-) diff --git a/.sampo/changesets/ai-integrations-require-capture-ai.md b/.sampo/changesets/ai-integrations-require-capture-ai.md index d34b6494b..8b4d4a478 100644 --- a/.sampo/changesets/ai-integrations-require-capture-ai.md +++ b/.sampo/changesets/ai-integrations-require-capture-ai.md @@ -2,4 +2,4 @@ pypi/posthog: major --- -The AI integrations no longer fall back to `capture` for a client object without `capture_ai`. Creating an OpenAI, Anthropic or Gemini wrapper, the LangChain `CallbackHandler`, or the OpenAI Agents or Claude Agent SDK processor with such a client now logs one error and captures no AI events; provider calls still work. The error shows up when you set the integration up, and not after a provider call. A `Client` or `AsyncPosthog` instance, and the default global client, work as before. +The AI integrations no longer fall back to `capture` for a client object without `capture_ai`. Creating an OpenAI, Anthropic or Gemini wrapper, the LangChain `CallbackHandler`, or the OpenAI Agents or Claude Agent SDK processor with such a client now logs one error and captures no AI events. Provider calls still work. The error shows up when you set the integration up, and not after a provider call. A `Client` or `AsyncPosthog` instance, and the default global client, work as before. diff --git a/.sampo/changesets/capture-hook-log-and-connection-retries.md b/.sampo/changesets/capture-hook-log-and-connection-retries.md index e5c224754..c17b13754 100644 --- a/.sampo/changesets/capture-hook-log-and-connection-retries.md +++ b/.sampo/changesets/capture-hook-log-and-connection-retries.md @@ -2,7 +2,7 @@ pypi/posthog: patch --- -Capture makes one connection try per attempt, so every physical request carries its own attempt number and request timestamp. 7.x also retried a failed connection up to twice inside each attempt, reusing the request timestamp, which shifted the events' stored time by the time spent connecting. +Capture makes one connection try per attempt, so every physical request carries its own attempt number and request timestamp. posthog-python 7.x.x also retried a failed connection up to twice inside each attempt, reusing the request timestamp, which shifted the events' stored time by the time spent connecting. When `before_send` raises, the error line names the event and the exception type, not the exception message. The traceback is logged at debug level. diff --git a/.sampo/changesets/capture-same-origin-redirects.md b/.sampo/changesets/capture-same-origin-redirects.md index 5428fed89..cd45470f0 100644 --- a/.sampo/changesets/capture-same-origin-redirects.md +++ b/.sampo/changesets/capture-same-origin-redirects.md @@ -2,4 +2,4 @@ pypi/posthog: major --- -Capture follows a `307` or `308` redirect only to the origin of `host`, at most 5 times, for the sync and async clients. Any other redirect fails the batch and reaches `on_error`. In 7.x the sync client followed redirects to any origin and resent the event batch there. +Capture follows a `307` or `308` redirect only to the origin of `host`, at most 5 times, for the sync and async clients. Any other redirect fails the batch and reaches `on_error`. In posthog-python 7.x.x the sync client followed redirects to any origin and resent the event batch there. diff --git a/.sampo/changesets/exception-level-and-reserved-properties.md b/.sampo/changesets/exception-level-and-reserved-properties.md index f047fe3d1..9777d743b 100644 --- a/.sampo/changesets/exception-level-and-reserved-properties.md +++ b/.sampo/changesets/exception-level-and-reserved-properties.md @@ -2,4 +2,4 @@ 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`. +`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 posthog-python 7.x.x they overrode the SDK's values with a `DeprecationWarning`. `AsyncPosthog.capture_exception` now sends `$exception_level`. diff --git a/.sampo/changesets/identity-and-context-rules.md b/.sampo/changesets/identity-and-context-rules.md index 41af04176..293d7d870 100644 --- a/.sampo/changesets/identity-and-context-rules.md +++ b/.sampo/changesets/identity-and-context-rules.md @@ -2,4 +2,4 @@ pypi/posthog: major --- -`group_identify` falls back to the context distinct ID and then to `$_` instead of a random ID, and no longer adds the context session ID. `alias()` no longer takes its target from the context distinct ID. Context tags and options now apply to `alias` and `group_identify`, but context tags, context options, `super_options` and `super_properties` do not set `process_person_profile` on identify-style events (`$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set` and `$set_once`) whose distinct ID the SDK did not generate; those default to `True` unless the call sets it. An empty or whitespace-only `distinct_id` counts as missing, and generated personless distinct IDs are UUIDv7. `AsyncPosthog` follows the same rules. +`group_identify` falls back to the context distinct ID and then to `$_` instead of a random ID, and no longer adds the context session ID. `alias()` no longer takes its target from the context distinct ID. Context tags and options now apply to `alias` and `group_identify`, but context tags, context options, `super_options` and `super_properties` do not set `process_person_profile` on identify-style events (`$identify`, `$create_alias`, `$merge_dangerously`, `$groupidentify`, `$set` and `$set_once`) whose distinct ID the SDK did not generate. Those events default to `True` unless the call sets it. An empty or whitespace-only `distinct_id` counts as missing, and generated personless distinct IDs are UUIDv7. `AsyncPosthog` follows the same rules. diff --git a/.sampo/changesets/lib-is-an-sdk-value.md b/.sampo/changesets/lib-is-an-sdk-value.md index 160010a2a..963751c3a 100644 --- a/.sampo/changesets/lib-is-an-sdk-value.md +++ b/.sampo/changesets/lib-is-an-sdk-value.md @@ -2,4 +2,4 @@ pypi/posthog: major --- -Events send `$lib` and `$lib_version` as they are at send time, whoever set them. If no `$lib` is set in the call's `properties`, a context tag or `super_properties`, the SDK sets both to `posthog-python` and the installed version. If `$lib` is `posthog-python` and `$lib_version` is missing, the SDK fills in the version. A `$lib` that names another library is sent with whatever `$lib_version` it has, so set both when you override them. Only a non-empty string counts as set. In 7.x the SDK overwrote a `$lib` or `$lib_version` set in a call or a context tag. `before_send` can still change or remove both, and `AsyncPosthog` follows the same rules. +Events send `$lib` and `$lib_version` as they are at send time, whoever set them. If no `$lib` is set in the call's `properties`, a context tag or `super_properties`, the SDK sets both to `posthog-python` and the installed version. If `$lib` is `posthog-python` and `$lib_version` is missing, the SDK fills in the version. A `$lib` that names another library is sent with whatever `$lib_version` it has, so set both when you override them. Only a non-empty string counts as set. In posthog-python 7.x.x the SDK overwrote a `$lib` or `$lib_version` set in a call or a context tag. `before_send` can still change or remove both, and `AsyncPosthog` follows the same rules. diff --git a/.sampo/changesets/mcp-keeps-sdk-identity.md b/.sampo/changesets/mcp-keeps-sdk-identity.md index a1987b481..cf189ca82 100644 --- a/.sampo/changesets/mcp-keeps-sdk-identity.md +++ b/.sampo/changesets/mcp-keeps-sdk-identity.md @@ -2,4 +2,4 @@ pypi/posthog: major --- -MCP instrumentation no longer relabels the whole client. In 7.x, `posthog.mcp.instrument()` and `PostHogMCP` changed `$lib` on every event the client sent, including the app's own, and the SDK name in its request headers, feature flag requests included. `instrument()` without a client relabeled the global client. Now only MCP analytics events report `posthog-python-mcp`, and everything else reports `posthog-python`. The label is set in each MCP event's own properties, so a `$lib` in `super_properties` no longer replaces it; `before_send` can. +MCP instrumentation no longer relabels the whole client. In posthog-python 7.x.x, `posthog.mcp.instrument()` and `PostHogMCP` changed `$lib` on every event the client sent, including the app's own, and the SDK name in its request headers, feature flag requests included. `instrument()` without a client relabeled the global client. Now only MCP analytics events report `posthog-python-mcp`, and everything else reports `posthog-python`. The label is set in each MCP event's own properties, so a `$lib` in `super_properties` no longer replaces it. `before_send` can. diff --git a/.sampo/changesets/openfeature-provider-posthog-8.md b/.sampo/changesets/openfeature-provider-posthog-8.md index 02e3f2e00..c91b6da50 100644 --- a/.sampo/changesets/openfeature-provider-posthog-8.md +++ b/.sampo/changesets/openfeature-provider-posthog-8.md @@ -2,4 +2,4 @@ pypi/openfeature-provider-posthog: minor --- -Supports posthog 8.x as well as 7.x. +Supports posthog-python 8.x.x as well as 7.x.x. diff --git a/.sampo/changesets/request-keyword-only.md b/.sampo/changesets/request-keyword-only.md index 4337cd2b8..e4304df4e 100644 --- a/.sampo/changesets/request-keyword-only.md +++ b/.sampo/changesets/request-keyword-only.md @@ -2,4 +2,4 @@ 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`. +The parameters after `path` in `posthog.request.post` and after `host` in `posthog.request.flags` are keyword-only. With `gzip` removed, a posthog-python 7.x.x call that passed them by position bound them to the wrong parameter. It now raises `TypeError`. diff --git a/.sampo/changesets/session-id-drop-warning.md b/.sampo/changesets/session-id-drop-warning.md index aad2adb43..2e1b5b0f6 100644 --- a/.sampo/changesets/session-id-drop-warning.md +++ b/.sampo/changesets/session-id-drop-warning.md @@ -2,4 +2,4 @@ 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. +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 posthog-python 7.x.x these values were sent as properties. diff --git a/README.md b/README.md index bcb872c44..45e58b436 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,21 @@ SDK usage examples and code snippets live in the official documentation so they | SDK Version | Python Versions Supported | Notes | | ------------- | ---------------------------- | -------------------------- | -| 8.0.0+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Capture v1 only, see the [migration guide](docs/migration-7.x-to-8.0.md) | -| 7.3.1 - 7.x | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support | +| 8.0.0+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Capture v1 only | +| 7.3.1 - 7.x.x | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support | | 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support | -| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required | +| 4.0.1 - 6.x.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required | + +To upgrade from posthog-python 7.x.x to 8.0.0, see the [migration guide](docs/migration-7.x-to-8.0.md). ## Documentation - [Python library docs](https://posthog.com/docs/libraries/python) -- [Migrating from 7.x to 8.0](docs/migration-7.x-to-8.0.md) +- [Migrating from posthog-python 7.x.x to 8.0.0](docs/migration-7.x-to-8.0.md) - [Django framework docs](https://posthog.com/docs/libraries/django) - [Flask framework docs](https://posthog.com/docs/libraries/flask) -- [Request integration configuration](references/middleware_configuration.md) — shared middleware options and compatibility notes -- [OpenFeature provider docs](https://posthog.com/docs/feature-flags/installation/openfeature) — use PostHog flags through the [OpenFeature](https://openfeature.dev) Python SDK +- [Request integration configuration](references/middleware_configuration.md), with shared middleware options and compatibility notes +- [OpenFeature provider docs](https://posthog.com/docs/feature-flags/installation/openfeature), to use PostHog flags through the [OpenFeature](https://openfeature.dev) Python SDK ## Contributing diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index f90e001ba..ebf84f8ab 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -16,7 +16,6 @@ Read the checklist first, then the sections that apply to you. **Before you upgrade** -- [ ] You send events to a self-hosted PostHog that doesn't serve the capture v1 endpoints → [Prepare your proxy](#prepare-your-proxy) - [ ] You send events through your own proxy → [Prepare your proxy](#prepare-your-proxy) - [ ] A proxy redirects capture requests to another host → [Prepare your proxy](#prepare-your-proxy) - [ ] You pass a personal API key (`phx_...`) as the project token → [Use a project token](#use-a-project-token) @@ -60,7 +59,7 @@ Read the checklist first, then the sections that apply to you. | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. An endpoint that isn't served drops every event sent to it. +If you self-host PostHog, see the [self-hosting docs](https://posthog.com/docs/self-host) before you upgrade. An endpoint that isn't served drops every event sent to it. If you send events through your own reverse proxy, update it before you upgrade: @@ -158,7 +157,7 @@ An MCP hook that returns a new dict must copy them, or the event is sent without - **Content capture.** `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. - **Fallback IDs.** When an AI integration or MCP falls back to a trace, run, or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options`, and context options. `before_send` can still change it. - **Empty distinct IDs.** An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing. The integration falls back the same way and turns person processing off. -- **Empty distinct IDs in 7.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. +- **Empty distinct IDs in 7.x.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. - **Async capture.** `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. It sends events with `requests` in a worker thread, and uses `httpx` only for feature flags and remote config. - **Async content settings.** `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. @@ -239,7 +238,7 @@ These changes follow from this order: - **Call properties.** Properties passed to a call now override `super_properties`. - **SDK values.** A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version`, 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.x the SDK overwrote it. - **SDK values and `super_properties`.** `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`. -- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x. +- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x.x. - **Trace IDs.** A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x.x the span's IDs beat context tags. - **Trace ID pairs.** Setting either key in the call's `properties`, a context tag, or `super_properties` stops the SDK adding both. In 7.x.x the SDK still added the other key. - **The `disable_geoip` argument.** 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. @@ -327,7 +326,7 @@ A `Retry-After` header sets a minimum wait, up to 30 seconds. Capture itself doesn't return `429`, but a proxy in front of it can. -A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. +A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route. ### Check dropped events @@ -338,7 +337,7 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t | The name is missing, `None`, empty, or not a string | Dropped, with a warning | Sent | | A `before_send` hook sets `distinct_id` to `None` or a non-string | Dropped, with a warning | Sent, and capture rejected the whole batch | | The name is over 200 UTF-8 bytes | Dropped by PostHog as `event_name_too_long` | Sent, with no name limit | -| A `NaN` or infinite `float` value | Sent as `null`, as in 7.75.0 and later | Invalid JSON before 7.75.0, so capture rejected the whole batch | +| A `NaN` or infinite `float` value | Sent as `null` | Sent as `null` from 7.75.0 | | It can't be serialized to JSON, for example with a circular reference | Dropped and logged | Logged the same line | | A property or option key JSON can't represent, such as a tuple | Key dropped without a log | Event dropped | | A key is an `int`, finite `float`, `bool`, or `None` | Key converted to a string | Key converted to a string | @@ -346,7 +345,7 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t The SDK logs one warning per reason per process for a dropped name or `distinct_id`. It doesn't convert the hook's `distinct_id` or make up an ID. -`on_error` reports an event that PostHog drops for its name. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. +`on_error` reports an event that PostHog drops for its name. Before 7.75.0, a `NaN` or infinite `float` value made the JSON invalid, so capture rejected the whole batch. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. The SDK moves them out of `properties` for you, by the value's type: diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index fb25bd29a..57bbf2785 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -79,15 +79,15 @@ report none. The MCP SDK answers `initialize` itself, so the SDK observes it from outside: -- **MCP SDK 2.x**: through `Server.middleware`, on every transport. -- **MCP SDK 1.x over HTTP**: through `PostHogMcpStatelessSessionMiddleware`, which +- **MCP SDK 2.x.x**: through `Server.middleware`, on every transport. +- **MCP SDK 1.x.x over HTTP**: through `PostHogMcpStatelessSessionMiddleware`, which `instrument()` adds to the apps it autowires. If you mount the middleware yourself, pass the server: `app.add_middleware(PostHogMcpStatelessSessionMiddleware, server=mcp)`. Without it, each pod reports the session's first request as its initialize. The deprecated SSE transport also uses the session's first request. The middleware reads only requests that carry no `Mcp-Session-Id`, so an `initialize` that reuses an existing session id reports none. -- **MCP SDK 1.x over stdio**: from the session's first request. A stdio server +- **MCP SDK 1.x.x over stdio**: from the session's first request. A stdio server serves one client, so this misses only a client that never sends a request. Set `send_initialize_event=False` to stop sending `$mcp_initialize`. Many clients reconnect before @@ -103,7 +103,7 @@ instrument(server, posthog, MCPAnalyticsOptions(send_initialize_event=False)) Set `send_tools_list_event=False` to stop sending `$mcp_tools_list`. Nothing else changes: tools keep their injected arguments, and the virtual tools stay listed. -`identify` and `event_properties` run for the handshake too. On MCP SDK 1.x the +`identify` and `event_properties` run for the handshake too. On MCP SDK 1.x.x the SDK has no request context for it, so `extra["ctx"]` carries only `.request`. `get_request_headers(extra)` works the same as for any other request. @@ -195,17 +195,17 @@ Custom dispatchers can call `get_tool_input_properties()` and add its result to the `properties` argument of `capture_tool_call()`. Model capture adds an optional `llm_model` argument to compatible tool schemas. Dispatch never -enforces it, so servers keep working; strict-schema clients see the new field. Set +enforces it, so servers keep working. Strict-schema clients see the new field. Set `capture_model=False` to leave schemas untouched. Conversation correlation adds an optional `conversation_id` argument and returns a handle in -eligible tool results. Clients must echo it to group later calls; calls without it mint new handles. +eligible tool results. Clients must echo it to group later calls. Calls without it mint new handles. Set `enable_conversation_id=False` to retain transport-based session grouping and unchanged response content. Custom `PostHogMCP` dispatchers also enable model capture and conversation correlation by default. The reasoning is recorded in posthog-js `docs/adr/0013`. ## Capture the calling model -Model capture is on by default for instrumented MCP Python SDK 1.x and 2.x servers. +Model capture is on by default for instrumented MCP Python SDK 1.x.x and 2.x.x servers. The SDK records the best model identifier visible to the server as `$mcp_llm_model`. Recognized client metadata wins and sets `$mcp_llm_model_source` to `client_metadata`. The SDK also adds an `llm_model` @@ -232,18 +232,18 @@ A raw low-level server learns ownership while serving `tools/list`. A fresh inst served one has no answer, so it records `llm_model` as the self-reported model and strips nothing (posthog-js ADR-0011: reads fail open, strips fail closed). A tool that declares its own `llm_model` on such an instance is therefore recorded under `$mcp_llm_model` until a listing says -otherwise; `capture_model=False` or `before_send` are the escapes. High-level adapters and +otherwise. `capture_model=False` or `before_send` are the escapes. High-level adapters and standalone `fastmcp.FastMCP` read ownership from the registered tool schema, so they are unaffected and need no prior listing when dispatch uses that registry. -On MCP SDK 1.x, standalone FastMCP application middleware can replace or reroute a tool. +On MCP SDK 1.x.x, standalone FastMCP application middleware can replace or reroute a tool. When it overrides a listing or dispatch hook, PostHog does not inject `llm_model`: a fresh replica cannot safely distinguish that field from a replacement tool's own argument. Client metadata capture remains enabled. This also applies to pass-through logging or -authorization middleware of your own with those hooks; argument-based model capture is skipped +authorization middleware of your own with those hooks. Argument-based model capture is skipped so an application-owned value cannot be mistaken for analytics. FastMCP's stock middleware, such as `LoggingMiddleware`, `TimingMiddleware`, and `ErrorHandlingMiddleware`, never reroutes a -tool and does not count; its tool-injection middleware does. No additional catalog lookup runs +tool and does not count. Its tool-injection middleware does. No additional catalog lookup runs during a tool call. For a custom dispatcher, `PostHogMCP` enables model capture and conversation correlation @@ -311,8 +311,8 @@ Each call emits one `$mcp_feedback` event (never a `$mcp_tool_call`) with `$mcp_feedback_friction_points`, `$mcp_feedback_suggested_improvement`, `$mcp_feedback_tool`, `$mcp_feedback_sentiment`, and `$mcp_feedback_task_completed`. The summary and details also map to -`$mcp_intent`. Free-text fields are sanitized, PII-redacted, and length-bounded; -the raw arguments are never captured. An invalid `feedback_type` falls back to +`$mcp_intent`. Free-text fields are sanitized, PII-redacted, and length-bounded. +The raw arguments are never captured. An invalid `feedback_type` falls back to `other`. The agent receives an honest acknowledgement: the call records feedback and adds no tools. @@ -343,9 +343,9 @@ options = MCPAnalyticsOptions( ``` Declared extras merge into the advertised schema and are captured as -`$mcp_feedback_`. Arguments the agent invents are never captured; the +`$mcp_feedback_`. Arguments the agent invents are never captured. The handler reads them from `report.raw`. A key that collides with a core field -raises at configuration time. `on_feedback` may be sync or async; a returned +raises at configuration time. `on_feedback` may be sync or async. A returned non-blank string replaces the default reply, and a raised handler is logged and falls back to it. The event is captured either way. @@ -372,7 +372,7 @@ if call.is_feedback: return prepared.result ``` -`on_feedback` is ignored on this path — the dispatcher routes reports itself via +`on_feedback` is ignored on this path. The dispatcher routes reports itself via `call.feedback_report`. On this path you own the page rule, because you pass the switches per call. Pass @@ -404,7 +404,7 @@ tool call whenever the registry can't rule the tool out: - A raw low-level server has no registry the SDK can read. - On standalone `fastmcp.FastMCP`, middleware of your own that overrides a listing or dispatch hook can serve tools the registry lacks. -- On standalone `fastmcp.FastMCP` 3 and later, a mounted server, proxy, or +- On standalone `fastmcp.FastMCP` 3.0.0 and later, a mounted server, proxy, or other provider hides its disabled tools. ## Virtual tools on a paginated `tools/list` @@ -413,9 +413,9 @@ tool call whenever the registry can't rule the tool out: (`report_missing=True`) and `send_feedback` (`collect_feedback=True`). A client concatenates every `tools/list` page into one list, so each virtual tool -is appended to the **first page only** — the page every client reads, including +is appended to the **first page only**, the page every client reads, including clients that never follow `nextCursor`. "First page" means a request with no -cursor; an empty string is a valid opaque cursor, so `cursor: ""` is a +cursor. An empty string is a valid opaque cursor, so `cursor: ""` is a continuation page. ### Tool name collisions @@ -432,7 +432,7 @@ MCPAnalyticsOptions( - A real tool using the name **on the first page** wins: the SDK warns, injects nothing, and never intercepts yours. -- A real tool that appears **only on a later page** is shadowed — page one cannot +- A real tool that appears **only on a later page** is shadowed. Page one cannot see page two, so the SDK's tool is already advertised and calls to the name reach it. The SDK warns when that page is served. `@posthog/mcp` is the same. - Configuring **both** virtual tools with one name advertises only @@ -442,12 +442,12 @@ Warnings go to the `logger` option *and* the `posthog.mcp` standard-library logger, so a default-configured host sees them on stderr. At call time the SDK checks ownership again, covering the process that serves a -call without having served a listing — the ordinary multi-pod case. FastMCP and -v2 `MCPServer` are asked via their tool registry; a raw low-level server has +call without having served a listing, the ordinary multi-pod case. FastMCP and +the MCP SDK 2.x.x `MCPServer` are asked via their tool registry. A raw low-level server has none, so the SDK calls your own `tools/list` handler, once per call to a virtual -tool's name and never for ordinary traffic. If that check cannot answer — a +tool's name and never for ordinary traffic. If that check cannot answer (a registry lookup or a listing handler raised, or you registered `tools/list` -after `instrument()` — the call is delegated to your server rather than +after `instrument()`), the call is delegated to your server rather than intercepted, and the reason is logged: guessing the other way would swallow a real tool of yours silently. @@ -460,8 +460,8 @@ happened to be served last. A stateless MCP server issues no session id, so `$session_id` fragments across pods and the client identity (sent only at `initialize`) is lost. PostHog fixes this with -a small ASGI middleware — `PostHogMcpStatelessSessionMiddleware` — that mints a -self-encoded token onto the `Mcp-Session-Id` response header at `initialize`; the +a small ASGI middleware, `PostHogMcpStatelessSessionMiddleware`, that mints a +self-encoded token onto the `Mcp-Session-Id` response header at `initialize`. The client replays it on every request, so any pod recovers the session and harness from the header alone. @@ -469,7 +469,7 @@ the header alone. `instrument()` wraps the FastMCP server's app factories (`streamable_http_app()` / `sse_app()`), so an app you build **after** calling `instrument()` already carries the -middleware — including `mcp.run(transport="streamable-http")`, which calls those +middleware, including `mcp.run(transport="streamable-http")`, which calls those factories internally. Nothing extra to add, as long as `instrument()` runs first: ```python @@ -478,11 +478,11 @@ instrument(server, posthog) server.run(transport="streamable-http") # already wired ``` -### Manual path — required when you build the app yourself +### Manual path, required when you build the app yourself Autowiring only affects an app built **after** `instrument()` runs. If you build or -mount the ASGI app before `instrument()`, or in a different module — the common -FastAPI case — the running app gets **no** middleware and every session falls back to +mount the ASGI app before `instrument()`, or in a different module (the common +FastAPI case), the running app gets **no** middleware and every session falls back to a fragmented per-process id. Add the middleware to your app explicitly: ```python @@ -517,9 +517,9 @@ handle when this option is enabled. The failure used to be silent. It now surfaces two ways: -- **At `instrument()`** — if `streamable_http_app()` was already called before +- **At `instrument()`**: if `streamable_http_app()` was already called before `instrument()` ran, so the live app has no middleware. -- **At runtime, once** — the first time a tool call arrives over streamable HTTP and the +- **At runtime, once**: the first time a tool call arrives over streamable HTTP and the session still has to come from this process's memory. Both go to the logger you pass via `MCPAnalyticsOptions(logger=...)` **and** to the @@ -534,18 +534,18 @@ Neither fires for stdio, for a correctly-wired server, or for a conversation-anc session. The instrument-time check can't see whether you added the middleware yourself (the app is already built by then), so ignore it if you did. -Standalone `fastmcp` 4 uses the MCP SDK v2 handler registry. `instrument()` detects +Standalone `fastmcp` 4.x.x uses the MCP SDK 2.x.x handler registry. `instrument()` detects that registry automatically and captures tool calls over stdio and streamable HTTP, -including the stateless protocol. Mounted tools retain their own arguments; analytics +including the stateless protocol. Mounted tools retain their own arguments. Analytics parameters are removed before dispatch only when the tool does not declare them. Instrumenting both the wrapper and its underlying server works in either order. For versioned tools, argument ownership follows the version requested by the client. Each tool call resolves the schema through FastMCP's tool listing in the current request context, including middleware and session transforms. This adds a schema lookup per call so clients with different tool schemas cannot change how another client's arguments are handled. -The same installation code continues to support standalone FastMCP 2.x/3.x on MCP SDK v1. +The same installation code continues to support standalone FastMCP 2.x.x and 3.x.x on MCP SDK 1.x.x. -Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the +Two gaps worth knowing: jlowin's `fastmcp` 2.x.x and 3.x.x don't expose the attribute the instrument-time check reads, so those servers get the runtime warning only. And the -deprecated SSE transport is excluded — it keys sessions off a query parameter, and the +deprecated SSE transport is excluded. It keys sessions off a query parameter, and the mint sets a response header an SSE client never replays, so the middleware wouldn't help it. diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index d0e1522d4..c80d4c320 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -20,7 +20,6 @@ Read the checklist first, then the sections that apply to you. **Before you upgrade** -- [ ] You send events to a self-hosted PostHog that doesn't serve the capture v1 endpoints → [Prepare your proxy](#prepare-your-proxy) - [ ] You send events through your own proxy → [Prepare your proxy](#prepare-your-proxy) - [ ] A proxy redirects capture requests to another host → [Prepare your proxy](#prepare-your-proxy) - [ ] You pass a personal API key (`phx_...`) as the project token → [Use a project token](#use-a-project-token) @@ -64,7 +63,7 @@ Read the checklist first, then the sections that apply to you. | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you send events to a self-hosted PostHog, check that it serves both `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. Self-hosted PostHog serves the AI endpoint from a separate capture service, so check each route. An endpoint that isn't served drops every event sent to it. +If you self-host PostHog, see the [self-hosting docs](https://posthog.com/docs/self-host) before you upgrade. An endpoint that isn't served drops every event sent to it. If you send events through your own reverse proxy, update it before you upgrade: @@ -162,7 +161,7 @@ An MCP hook that returns a new dict must copy them, or the event is sent without - **Content capture.** `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. - **Fallback IDs.** When an AI integration or MCP falls back to a trace, run, or session ID, it turns person processing off with a per-event option. This is the only option the integrations set on their own. It wins over your `posthog_options`, `options`, `super_options`, and context options. `before_send` can still change it. - **Empty distinct IDs.** An empty or whitespace-only distinct ID passed to an AI integration, such as `posthog_distinct_id=""`, counts as missing. The integration falls back the same way and turns person processing off. -- **Empty distinct IDs in 7.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. +- **Empty distinct IDs in 7.x.x.** An empty ID gave non-streaming wrapper calls a random personless ID. Streaming calls, embeddings, and LangChain used the trace or run ID with person processing on. OpenAI Agents already treated an empty ID as missing. Every integration sent a whitespace-only ID as given, with person processing on. - **Async capture.** `AsyncPosthog` has `capture_ai` and `capture_ai_immediate`. It sends events with `requests` in a worker thread, and uses `httpx` only for feature flags and remote config. - **Async content settings.** `AsyncPosthog` accepts `privacy_mode` and `enable_full_ai_capture`, with the same meaning as on `Client`. An AI integration given an `AsyncPosthog` client used to always truncate and redact media. @@ -243,7 +242,7 @@ These changes follow from this order: - **Call properties.** Properties passed to a call now override `super_properties`. - **SDK values.** A `$is_server`, `$geoip_disable`, `$lib`, `$lib_version`, 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.x the SDK overwrote it. - **SDK values and `super_properties`.** `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`. -- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x. +- **Feature flag properties.** Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag, or `super_properties`, as in 7.x.x. - **Trace IDs.** A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x.x the span's IDs beat context tags. - **Trace ID pairs.** Setting either key in the call's `properties`, a context tag, or `super_properties` stops the SDK adding both. In 7.x.x the SDK still added the other key. - **The `disable_geoip` argument.** 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. @@ -331,7 +330,7 @@ A `Retry-After` header sets a minimum wait, up to 30 seconds. Capture itself doesn't return `429`, but a proxy in front of it can. -A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install. +A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route. ### Check dropped events @@ -342,7 +341,7 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t | The name is missing, `None`, empty, or not a string | Dropped, with a warning | Sent | | A `before_send` hook sets `distinct_id` to `None` or a non-string | Dropped, with a warning | Sent, and capture rejected the whole batch | | The name is over 200 UTF-8 bytes | Dropped by PostHog as `event_name_too_long` | Sent, with no name limit | -| A `NaN` or infinite `float` value | Sent as `null`, as in 7.75.0 and later | Invalid JSON before 7.75.0, so capture rejected the whole batch | +| A `NaN` or infinite `float` value | Sent as `null` | Sent as `null` from 7.75.0 | | It can't be serialized to JSON, for example with a circular reference | Dropped and logged | Logged the same line | | A property or option key JSON can't represent, such as a tuple | Key dropped without a log | Event dropped | | A key is an `int`, finite `float`, `bool`, or `None` | Key converted to a string | Key converted to a string | @@ -350,7 +349,7 @@ Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only t The SDK logs one warning per reason per process for a dropped name or `distinct_id`. It doesn't convert the hook's `distinct_id` or make up an ID. -`on_error` reports an event that PostHog drops for its name. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. +`on_error` reports an event that PostHog drops for its name. Before 7.75.0, a `NaN` or infinite `float` value made the JSON invalid, so capture rejected the whole batch. A `NaN` value in `$set` sets that person property to `null`. A circular reference logs `Error in capture: maximum recursion depth exceeded` with a long traceback. Capture v1 sends `$session_id` and `$window_id` as top-level event fields, not as properties. The SDK moves them out of `properties` for you, by the value's type: From 756fe087a2b5ad7d49b62a0b4a6270bc8fef6a39 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 13 Nov 2026 11:20:40 -0500 Subject: [PATCH 24/27] docs: drop self-hosted lines from the migration guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index ebf84f8ab..d5efba918 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -59,8 +59,6 @@ Read the checklist first, then the sections that apply to you. | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you self-host PostHog, see the [self-hosting docs](https://posthog.com/docs/self-host) before you upgrade. An endpoint that isn't served drops every event sent to it. - If you send events through your own reverse proxy, update it before you upgrade: - **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. From 5432eafff5c6ee6b70be2ae49fee7f5b35393eef Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 13 Nov 2026 12:37:37 -0500 Subject: [PATCH 25/27] docs: drop the "(experimental)" title from the next-major reference page The 8.x page is titled "PostHog Python SDK 8.x" before and after the release. Regenerating also picks up the self-hosted paragraph the migration guide's source already dropped. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 4 +--- references/posthog-python-8.md | 2 +- references/posthog-python-migration.md | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index caf9125a6..9f7237aea 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -265,9 +265,7 @@ def main() -> None: started, *reference(pkg, capabilities(opts.sdk_specs), declared >= major), ] - title = f"PostHog Python SDK {major}.x" + ( - "" if declared >= major else " (experimental)" - ) + title = f"PostHog Python SDK {major}.x" write(f"posthog-python-{major}.md", [front_matter(title, body), *body]) if declared >= major: write( diff --git a/references/posthog-python-8.md b/references/posthog-python-8.md index cbede3a95..7f7416cfa 100644 --- a/references/posthog-python-8.md +++ b/references/posthog-python-8.md @@ -1,5 +1,5 @@ --- -title: "PostHog Python SDK 8.x (experimental)" +title: "PostHog Python SDK 8.x" sidebar: Docs showTitle: true contentMaxWidthClass: max-w-full diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index c80d4c320..bdd59cf09 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -63,8 +63,6 @@ Read the checklist first, then the sections that apply to you. | `capture_ai`, `capture_ai_immediate`, and the AI wrapper integrations | `/i/v1/ai/events` | | OpenTelemetry (`posthog.ai.otel`) | `/i/v0/ai/otel`, unchanged | -If you self-host PostHog, see the [self-hosting docs](https://posthog.com/docs/self-host) before you upgrade. An endpoint that isn't served drops every event sent to it. - If you send events through your own reverse proxy, update it before you upgrade: - **Forward both routes.** Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. From 3002d0e4c94074d28723e2330ad1fb24c3e67de5 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 13 Nov 2026 12:59:20 -0500 Subject: [PATCH 26/27] docs: say to pass process_person_profile in options on set and group_identify "so set the option there" read as setting it in properties, where the SDK treats it as a person property. set, set_once, and group_identify take options, so the guide now names them. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- references/posthog-python-migration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index d5efba918..922616acb 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -190,7 +190,7 @@ Identify-style events default to person processing on. These are `$identify`, `$ The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option, a `capture` or `capture_ai` call sets a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. - **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. -- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so set the option there. +- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so pass `process_person_profile` in the call's `options`. - **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. - **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index bdd59cf09..1c115a766 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -194,7 +194,7 @@ Identify-style events default to person processing on. These are `$identify`, `$ The SDK sets `process_person_profile: True` on them unless the call sets the `process_person_profile` option, a `capture` or `capture_ai` call sets a `$process_person_profile` property, or the SDK generated the distinct ID. PostHog drops the `$create_alias` and `$groupidentify` events when person processing is off, with an ingestion warning, and ignores the person update of `$set` and `$set_once`. - **Call values.** The call's own value wins over context options and tags, `super_options`, and `super_properties`. So a `False` set there never reaches these events when the SDK didn't generate the distinct ID. In 7.x.x, `$process_person_profile: False` in `super_properties` reached them too. -- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so set the option there. +- **Legacy property.** When a `capture` or `capture_ai` call sets only the `$process_person_profile` property, the SDK moves it into the option. The `properties` of `set`, `set_once`, and `group_identify` are person and group properties, so pass `process_person_profile` in the call's `options`. - **`None` values.** A `None` option or property counts as unset here, so the event gets `True`. - **Generated IDs.** An event whose distinct ID the SDK generated gets no default. Context options and `super_options` still fill it, then the personless `process_person_profile: False`. - **`before_send`.** The hook sees the value and can change it. The SDK first removes any `$process_person_profile` property from these events, including one from a context tag or `super_properties`, so the hook sees only the option. From 5b249fb75375888ad74c5a15f1829d7523f2b8f1 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 13 Nov 2026 13:14:14 -0500 Subject: [PATCH 27/27] docs: cut the 404 hint and self-hosted lines from the migration guide Vincent ruled out self-hosted framing in the guides, and the endpoint and 404 sentences read as self-host framing. The retry tables and the proxy notes stay. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 -- references/posthog-python-migration.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 922616acb..9fbb5cb83 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -324,8 +324,6 @@ A `Retry-After` header sets a minimum wait, up to 30 seconds. Capture itself doesn't return `429`, but a proxy in front of it can. -A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route. - ### Check dropped events Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only the invalid event, so the rest of the batch is delivered. diff --git a/references/posthog-python-migration.md b/references/posthog-python-migration.md index 1c115a766..5059a5f2a 100644 --- a/references/posthog-python-migration.md +++ b/references/posthog-python-migration.md @@ -328,8 +328,6 @@ A `Retry-After` header sets a minimum wait, up to 30 seconds. Capture itself doesn't return `429`, but a proxy in front of it can. -A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route. - ### Check dropped events Capture rejects a whole batch when one event can't be parsed. 8.x.x drops only the invalid event, so the rest of the batch is delivered.