Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
698b3a4
chore: add major changeset for posthog 8.0 capture v1 release
eli-r-ph Oct 6, 2026
5b43ec7
feat!: send capture through v1 by default; capture_ai to /i/v1/ai/eve…
eli-r-ph Oct 10, 2026
8da46d8
feat!: remove capture v0 and legacy transport options (#1018)
eli-r-ph Oct 10, 2026
29225eb
refactor!: split capture_v1 into capture_event and capture_send (#1019)
eli-r-ph Oct 10, 2026
20fdb14
feat!: report capture v1 results through CaptureError and on_error (#…
eli-r-ph Oct 10, 2026
a15eff4
feat: AI lane config and size-aware batching (#1022)
eli-r-ph Oct 10, 2026
252886c
test(mcp): fix get_tool checks for fastmcp 4.0.11 (#1050)
eli-r-ph Oct 14, 2026
305e74e
feat!: add per-event capture options (#1023)
eli-r-ph Oct 14, 2026
4cc19c1
feat!: add global and context option layers (#1024)
eli-r-ph Oct 14, 2026
f4b67b5
feat!: AI and MCP send personless as an event option (#1025)
eli-r-ph Oct 14, 2026
49399ed
chore(ci): run compliance harness 1.13.1 once per codec (#1026)
eli-r-ph Oct 14, 2026
dd9c122
feat!: AI wrappers always send through capture_ai (#1027)
eli-r-ph Oct 14, 2026
ed296dc
feat: add an AI capture lane to AsyncPosthog (#1028)
eli-r-ph Oct 14, 2026
5ec93d0
test: cover capture invariants on both lanes (#1029)
eli-r-ph Oct 14, 2026
63d3204
docs: 8.0 migration guide and openfeature-provider pin (#1030)
eli-r-ph Oct 20, 2026
331ae32
feat!: MCP no longer relabels the client SDK identity (#1034)
eli-r-ph Oct 20, 2026
2343064
fix!: follow capture redirects only to the same origin (#1035)
eli-r-ph Oct 20, 2026
0e7afbb
chore: sync main into v1 (7.65.0–7.67.0) (#1044)
eli-r-ph Oct 20, 2026
4d32397
feat!: exception level, keyword-only args, uuid and geoip (#1046)
eli-r-ph Oct 20, 2026
bddc729
fix(ai)!: AI integrations require a client with capture_ai (#1048)
eli-r-ph Oct 20, 2026
d5b29f1
chore: sync main into v1 (7.67.1–7.68.0) (#1051)
eli-r-ph Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 127 additions & 12 deletions .github/workflows/posthog-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ on:
permissions:
contents: read

concurrency:
group: posthog-upgrade-${{ github.event.inputs.package_name }}
cancel-in-progress: false

jobs:
posthog-upgrade:
name: Upgrade PostHog package
Expand Down Expand Up @@ -81,16 +85,117 @@ jobs:
fi
done

- name: Generate branch name
id: generate-branch-name
- name: Generate pull request details
id: generate-pr-details
shell: bash
env:
GH_TOKEN: ${{ steps.upgrader.outputs.token }}
PACKAGE_NAME: ${{ github.event.inputs.package_name }}
PACKAGE_VERSION: ${{ github.event.inputs.package_version }}
run: |
echo "branch_name=${PACKAGE_NAME}-${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
TITLE_PREFIX="chore(deps): update ${PACKAGE_NAME} to "

skip_update() {
echo "::notice::$1"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
}

# Serialize writers and reject stale reruns using Python package ordering.
check_version() {
local current_title="$1" current_version older
if [[ "$current_title" != "$TITLE_PREFIX"* ]]; then
echo "::error::Cannot determine the destination PR's package version." >&2
exit 1
fi
current_version="${current_title#"$TITLE_PREFIX"}"
older=$(uv run --no-project --with packaging==25.0 python - "$PACKAGE_VERSION" "$current_version" <<'PY'
import sys
from packaging.version import Version

print("true" if Version(sys.argv[1]) < Version(sys.argv[2]) else "false")
PY
) || {
echo "::error::Cannot compare package versions; leaving remote branches unchanged." >&2
exit 1
}
if [ "$older" = "true" ]; then
skip_update "Version $PACKAGE_VERSION is older than PR version $current_version; leaving it unchanged."
fi
}

queue_status() {
local pr="$1" status
if status=$(gh api "repos/PostHog/posthog/issues/${pr}/comments?per_page=100" \
--paginate --slurp \
--jq '[.[][] | select(.user.login == "trunk-io[bot]") | .body] | last // ""'); then
printf '%s' "$status" \
| grep -oE "Running tests on this pull request|waiting to start tests" | head -n 1 || true
else
echo "::warning::Could not read the queue status of PR #${pr}; treating it as queued." >&2
echo "unknown"
fi
}

EXISTING=$(gh pr list \
--repo PostHog/posthog \
--state open \
--search "\"${TITLE_PREFIX}\" in:title sort:created-desc" \
--limit 100 \
--json title,number,headRefName,headRepositoryOwner \
--jq ".[] | select(.title | startswith(\"${TITLE_PREFIX}\")) | select(.headRepositoryOwner.login == \"PostHog\") | [.number, .headRefName, .title] | @tsv" | head -n 1)
IFS=$'\t' read -r EXISTING_PR EXISTING_BRANCH EXISTING_TITLE <<< "$EXISTING"

# Updating a queued PR's branch restarts the merge queue's checks.
# Leave queued PRs alone, including when their status cannot be read.
QUEUED=""
if [ -n "$EXISTING_PR" ]; then
check_version "$EXISTING_TITLE"
QUEUED=$(queue_status "$EXISTING_PR")
fi

if [ -n "$EXISTING_BRANCH" ] && [ -z "$QUEUED" ]; then
BRANCH_NAME="$EXISTING_BRANCH"
elif [ -n "$QUEUED" ]; then
echo "::notice::PR #${EXISTING_PR} is in the merge queue; checking a separate branch instead."
BRANCH_NAME="${PACKAGE_NAME}-upgrade-${PACKAGE_VERSION}"
# A rerun can collide with an already queued version-specific PR.
if ! DESTINATIONS=$(gh pr list \
--repo PostHog/posthog \
--state open \
--head "$BRANCH_NAME" \
--limit 100 \
--json title,number,headRefName,isCrossRepository \
--jq '.[] | select(.isCrossRepository == false) | [.number, .headRefName, .title] | @tsv'); then
skip_update "Could not check the fallback branch for an existing PR; leaving it unchanged."
fi
while IFS=$'\t' read -r DESTINATION_PR DESTINATION_BRANCH DESTINATION_TITLE; do
[ -n "$DESTINATION_PR" ] || continue
[ "$DESTINATION_BRANCH" = "$BRANCH_NAME" ] || continue
check_version "$DESTINATION_TITLE"
if [ -n "$(queue_status "$DESTINATION_PR")" ]; then
skip_update "Fallback PR #${DESTINATION_PR} is queued or its status is unknown; leaving it unchanged."
fi
done <<< "$DESTINATIONS"
else
BRANCH_NAME="${PACKAGE_NAME}-upgrade"
fi

echo "skip=false" >> "$GITHUB_OUTPUT"
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
echo "title=${TITLE_PREFIX}${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
{
echo 'body<<EOF'
echo '## Changes'
echo
echo "PostHog Python SDK version $PACKAGE_VERSION has been released. This updates the \`posthoganalytics\` and \`posthog\` dependency declarations to the same SDK version."
echo
echo '[GitHub releases](https://github.com/PostHog/posthog-python/releases) • [`posthoganalytics` on PyPI](https://pypi.org/project/posthoganalytics/#history) • [`posthog` on PyPI](https://pypi.org/project/posthog/#history)'
echo 'EOF'
} >> "$GITHUB_OUTPUT"

- name: Create main repo pull request
if: steps.generate-pr-details.outputs.skip == 'false'
id: main-repo-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
env:
Expand All @@ -99,18 +204,14 @@ jobs:
token: ${{ steps.upgrader.outputs.token }}
# PostHog/posthog requires signed commits, so commit through the GitHub API.
sign-commits: true
commit-message: "chore(deps): update ${{ github.event.inputs.package_name }} to ${{ github.event.inputs.package_version }}"
branch: ${{ steps.generate-branch-name.outputs.branch_name }}
commit-message: ${{ steps.generate-pr-details.outputs.title }}
branch: ${{ steps.generate-pr-details.outputs.branch_name }}
delete-branch: true
title: "chore(deps): update ${{ github.event.inputs.package_name }} to ${{ github.event.inputs.package_version }}"
body: |
## Changes

PostHog Python SDK version ${{ github.event.inputs.package_version }} has been released. This updates the `posthoganalytics` and `posthog` dependency declarations to the same SDK version.

[GitHub releases](https://github.com/PostHog/posthog-python/releases) • [`posthoganalytics` on PyPI](https://pypi.org/project/posthoganalytics/#history) • [`posthog` on PyPI](https://pypi.org/project/posthog/#history)
title: ${{ steps.generate-pr-details.outputs.title }}
body: ${{ steps.generate-pr-details.outputs.body }}

- name: Output pull request result
if: steps.generate-pr-details.outputs.skip == 'false'
shell: bash
env:
PACKAGE_NAME: ${{ github.event.inputs.package_name }}
Expand All @@ -119,7 +220,21 @@ jobs:
run: |
echo "PostHog pull request for $PACKAGE_NAME version $PACKAGE_VERSION ready: $PR_URL"

- name: Update pull request metadata
if: steps.generate-pr-details.outputs.skip == 'false'
env:
GH_TOKEN: ${{ steps.upgrader.outputs.token }}
PR_BODY: ${{ steps.generate-pr-details.outputs.body }}
PR_NUMBER: ${{ steps.main-repo-pr.outputs.pull-request-number }}
PR_TITLE: ${{ steps.generate-pr-details.outputs.title }}
run: |
gh pr edit "$PR_NUMBER" \
--repo PostHog/posthog \
--title "$PR_TITLE" \
--body "$PR_BODY"

- name: Assign reviewers
if: steps.generate-pr-details.outputs.skip == 'false'
env:
GH_TOKEN: ${{ steps.upgrader.outputs.token }}
PR_NUMBER: ${{ steps.main-repo-pr.outputs.pull-request-number }}
Expand Down
95 changes: 74 additions & 21 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: SDK Compliance Tests

permissions:
contents: read
packages: read
pull-requests: write

on:
pull_request:
Expand All @@ -23,26 +21,81 @@ jobs:
with:
python-version: "3.12"
- name: Install adapter and test dependencies
run: python -m pip install -e . -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio
run: python -m pip install -e ".[zstd]" -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio
- name: Test adapter protocol
run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30

compliance:
name: PostHog SDK compliance tests (capture v0)
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "1.1.1"
continue-on-error: false
report-name: "sdk-compliance-report-v0"

compliance-v1:
name: PostHog SDK compliance tests (capture v1)
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1"
adapter-context: "."
test-harness-version: "1.1.1"
continue-on-error: false
report-name: "sdk-compliance-report-v1"
# The harness has one enable_compression flag but a test per codec, so each
# job runs the adapter with one codec. The reusable harness workflow cannot
# pass the COMPRESSION env var, so this job runs the containers directly.
# The SDK has no brotli support, so there is no br job.
name: PostHog SDK compliance tests (${{ matrix.compression }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
compression: [gzip, deflate, zstd]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.13.1
steps:
- name: Check out source code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Pull test harness
run: docker pull "$HARNESS_IMAGE"

- name: Build SDK adapter
run: docker build -f sdk_compliance_adapter/Dockerfile -t sdk-adapter:test .

- name: Start SDK adapter
run: |
docker network create test-network
docker run -d \
--name sdk-adapter \
--network test-network \
-e COMPRESSION=${{ matrix.compression }} \
sdk-adapter:test

- name: Run compliance suite
run: |
mkdir -p report
docker run --rm \
--name test-harness \
--network test-network \
-v "${GITHUB_WORKSPACE}/report:/report" \
"$HARNESS_IMAGE" \
run \
--adapter-url http://sdk-adapter:8080 \
--mock-url http://test-harness:8081 \
--output text \
--report /report/sdk-compliance-report.md \
--sdk-type server

- name: Publish report to the job summary
if: always()
run: |
if [ -f report/sdk-compliance-report.md ]; then
cat report/sdk-compliance-report.md >> "$GITHUB_STEP_SUMMARY"
fi

- name: Upload compliance report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sdk-compliance-report-${{ matrix.compression }}
path: report/sdk-compliance-report.md

- name: Show adapter logs on failure
if: failure()
run: docker logs sdk-adapter || true

- name: Stop containers
if: always()
run: |
docker rm -f sdk-adapter || true
docker network rm test-network || true
5 changes: 5 additions & 0 deletions .sampo/changesets/ai-integrations-require-capture-ai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
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.
5 changes: 5 additions & 0 deletions .sampo/changesets/call-disable-geoip-event-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

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

Capture v1 is the only capture path. Events and AI events send to the capture v1 endpoints, and the legacy v0 capture path is removed. See the [migration guide](https://github.com/PostHog/posthog-python/blob/main/docs/migration-7.x-to-8.0.md) for breaking changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

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

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

MCP instrumentation no longer relabels the client as `posthog-python-mcp`. `posthog.mcp.instrument()` and `PostHogMCP` used to change `$lib`, the `PostHog-Sdk-Info` header and the feature flag request `User-Agent` for every event the client sent, including the app's own events. All events now report `posthog-python`. Filter MCP traffic on the `$mcp_*` events and properties.
5 changes: 5 additions & 0 deletions .sampo/changesets/openfeature-provider-posthog-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/openfeature-provider-posthog: minor
---

Supports posthog 8.x as well as 7.x.
5 changes: 5 additions & 0 deletions .sampo/changesets/request-keyword-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

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

A `$session_id` or `$window_id` that is not a string is not sent, because capture v1 would reject the whole batch, and the SDK logs a warning for each one. The warning names the key and the value's type, not the value. `None` counts as unset and drops without a warning. An empty string is sent. In 7.x these values were sent as properties.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Follow [Public API changes](./CONTRIBUTING.md#public-api-changes). As an agent,

Before changing capture configuration, serialization, routing, or retries, read the relevant implementation and tests.

Preserve v0 defaults/compatibility; strictly typed v1 options and `$set`/`$set_once` relocation; v1-only compression (zlib-wrapped deflate, optional zstd); partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.
Capture v1 is the only capture protocol (`capture` posts to `/i/v1/analytics/events`, `capture_ai` to `/i/v1/ai/events`); per-event `options` sent as given, layered over `super_options` and context options, with legacy `$` option properties hoisted once after `before_send`; `$set`/`$set_once` relocation; compression (gzip, zlib-wrapped deflate, optional zstd, default none), set per lane by `capture_compression` and `capture_ai_compression`; partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.

## Mirror and build safety

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uv sync --extra dev --extra test

## CI-aligned checks

Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_v1.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:
Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_send.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:

```bash
ruff format --check .
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ SDK usage examples and code snippets live in the official documentation so they

| SDK Version | Python Versions Supported | Notes |
| ------------- | ---------------------------- | -------------------------- |
| 7.3.1+ | 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, 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 |
| 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 |

## 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)
- [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

## Contributing
Expand Down
Loading