diff --git a/.github/workflows/posthog-upgrade.yml b/.github/workflows/posthog-upgrade.yml index 576f83920..19a04aa24 100644 --- a/.github/workflows/posthog-upgrade.yml +++ b/.github/workflows/posthog-upgrade.yml @@ -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 @@ -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<> "$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: @@ -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 }} @@ -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 }} diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index 3891e105c..b77681cad 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -2,8 +2,6 @@ name: SDK Compliance Tests permissions: contents: read - packages: read - pull-requests: write on: pull_request: @@ -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 diff --git a/.sampo/changesets/ai-integrations-require-capture-ai.md b/.sampo/changesets/ai-integrations-require-capture-ai.md new file mode 100644 index 000000000..d34b6494b --- /dev/null +++ b/.sampo/changesets/ai-integrations-require-capture-ai.md @@ -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. diff --git a/.sampo/changesets/call-disable-geoip-event-value.md b/.sampo/changesets/call-disable-geoip-event-value.md new file mode 100644 index 000000000..ae143d617 --- /dev/null +++ b/.sampo/changesets/call-disable-geoip-event-value.md @@ -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. diff --git a/.sampo/changesets/capture-retry-jitter.md b/.sampo/changesets/capture-retry-jitter.md new file mode 100644 index 000000000..08e170ddb --- /dev/null +++ b/.sampo/changesets/capture-retry-jitter.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Capture retry waits now vary randomly by up to 25%, so many clients do not retry at the same moment. diff --git a/.sampo/changesets/capture-same-origin-redirects.md b/.sampo/changesets/capture-same-origin-redirects.md new file mode 100644 index 000000000..5428fed89 --- /dev/null +++ b/.sampo/changesets/capture-same-origin-redirects.md @@ -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. diff --git a/.sampo/changesets/capture-v1-major.md b/.sampo/changesets/capture-v1-major.md new file mode 100644 index 000000000..b34e2a802 --- /dev/null +++ b/.sampo/changesets/capture-v1-major.md @@ -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. diff --git a/.sampo/changesets/exception-level-and-reserved-properties.md b/.sampo/changesets/exception-level-and-reserved-properties.md new file mode 100644 index 000000000..f047fe3d1 --- /dev/null +++ b/.sampo/changesets/exception-level-and-reserved-properties.md @@ -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`. diff --git a/.sampo/changesets/identity-and-context-rules.md b/.sampo/changesets/identity-and-context-rules.md new file mode 100644 index 000000000..affd35e8f --- /dev/null +++ b/.sampo/changesets/identity-and-context-rules.md @@ -0,0 +1,5 @@ +--- +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`. 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/invalid-events-and-sizes.md b/.sampo/changesets/invalid-events-and-sizes.md new file mode 100644 index 000000000..e7b310d8e --- /dev/null +++ b/.sampo/changesets/invalid-events-and-sizes.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: major +--- + +An invalid event no longer costs the rest of its batch. An event with a missing, empty or non-string name, or with a non-string `distinct_id` after `before_send`, is dropped locally, an event that cannot be serialized (for example with `NaN`) is dropped alone, and a property key JSON cannot represent is dropped from its event. The analytics per-event limit is now capture's 10 MiB request limit instead of 900 KiB. A personal API key (`phx_...`) disables the client like an empty key. The SDK's own log lines no longer include event content or invalid timestamp values. `AsyncPosthog` follows the same rules. diff --git a/.sampo/changesets/invalid-uuid-warning.md b/.sampo/changesets/invalid-uuid-warning.md new file mode 100644 index 000000000..d89614ea8 --- /dev/null +++ b/.sampo/changesets/invalid-uuid-warning.md @@ -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. diff --git a/.sampo/changesets/mcp-keeps-sdk-identity.md b/.sampo/changesets/mcp-keeps-sdk-identity.md new file mode 100644 index 000000000..731c7faab --- /dev/null +++ b/.sampo/changesets/mcp-keeps-sdk-identity.md @@ -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. diff --git a/.sampo/changesets/openfeature-provider-posthog-8.md b/.sampo/changesets/openfeature-provider-posthog-8.md new file mode 100644 index 000000000..02e3f2e00 --- /dev/null +++ b/.sampo/changesets/openfeature-provider-posthog-8.md @@ -0,0 +1,5 @@ +--- +pypi/openfeature-provider-posthog: minor +--- + +Supports posthog 8.x as well as 7.x. diff --git a/.sampo/changesets/request-keyword-only.md b/.sampo/changesets/request-keyword-only.md new file mode 100644 index 000000000..4337cd2b8 --- /dev/null +++ b/.sampo/changesets/request-keyword-only.md @@ -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`. diff --git a/.sampo/changesets/session-id-drop-warning.md b/.sampo/changesets/session-id-drop-warning.md new file mode 100644 index 000000000..aad2adb43 --- /dev/null +++ b/.sampo/changesets/session-id-drop-warning.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 745e91ad7..0e40e181c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6625fec0c..bb011dfb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 . diff --git a/README.md b/README.md index 364101f01..bcb872c44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md new file mode 100644 index 000000000..2592007c9 --- /dev/null +++ b/docs/migration-7.x-to-8.0.md @@ -0,0 +1,232 @@ +# Migrating from posthog 7.x to 8.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. + +## 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 `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 +- sets `$process_person_profile` to turn person processing on for events without a distinct ID +- passes strings such as `"true"` for `$cookieless_mode`, `$ignore_sent_at` or `$process_person_profile` +- 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 +- 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`, or filters 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 + +## Endpoints + +| Method | Endpoint | +| --- | --- | +| `capture`, `set`, `set_once`, `alias`, `group_identify`, `capture_exception` | `/i/v1/analytics/events` | +| `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 endpoints before you upgrade. +An endpoint that is not served drops every event sent to it. + +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. + +## SDK identity + +PostHog sets `$lib` and `$lib_version` on every event from the `PostHog-Sdk-Info` request header, which is always `posthog-python/`. +The SDK removes `$lib` and `$lib_version` from the properties it sends. +A value you set in a call, in `super_properties` or in `before_send` does not reach PostHog. + +MCP instrumentation no longer relabels the client. +In 7.x, `posthog.mcp.instrument()` and `PostHogMCP` set the client's identity to `posthog-python-mcp`. +With the default client, or any client the app also used, every event and feature flag request from the app then reported `posthog-python-mcp`, not only the MCP events. +In 8.0, MCP events report `posthog-python` like all other events. +To find MCP traffic, filter on the `$mcp_*` events and properties instead of `$lib`. + +## Removed 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. | +| `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`. | +| 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`. + +Constructor arguments after `host` are keyword-only: + +```python +# 7.x +Posthog("", "https://us.i.posthog.com", True) + +# 8.0 +Posthog("", host="https://us.i.posthog.com", debug=True) +``` + +## Errors and `on_error` + +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. +- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. Update any log-based alerts that match them. +- 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. +- If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again. +- The SDK retries `408`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including `429` and the other 5xx codes. 7.x also retried `429` and every 5xx. Capture itself does not return `429`; a proxy in front of it can. +- 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. +- 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. An exception your `before_send` hook raises is still logged with its message. 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. +- An event that cannot be serialized to JSON is dropped and logged, for example one with `NaN` or `Infinity` in its properties. 7.x sent `NaN` as invalid JSON. +- 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 drops the event, as a `NaN` value does. + +## Event uuids + +- Each event needs its own uuid. Capture rejects a whole batch that contains the same uuid twice, so the other events in that batch are lost too. +- The SDK accepts a uuid with hyphens, 32 hex digits, `{...}` braces or a `urn:uuid:` prefix, in any case. It sends the lowercase hyphenated form and returns that form from `capture`. +- Any other value is replaced with a generated uuid, and the SDK logs 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`. + +## Exceptions + +- `capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module. It sets `$exception_level`, for example `"warning"` or `"fatal"`. The default is `"error"`, and an unknown value counts as unset. +- `capture_exception` ignores reserved exception properties in `properties`, such as `$exception_list`, `$exception_level` and `$exception_source`. In 7.x they overrode the SDK's values, with a `DeprecationWarning`. Use `level=` instead of `$exception_level`. +- `AsyncPosthog.capture_exception` now sends `$exception_level`. + +## Event options + +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}) + ``` + +- `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. +- 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. +- An `options` value that is not a dict is logged as an error and ignored. The event is still sent. +- The legacy option properties, the `$`-prefixed properties legacy capture reads as settings, still work. They fill the matching option only when it is unset, and the SDK always removes them from `properties`. Their values are no longer converted, so pass `True` or `False`, not `"true"`. + + | Legacy option property | Option | + | --- | --- | + | `$cookieless_mode` | `cookieless_mode` | + | `$ignore_sent_at` | `disable_skew_correction` | + | `$process_person_profile` | `process_person_profile` | + +- An option set at any layer wins over its legacy property set at any layer. For example, `super_options={"cookieless_mode": True}` wins over an event's `$cookieless_mode: False`. When you move a default to options, move the per-event overrides of that key to options too. +- A `None` option counts as unset, so a later step can fill it. + +Values apply in this order. Steps 2 to 4 fill only the options and properties that the steps before them left unset: + +1. the `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable` +2. context options and tags +3. `super_options` and `super_properties` +4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID` +5. `before_send`, which sees the result of steps 1 to 4 and can change or remove any of it +6. legacy properties, which fill unset options and are then removed + +A property set to `None` counts as set, so no later step fills it. +`$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`. `super_properties` can no longer change `$lib` or `$lib_version`. +- A `$is_server`, `$geoip_disable` or system property such as `$os` that you set in a call or a context tag now wins over the SDK's value. In 7.x the SDK overwrote it. `super_properties` win over the SDK's value too, so `super_properties={"$geoip_disable": False}` turns GeoIP lookup on for events, even with `disable_geoip=True`. +- A `disable_geoip` argument to a call belongs to that event, so it wins over context tags and `super_properties`. A `$geoip_disable` in the call's own `properties` still wins over it. `disable_geoip=False` now sends `$geoip_disable: false`. In 7.x it sent no `$geoip_disable` property. +- The `groups` argument merges into a `$groups` property of the call, and wins key by key. In 7.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way. +- A `$set`, `$set_once`, `$groups` or `$group_set` in `super_properties` no longer replaces the whole value of the call. The two merge, and the call wins key by key. +- An event without a distinct ID gets `options.process_person_profile = false`. A `$process_person_profile: true` property no longer turns person processing back on. Set the option instead. +- To change an option in `before_send`, edit `options`. A legacy property that `before_send` adds does not replace an option that is already set. +- When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id` and the legacy option properties, so their options still apply. + +## AI capture + +- `capture_ai` and the AI wrapper integrations send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. +- 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 pass. It wins over `super_options`, context options and a `$process_person_profile: true` property. `before_send` can still change it. +- The AI integrations have no `options` argument. To set options on their events, use `set_context_option`, `super_options`, or a legacy property such as `$cookieless_mode` in `posthog_properties`. +- MCP's `PostHogCaptureEvent` has an `options` key. +- 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. + +## Batching and size limits + +- 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. + +## OpenFeature provider + +`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. diff --git a/examples/example-ai-crewai/pyproject.toml b/examples/example-ai-crewai/pyproject.toml index 0c6cb57f8..9e655dc11 100644 --- a/examples/example-ai-crewai/pyproject.toml +++ b/examples/example-ai-crewai/pyproject.toml @@ -3,7 +3,10 @@ name = "example-ai-crewai" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "posthog==7.9.12", + "posthog", "crewai>=0.80.0", "litellm>=1.88.6", ] + +[tool.uv.sources] +posthog = { path = "../.." } diff --git a/examples/example-ai-crewai/uv.lock b/examples/example-ai-crewai/uv.lock index 7485aa704..2ab5e826d 100644 --- a/examples/example-ai-crewai/uv.lock +++ b/examples/example-ai-crewai/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-10-03T03:16:06.117367Z" exclude-newer-span = "P7D" [[package]] @@ -260,15 +260,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/83/65ecd7449fbdc5b91de15e6a9fcb4766076da8c4b117d3eb1194582e4de4/auth0_python-5.3.0-py3-none-any.whl", hash = "sha256:251fb2f524f7bc4f88b69e48b0444d05f71769aef4f61076f2ba3573ca69dae6", size = 2038474, upload-time = "2026-04-09T13:27:03.808Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "bcrypt" version = "5.0.0" @@ -794,7 +785,7 @@ dependencies = [ requires-dist = [ { name = "crewai", specifier = ">=0.80.0" }, { name = "litellm", specifier = ">=1.88.6" }, - { name = "posthog", specifier = "==7.9.12" }, + { name = "posthog", directory = "../../" }, ] [[package]] @@ -802,7 +793,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1306,17 +1297,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1331,17 +1322,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } wheels = [ @@ -1358,16 +1349,16 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } wheels = [ @@ -1379,7 +1370,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2620,20 +2611,75 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.12" -source = { registry = "https://pypi.org/simple" } +version = "7.64.1" +source = { directory = "../../" } dependencies = [ - { name = "backoff" }, { name = "distro" }, - { name = "python-dateutil" }, { name = "requests" }, - { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, + { name = "claude-agent-sdk", marker = "extra == 'test'" }, + { name = "coverage", marker = "extra == 'test'" }, + { name = "distro", specifier = ">=1.5.0" }, + { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, + { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, + { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, + { name = "google-genai", marker = "extra == 'test'" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.27.0,<1.0" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.9" }, + { name = "langchain", marker = "extra == 'test'", specifier = ">=1.3.9" }, + { name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" }, + { name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.1.14" }, + { name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langgraph-checkpoint", marker = "extra == 'test'", specifier = ">=4.1.1" }, + { name = "lxml", marker = "extra == 'dev'" }, + { name = "mcp", marker = "extra == 'test'", specifier = ">=1.28.1,<2" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "mypy-baseline", marker = "extra == 'dev'" }, + { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "packaging", marker = "extra == 'dev'" }, + { name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2.12.0" }, + { name = "pydantic", marker = "extra == 'test'", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-bdd", marker = "extra == 'test'", specifier = ">=8.1.0" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-dateutil", marker = "extra == 'test'", specifier = ">=2.9.0.post0" }, + { name = "requests", specifier = ">=2.7,<3.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "setuptools", marker = "extra == 'dev'" }, + { name = "tiktoken", marker = "extra == 'test'", specifier = ">=0.12.0" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tomli-w", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "tzdata", marker = "extra == 'test'" }, + { name = "wheel", marker = "extra == 'dev'" }, + { name = "zstandard", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "zstandard", marker = "extra == 'zstd'", specifier = ">=0.23.0" }, +] +provides-extras = ["langchain", "async", "zstd", "otel", "dev", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] [[package]] name = "prompt-toolkit" @@ -3216,11 +3262,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] diff --git a/examples/example-ai-dspy/pyproject.toml b/examples/example-ai-dspy/pyproject.toml index a78ea7b6d..48e96ffad 100644 --- a/examples/example-ai-dspy/pyproject.toml +++ b/examples/example-ai-dspy/pyproject.toml @@ -3,7 +3,10 @@ name = "example-ai-dspy" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "posthog==7.9.12", + "posthog", "dspy>=2.5.0", "litellm==1.88.6", ] + +[tool.uv.sources] +posthog = { path = "../.." } diff --git a/examples/example-ai-dspy/uv.lock b/examples/example-ai-dspy/uv.lock index 8b14811a9..a5c3d042c 100644 --- a/examples/example-ai-dspy/uv.lock +++ b/examples/example-ai-dspy/uv.lock @@ -7,7 +7,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-10-03T03:16:08.499203Z" exclude-newer-span = "P7D" [[package]] @@ -246,15 +246,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "cachetools" version = "7.0.5" @@ -482,7 +473,7 @@ dependencies = [ requires-dist = [ { name = "dspy", specifier = ">=2.5.0" }, { name = "litellm", specifier = "==1.88.6" }, - { name = "posthog", specifier = "==7.9.12" }, + { name = "posthog", directory = "../../" }, ] [[package]] @@ -490,7 +481,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1616,20 +1607,75 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.12" -source = { registry = "https://pypi.org/simple" } +version = "7.64.1" +source = { directory = "../../" } dependencies = [ - { name = "backoff" }, { name = "distro" }, - { name = "python-dateutil" }, { name = "requests" }, - { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, + { name = "claude-agent-sdk", marker = "extra == 'test'" }, + { name = "coverage", marker = "extra == 'test'" }, + { name = "distro", specifier = ">=1.5.0" }, + { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, + { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, + { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, + { name = "google-genai", marker = "extra == 'test'" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.27.0,<1.0" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.9" }, + { name = "langchain", marker = "extra == 'test'", specifier = ">=1.3.9" }, + { name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" }, + { name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.1.14" }, + { name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langgraph-checkpoint", marker = "extra == 'test'", specifier = ">=4.1.1" }, + { name = "lxml", marker = "extra == 'dev'" }, + { name = "mcp", marker = "extra == 'test'", specifier = ">=1.28.1,<2" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "mypy-baseline", marker = "extra == 'dev'" }, + { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "packaging", marker = "extra == 'dev'" }, + { name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2.12.0" }, + { name = "pydantic", marker = "extra == 'test'", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-bdd", marker = "extra == 'test'", specifier = ">=8.1.0" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-dateutil", marker = "extra == 'test'", specifier = ">=2.9.0.post0" }, + { name = "requests", specifier = ">=2.7,<3.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "setuptools", marker = "extra == 'dev'" }, + { name = "tiktoken", marker = "extra == 'test'", specifier = ">=0.12.0" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tomli-w", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "tzdata", marker = "extra == 'test'" }, + { name = "wheel", marker = "extra == 'dev'" }, + { name = "zstandard", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "zstandard", marker = "extra == 'zstd'", specifier = ">=0.23.0" }, +] +provides-extras = ["langchain", "async", "zstd", "otel", "dev", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] [[package]] name = "propcache" @@ -1887,25 +1933,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] @@ -2266,15 +2300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" diff --git a/examples/example-ai-litellm/pyproject.toml b/examples/example-ai-litellm/pyproject.toml index 0691dbfb5..d3e0b35da 100644 --- a/examples/example-ai-litellm/pyproject.toml +++ b/examples/example-ai-litellm/pyproject.toml @@ -3,6 +3,9 @@ name = "example-ai-litellm" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "posthog==7.9.12", + "posthog", "litellm==1.88.6", ] + +[tool.uv.sources] +posthog = { path = "../.." } diff --git a/examples/example-ai-litellm/uv.lock b/examples/example-ai-litellm/uv.lock index a17bcd98f..5c9ae49f0 100644 --- a/examples/example-ai-litellm/uv.lock +++ b/examples/example-ai-litellm/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.10" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-10-03T03:16:09.116916Z" exclude-newer-span = "P7D" [[package]] @@ -215,15 +215,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -380,7 +371,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "litellm", specifier = "==1.88.6" }, - { name = "posthog", specifier = "==7.9.12" }, + { name = "posthog", directory = "../../" }, ] [[package]] @@ -388,7 +379,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1183,20 +1174,75 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.12" -source = { registry = "https://pypi.org/simple" } +version = "7.64.1" +source = { directory = "../../" } dependencies = [ - { name = "backoff" }, { name = "distro" }, - { name = "python-dateutil" }, { name = "requests" }, - { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, + { name = "claude-agent-sdk", marker = "extra == 'test'" }, + { name = "coverage", marker = "extra == 'test'" }, + { name = "distro", specifier = ">=1.5.0" }, + { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, + { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, + { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, + { name = "google-genai", marker = "extra == 'test'" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.27.0,<1.0" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.9" }, + { name = "langchain", marker = "extra == 'test'", specifier = ">=1.3.9" }, + { name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" }, + { name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.1.14" }, + { name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langgraph-checkpoint", marker = "extra == 'test'", specifier = ">=4.1.1" }, + { name = "lxml", marker = "extra == 'dev'" }, + { name = "mcp", marker = "extra == 'test'", specifier = ">=1.28.1,<2" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "mypy-baseline", marker = "extra == 'dev'" }, + { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "packaging", marker = "extra == 'dev'" }, + { name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2.12.0" }, + { name = "pydantic", marker = "extra == 'test'", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-bdd", marker = "extra == 'test'", specifier = ">=8.1.0" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-dateutil", marker = "extra == 'test'", specifier = ">=2.9.0.post0" }, + { name = "requests", specifier = ">=2.7,<3.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "setuptools", marker = "extra == 'dev'" }, + { name = "tiktoken", marker = "extra == 'test'", specifier = ">=0.12.0" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tomli-w", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "tzdata", marker = "extra == 'test'" }, + { name = "wheel", marker = "extra == 'dev'" }, + { name = "zstandard", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "zstandard", marker = "extra == 'zstd'", specifier = ">=0.23.0" }, +] +provides-extras = ["langchain", "async", "zstd", "otel", "dev", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] [[package]] name = "propcache" @@ -1454,18 +1500,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.3" @@ -1833,15 +1867,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" diff --git a/examples/example-ai-llamaindex/uv.lock b/examples/example-ai-llamaindex/uv.lock index d9f12ab82..0068648e4 100644 --- a/examples/example-ai-llamaindex/uv.lock +++ b/examples/example-ai-llamaindex/uv.lock @@ -230,7 +230,7 @@ wheels = [ [[package]] name = "banks" -version = "2.4.5" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -239,10 +239,11 @@ dependencies = [ { name = "jinja2" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/b5/4784ee9518b97f9f69c714a4303f9a6186a7e4ff2349f89e24767e9754d9/banks-2.4.5.tar.gz", hash = "sha256:ff575732fc67d5493a73c21e0d7268bc49e86fff02b0b8735e8efb9fcb9af3a4", size = 190822, upload-time = "2026-07-07T08:14:12.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/92/0449c32e718c10e4af40a3e040c830a77cdaeec6e9066622ee60e4c93e6e/banks-2.5.1.tar.gz", hash = "sha256:a7ca5c250b605ae90cfd6e4ca5dd6ce2ee377d5d59021b6feba44e345910026d", size = 196398, upload-time = "2026-09-10T10:15:34.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b0/4d34cb2fe538aeb6d4b0723b3339cb932120db084a5c9a3c6966a26bbe1b/banks-2.4.5-py3-none-any.whl", hash = "sha256:ac2e0091b4c79379d4773c9d04a138a0d937ee27c5803bf0142acc6d6769eea1", size = 36145, upload-time = "2026-07-07T08:14:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6f/06c0824ee2611de47a0fc731fa57f6042d3f573025d9f504adfd65e50d51/banks-2.5.1-py3-none-any.whl", hash = "sha256:b7c91365626bab4b46197d24ce04cd69540730ed669a50a399f240318ba3dcfb", size = 39462, upload-time = "2026-09-10T10:15:35.606Z" }, ] [[package]] @@ -1762,7 +1763,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.63.0" +version = "7.66.0" source = { directory = "../../" } dependencies = [ { name = "backoff" }, @@ -1786,7 +1787,9 @@ requires-dist = [ { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, diff --git a/examples/example-ai-openai-agents/pyproject.toml b/examples/example-ai-openai-agents/pyproject.toml index 647400643..06ad91cd7 100644 --- a/examples/example-ai-openai-agents/pyproject.toml +++ b/examples/example-ai-openai-agents/pyproject.toml @@ -3,6 +3,9 @@ name = "example-ai-openai-agents" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "posthog==7.9.12", + "posthog", "openai-agents==0.13.1", ] + +[tool.uv.sources] +posthog = { path = "../.." } diff --git a/examples/example-ai-openai-agents/uv.lock b/examples/example-ai-openai-agents/uv.lock index 63ad72f88..0a084773f 100644 --- a/examples/example-ai-openai-agents/uv.lock +++ b/examples/example-ai-openai-agents/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-10-03T03:16:09.242998Z" exclude-newer-span = "P7D" [[package]] @@ -44,15 +44,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -376,7 +367,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "openai-agents", specifier = "==0.13.1" }, - { name = "posthog", specifier = "==7.9.12" }, + { name = "posthog", directory = "../../" }, ] [[package]] @@ -384,7 +375,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -646,20 +637,75 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.12" -source = { registry = "https://pypi.org/simple" } +version = "7.64.1" +source = { directory = "../../" } dependencies = [ - { name = "backoff" }, { name = "distro" }, - { name = "python-dateutil" }, { name = "requests" }, - { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, + { name = "claude-agent-sdk", marker = "extra == 'test'" }, + { name = "coverage", marker = "extra == 'test'" }, + { name = "distro", specifier = ">=1.5.0" }, + { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, + { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, + { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, + { name = "google-genai", marker = "extra == 'test'" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.27.0,<1.0" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.9" }, + { name = "langchain", marker = "extra == 'test'", specifier = ">=1.3.9" }, + { name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" }, + { name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.1.14" }, + { name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "langgraph-checkpoint", marker = "extra == 'test'", specifier = ">=4.1.1" }, + { name = "lxml", marker = "extra == 'dev'" }, + { name = "mcp", marker = "extra == 'test'", specifier = ">=1.28.1,<2" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "mypy-baseline", marker = "extra == 'dev'" }, + { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "packaging", marker = "extra == 'dev'" }, + { name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2.12.0" }, + { name = "pydantic", marker = "extra == 'test'", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-bdd", marker = "extra == 'test'", specifier = ">=8.1.0" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-dateutil", marker = "extra == 'test'", specifier = ">=2.9.0.post0" }, + { name = "requests", specifier = ">=2.7,<3.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "setuptools", marker = "extra == 'dev'" }, + { name = "tiktoken", marker = "extra == 'test'", specifier = ">=0.12.0" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tomli-w", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "tzdata", marker = "extra == 'test'" }, + { name = "wheel", marker = "extra == 'dev'" }, + { name = "zstandard", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "zstandard", marker = "extra == 'zstd'", specifier = ">=0.23.0" }, +] +provides-extras = ["langchain", "async", "zstd", "otel", "dev", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] [[package]] name = "pycparser" @@ -834,18 +880,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.3" @@ -1037,15 +1071,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" diff --git a/examples/example-ai-pydantic-ai/pyproject.toml b/examples/example-ai-pydantic-ai/pyproject.toml index e2ea7b221..e700a26bc 100644 --- a/examples/example-ai-pydantic-ai/pyproject.toml +++ b/examples/example-ai-pydantic-ai/pyproject.toml @@ -3,7 +3,7 @@ name = "example-ai-pydantic-ai" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "pydantic-ai==1.102.0", + "pydantic-ai==1.107.7", "opentelemetry-sdk==1.39.1", "posthog[otel]>=7.11.0", ] diff --git a/examples/example-ai-pydantic-ai/uv.lock b/examples/example-ai-pydantic-ai/uv.lock index 7e0af6691..90f37651d 100644 --- a/examples/example-ai-pydantic-ai/uv.lock +++ b/examples/example-ai-pydantic-ai/uv.lock @@ -5,7 +5,8 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [options] @@ -761,7 +762,7 @@ dependencies = [ requires-dist = [ { name = "opentelemetry-sdk", specifier = "==1.39.1" }, { name = "posthog", extras = ["otel"], directory = "../../" }, - { name = "pydantic-ai", specifier = "==1.102.0" }, + { name = "pydantic-ai", specifier = "==1.107.7" }, ] [[package]] @@ -1036,15 +1037,15 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.0.56" +version = "0.0.73" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/6b/94b3018a672c7775edfb485f0fed8f6068fba75e49b067e8a1ac5eb96764/genai_prices-0.0.56.tar.gz", hash = "sha256:ac24b16a84d0ab97539bfa48dfa4649689de8e3ce71c12ebacef29efb1998045", size = 65872, upload-time = "2026-03-20T20:33:00.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0b/4b430d9c6ff0c76e42b91fc4cedff84dfbefa1a35b885fad45a22d29303f/genai_prices-0.0.73.tar.gz", hash = "sha256:ddad5b23dadd7aac8a58ab5af7506addb062114037e96427324703e50bc35077", size = 88406, upload-time = "2026-07-29T12:48:58.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, + { url = "https://files.pythonhosted.org/packages/a8/85/527a729ecd58b7169430e47febdab2e3f91193d0e3f5a56d721c04525709/genai_prices-0.0.73-py3-none-any.whl", hash = "sha256:64dfbbf55df91db6b74ff2b24ac752b8154952e0aed4e62868d6cd42710bf742", size = 92507, upload-time = "2026-07-29T12:48:57.194Z" }, ] [[package]] @@ -1239,6 +1240,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1263,6 +1277,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.7.2" @@ -2026,7 +2066,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.63.0" +version = "7.66.0" source = { directory = "../../" } dependencies = [ { name = "backoff" }, @@ -2050,7 +2090,9 @@ requires-dist = [ { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, @@ -2322,19 +2364,19 @@ email = [ [[package]] name = "pydantic-ai" -version = "1.102.0" +version = "1.107.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/a8/c6cecf03aea4ae75126069c6b0f988263d1cb18b97d6d0a6634f5e397b56/pydantic_ai-1.102.0.tar.gz", hash = "sha256:5def631d6e1c68b5e992c88da21b78377fe9262aeaf7f9ca09f67c100a9d3878", size = 17795, upload-time = "2026-05-23T01:14:30.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/84/1c219ab89d42a80c77d4437704bf15a8372ee0f251924d149a7baad33c22/pydantic_ai-1.107.7.tar.gz", hash = "sha256:aa5d170bfd772d0bb31609ac8b1d9b7588bffaf6643c4a7598475312ccd75c95", size = 18747, upload-time = "2026-09-30T01:14:17.459Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/57/de1ab45c2084cb2db886a09d93b005959134655f6ec348cf8a821a177b2f/pydantic_ai-1.102.0-py3-none-any.whl", hash = "sha256:bc38cf4936cf08fa3aaf9d34abf908fd73b47147768cdeb34ec3eaf43909aca8", size = 7587, upload-time = "2026-05-23T01:14:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/db/90/5cd0849fc5a2f2e72051da99cf8be126cf4da25e225b459fd2d48e708d51/pydantic_ai-1.107.7-py3-none-any.whl", hash = "sha256:ddd9b72e4e3486fc1e0cb9fe84efef46f3a5453a4861f9d07b95f401fd7a7c1a", size = 7608, upload-time = "2026-09-30T01:14:08.826Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "1.102.0" +version = "1.107.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -2346,9 +2388,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/3e/14980440e8f0532535e1fbe936fec5f8d8e7bc6cafa81f6f3c51b1884fe5/pydantic_ai_slim-1.102.0.tar.gz", hash = "sha256:0b8f2b70fa2b40efcbd09d341a346934fc4e46622ae281f858c6bfd3d0d3152b", size = 739988, upload-time = "2026-05-23T01:14:32.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/fd/8e5a8f207d1d397eaf26d68fe16d7916c4cfadc7be0d8e9825949c8fa19c/pydantic_ai_slim-1.107.7.tar.gz", hash = "sha256:92aa6087e190017348f9b6354d30f0e67998992c0273e778930005bc31e04794", size = 800463, upload-time = "2026-09-30T01:14:19.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/2e/089df86adaf904dd97a1b139d29fe728af0e41430d747f5b6315df3b0c1e/pydantic_ai_slim-1.102.0-py3-none-any.whl", hash = "sha256:f9fa9c3fb58a76f85522f78d1037d201b424de46d532263ed780b3730060449f", size = 919311, upload-time = "2026-05-23T01:14:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/28/6a/bd17311a6c3cb8c241de6abf43ef2ac0b4209ad46e24ee54efda44c2812c/pydantic_ai_slim-1.107.7-py3-none-any.whl", hash = "sha256:ecc02b80dcd9a4e7182e9389d21d6e559bcc324f6793b3f7171a52fb883edb5b", size = 984364, upload-time = "2026-09-30T01:14:11.694Z" }, ] [package.optional-dependencies] @@ -2385,6 +2427,7 @@ groq = [ { name = "groq" }, ] huggingface = [ + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "huggingface-hub" }, ] logfire = [ @@ -2541,7 +2584,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.102.0" +version = "1.107.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2551,14 +2594,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/2a/2f0a18e170dc1db4b32120bea9e1162ef196c1f453db823878f5eaf7b8bb/pydantic_evals-1.102.0.tar.gz", hash = "sha256:711a6335d24a11c324e5a5c7758b12dfd77209f885ab2501d7eedb9dd5b75b18", size = 78557, upload-time = "2026-05-23T01:14:34.447Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/1b/86b66789a04ddd382647fa0383c511214f504f652872b8b744f7b5a701a4/pydantic_evals-1.107.7.tar.gz", hash = "sha256:c42e39fec380f6126604021fa7b7da3d9b20a855746d4f17e6922d19458f35f9", size = 78541, upload-time = "2026-09-30T01:14:21.15Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fd/2281c166b2c5cedab003b12bf8a630656cb5a9bbd552e4981ee190570d15/pydantic_evals-1.102.0-py3-none-any.whl", hash = "sha256:579edd6f7056d0fe52e03c7004377a0b9c42264c60a370258235fb0750fe20a2", size = 93529, upload-time = "2026-05-23T01:14:25.559Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fc/3508eb8a09994edae90aa43341c989aacaf69da0269fca4e1582dc037cf9/pydantic_evals-1.107.7-py3-none-any.whl", hash = "sha256:818623cf712cb4fdc1b9e132ff238a8342c58bd158de4b4f2ec1d6b6c9dee1e5", size = 93586, upload-time = "2026-09-30T01:14:13.559Z" }, ] [[package]] name = "pydantic-graph" -version = "1.102.0" +version = "1.107.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2566,9 +2609,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/37/4265a1a63eddf35a5aa621c9b2355525bdeae3eb59c3954b165fbfe31404/pydantic_graph-1.102.0.tar.gz", hash = "sha256:e285bd7115e4e92676eaf0a5e7e6faa64cda8c4819f67923a118c50666b909ab", size = 62584, upload-time = "2026-05-23T01:14:36.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/9ec3ab42158e5317d031c915ff06450cb58103637d8c1082f6afda43c501/pydantic_graph-1.107.7.tar.gz", hash = "sha256:f58b7dfa3bf60eb7d68946e64dff2ccbb8e8c67458e957efd7d14bc9338c9400", size = 62567, upload-time = "2026-09-30T01:14:22.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/49/5597c52d50114440047dd4ce4f6505e32ee336f43267639907d1a17648ee/pydantic_graph-1.102.0-py3-none-any.whl", hash = "sha256:b1a28314adc4abca4db02cf095d064782ec5712e0847ce7a6b79a3c84bf1fc01", size = 80100, upload-time = "2026-05-23T01:14:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bb/b7a252e6205d9321ac1ce11fa251d8a25fe27cd23e780afd899036047b8f/pydantic_graph-1.107.7-py3-none-any.whl", hash = "sha256:0a7b964d6895f80b51ebab5e77f59b500d39ca7b17095e2233861e6dfd338ec6", size = 80106, upload-time = "2026-09-30T01:14:15.065Z" }, ] [[package]] @@ -2646,11 +2689,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] @@ -3319,6 +3362,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -3659,7 +3711,7 @@ wheels = [ [[package]] name = "xai-sdk" -version = "1.10.0" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3670,10 +3722,11 @@ dependencies = [ { name = "protobuf" }, { name = "pydantic" }, { name = "requests" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/f7/8053a41e67e3e14fb1a52afc2bef5642f9346cedc3f0594a83b6eec09e5a/xai_sdk-1.10.0.tar.gz", hash = "sha256:0ddb4def34294ba11573b3a4efdd8510456be304b175ddba96a1d687b9dea30e", size = 402806, upload-time = "2026-03-24T17:24:57.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/2b/a03f4a0495e279ece44920bba9da85abe15c3a14ec8eecd683608db63763/xai_sdk-1.20.0.tar.gz", hash = "sha256:ae10fd1713b20a852ffc9909d83233550a3193bd718f77a891ca1485f08e5fb3", size = 473156, upload-time = "2026-09-24T18:05:37.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/01/4082ae7ead2e84c22e22ef383c17f62224abbf9c6aba1f12bab3f80df148/xai_sdk-1.10.0-py3-none-any.whl", hash = "sha256:7644afe2f60311ae626fd3bb364fe135beff96b08715f430ee4a085f5542e357", size = 250708, upload-time = "2026-03-24T17:24:55.576Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/ac8c239f2a38172fb59763d6715bbf24864e24319ccdd7a09894c7644d1c/xai_sdk-1.20.0-py3-none-any.whl", hash = "sha256:cfd64983c48c78a647460a2e8ee136e6a35929249989b6c9be6387fdf86d661f", size = 281393, upload-time = "2026-09-24T18:05:35.82Z" }, ] [[package]] diff --git a/integration_tests/django5/uv.lock b/integration_tests/django5/uv.lock index 66c61178e..68d85d4a7 100644 --- a/integration_tests/django5/uv.lock +++ b/integration_tests/django5/uv.lock @@ -251,7 +251,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.61.1" +version = "7.66.0" source = { editable = "../../" } dependencies = [ { name = "backoff" }, @@ -269,7 +269,9 @@ requires-dist = [ { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, @@ -375,11 +377,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] diff --git a/openfeature-provider/CHANGELOG.md b/openfeature-provider/CHANGELOG.md index 747daf6b2..1f1a08ffb 100644 --- a/openfeature-provider/CHANGELOG.md +++ b/openfeature-provider/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to `openfeature-provider-posthog` are documented here. This file is maintained by [Sampo](https://github.com/bruits/sampo) from changesets in `.sampo/changesets/` that target `pypi/openfeature-provider-posthog`. +## 0.1.102 — 2026-10-12 + +### Patch changes + +- Updated dependencies: posthog@7.68.0 + +## 0.1.101 — 2026-10-12 + +### Patch changes + +- Updated dependencies: posthog@7.67.1 + +## 0.1.100 — 2026-10-09 + +### Patch changes + +- Updated dependencies: posthog@7.67.0 + +## 0.1.99 — 2026-10-08 + +### Patch changes + +- Updated dependencies: posthog@7.66.0 + +## 0.1.98 — 2026-10-08 + +### Patch changes + +- Updated dependencies: posthog@7.65.0 + ## 0.1.97 — 2026-10-06 ### Patch changes diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml index 4987c5008..3f86be56b 100644 --- a/openfeature-provider/pyproject.toml +++ b/openfeature-provider/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openfeature-provider-posthog" -version = "0.1.97" +version = "0.1.102" description = "Official PostHog provider for the OpenFeature Python SDK." readme = "README.md" authors = [{ name = "PostHog", email = "engineering@posthog.com" }] @@ -27,7 +27,7 @@ dependencies = [ # single-call API. Pinned to the posthog major this provider is released and # tested against (both ship from this repo); bump the upper bound when moving # the provider to a new posthog major. - "posthog>=7.0.0,<8.0.0", + "posthog>=7.0.0,<9.0.0", "openfeature-sdk>=0.8.0", ] diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index bd760cde0..64968df2d 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,5 +1,39 @@ # posthog +## 7.68.0 — 2026-10-12 + +### Minor changes + +- [2f0d67d](https://github.com/posthog/posthog-python/commit/2f0d67d55b074ce29b650931bb63ffe498da80a4) Align ASGI, Flask, and Django request integration configuration. ASGI and Flask now default to inheriting the effective client's exception-autocapture setting, with explicit capture overrides and context-only operation. Flask and Django require opt-in to accept PostHog tracing identity/session headers. Django adds property-named settings and extraction methods while preserving legacy aliases and its historical capture default; setting POSTHOG_MW_CAPTURE_EXCEPTIONS to None enables client inheritance. Document client routing, request-filter semantics, and event enrichment independently of error tracking. — Thanks @hpouillot! + +## 7.67.1 — 2026-10-12 + +### Patch changes + +- [a3bd254](https://github.com/posthog/posthog-python/commit/a3bd254bb924322bbd48a2ee2c778fe761a7ee73) Set `$mcp_interface` to `mcp` on events from MCP servers. — Thanks @gesh! + +## 7.67.0 — 2026-10-09 + +### Minor changes + +- [9baf8a5](https://github.com/posthog/posthog-python/commit/9baf8a5a84545568952490ff9397f599e0d6c9b4) Add a Django REST Framework error-only exception handler that captures handled 5xx API exceptions while preserving DRF responses, leaves expected 4xx errors excluded by default, and consistently inherits Django middleware, client, request-filter, and exception-autocapture configuration. — Thanks @hpouillot! + +## 7.66.0 — 2026-10-08 + +### Minor changes + +- [127009d](https://github.com/posthog/posthog-python/commit/127009dc394af6638a3ea8a88bdb0dcae46db0c3) Add `resolve_original_tool` for low-level MCP servers. Fresh server instances can now remove PostHog-owned arguments before strict tool validation. + + Raw low-level servers also remove PostHog-owned arguments after `tools/list`. A tool-owned `context` remains tool data. — Thanks @gesh! +- [1c6a47a](https://github.com/posthog/posthog-python/commit/1c6a47ae235c127329c38db02cb6837a7f1c6434) Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, additional request properties, and opt-in PostHog tracing headers without requiring a framework dependency. — Thanks @hpouillot! +- [292855d](https://github.com/posthog/posthog-python/commit/292855d1f267ad50e9c3b8341c9fbc839b36cf5c) Add a Flask integration that creates isolated request contexts, attaches safe request metadata and tracing identity, and automatically captures unhandled application exceptions without changing Flask's error behavior. — Thanks @hpouillot! + +## 7.65.0 — 2026-10-08 + +### Minor changes + +- [6d6cc3d](https://github.com/posthog/posthog-python/commit/6d6cc3dac989569f3c571308e4db9b9f6ecf7b36) Standardize exception capture metadata, including severity, capture source, typed integration metadata, mechanism semantics, bounded exception-group traversal, and deterministic cause linkage. Application overrides of reserved exception properties remain supported during a deprecation period, emit a warning, and will be removed in the next major version. — Thanks @hpouillot! + ## 7.64.1 — 2026-10-06 ### Patch changes diff --git a/posthog/__init__.py b/posthog/__init__.py index 5d6ae1193..5bea3f82f 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -10,8 +10,9 @@ OptionalSetArgs, ) from posthog.capture_compression import CaptureCompression as CaptureCompression -from posthog.capture_mode import CaptureMode as CaptureMode +from posthog.capture_send import CaptureError as CaptureError from posthog.client import Client +from posthog.utils import _is_personal_api_key from posthog.tracing.span import Span as Span from posthog.async_client import AsyncClient as AsyncClient from posthog.async_client import AsyncPosthog as AsyncPosthog @@ -52,6 +53,12 @@ from posthog.contexts import ( get_tags as inner_get_tags, ) +from posthog.contexts import ( + set_context_option as inner_set_context_option, +) +from posthog.contexts import ( + get_context_options as inner_get_context_options, +) from posthog.exception_utils import ( DEFAULT_CODE_VARIABLES_DETECT_SECRETS, DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS, @@ -299,6 +306,44 @@ def get_tags() -> Dict[str, Any]: return inner_get_tags() +def set_context_option(key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Context options fill options an event leaves unset, before ``before_send`` + runs, so the hook sees them. They override ``super_options``. An event's + own ``options`` override them, and ``before_send`` can change them. + + Args: + key: The option name, such as ``"process_person_profile"`` + value: The option value, sent as given + + Examples: + ```python + from posthog import new_context, set_context_option + with new_context(): + set_context_option("process_person_profile", False) + ``` + + Category: + Contexts + """ + return inner_set_context_option(key, value) + + +def get_context_options() -> Dict[str, Any]: + """ + Get all capture options from the current context. + + Returns: + Dict of all capture options in the current context + + Category: + Contexts + """ + return inner_get_context_options() + + """Settings. These module-level settings configure the legacy global PostHog client used by @@ -313,8 +358,11 @@ def get_tags() -> Dict[str, Any]: disabled no-op global client. host: PostHog ingestion host. Defaults to the US ingestion endpoint when not set. - on_error: Optional callback invoked by background consumers when event upload - fails. Keep it short and non-blocking. Lifecycle methods can be called + on_error: Optional callback ``(error, batch)`` invoked when event upload + fails: by background consumers, or on the calling thread in ``sync_mode``. + Capture failures arrive as ``CaptureError``. Without it, each failed batch + logs one aggregate line. A capture that fails inside the callback logs that + line instead of calling it again. Keep it short and non-blocking. Lifecycle methods can be called directly and will be deferred, but the callback must not wait for another thread or task that calls ``flush()``, ``join()``, or ``shutdown()``. debug: Enable verbose SDK logging and re-raise errors from public APIs. @@ -337,7 +385,18 @@ def get_tags() -> Dict[str, Any]: feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties merged into every captured event. + super_properties: Properties for every captured event. They fill only + keys the event leaves unset, before ``before_send`` runs. ``$set``, + ``$set_once``, ``$groups`` and ``$group_set`` fill one level deep. An + event's own properties and context tags override them, and + ``before_send`` can change or remove them. + super_options: Capture options for every captured event, such as + ``{"cookieless_mode": True}``. They fill only options the event leaves + unset, before ``before_send`` runs. An event's own ``options`` and + context options override them, and ``before_send`` can change them. They also + win over an event's legacy property for the same key, such as + ``$cookieless_mode``, so pass per-event overrides of that key as + ``options``. metrics: Config dict for the ``client.metrics`` API (``service_name``, ``service_version``, ``environment``, ``flush_interval``, ...). Applied when ``setup()`` builds the global client, or on a later ``setup()`` @@ -361,7 +420,9 @@ def get_tags() -> Dict[str, Any]: project_root: Root path used to determine in-app exception stack frames. privacy_mode: Capture AI usage metadata without prompt inputs or outputs. before_send: Optional callback that can modify or drop events before upload. - Return ``None`` to drop an event. + Return ``None`` to drop an event. Context tags, context options, + ``super_properties`` and ``super_options`` fill in before it runs, so + it can change or remove them. enable_local_evaluation: Whether to poll feature flag definitions for local evaluation when a personal API key is configured. flag_definition_cache_provider: Optional external cache provider for sharing @@ -411,6 +472,7 @@ def get_tags() -> Dict[str, Any]: feature_flags_request_timeout_seconds = 3 # type: int feature_flags_request_max_retries = 1 # type: int super_properties = None # type: Optional[Dict] +super_options = None # type: Optional[Dict] metrics = None # type: Optional[Dict] traces = None # type: Optional[Dict] enable_exception_autocapture = False # type: bool @@ -424,11 +486,8 @@ def get_tags() -> Dict[str, Any]: # We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation. enable_local_evaluation = True # type: bool flag_definition_cache_provider = None # type: Optional[FlagDefinitionCacheProvider] -# Capture wire protocol for the global client. None defers to POSTHOG_CAPTURE_MODE -# then CaptureMode.V0. See posthog.capture_mode.CaptureMode. -capture_mode = None # type: Optional[CaptureMode] -# Routes AI SDK wrapper events through the dedicated AI capture lane, skips -# truncation, and passes media unredacted. `privacy_mode` always wins. +# Captures full AI content in AI SDK wrapper events: skips truncation and +# passes media unredacted. `privacy_mode` always wins. enable_full_ai_capture = False # type: bool # Deprecated aliases for `enable_full_ai_capture`. _use_ai_lane = False # type: bool @@ -638,6 +697,7 @@ def group_identify( uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Set properties on a group. @@ -653,6 +713,7 @@ def group_identify( uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup distinct_id: Optional distinct ID of the user performing the action + options: Optional capture options for the event, sent as given Examples: ```python @@ -676,6 +737,7 @@ def group_identify( uuid=uuid, disable_geoip=disable_geoip, distinct_id=distinct_id, + options=options, ) @@ -685,6 +747,7 @@ def alias( timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Associate user behaviour before and after they e.g. register, login, or perform some other identifying action. @@ -696,6 +759,7 @@ def alias( datetimes and parseable ISO timestamp strings are converted to UTC. uuid: Optional UUID for the event disable_geoip: Whether to disable GeoIP lookup + options: Optional capture options for the event, sent as given Details: To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action. @@ -717,11 +781,14 @@ def alias( timestamp=timestamp, uuid=uuid, disable_geoip=disable_geoip, + options=options, ) def capture_exception( exception: Optional[ExceptionArg] = None, + *, + level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs], ) -> Optional[str]: """ @@ -729,8 +796,12 @@ def capture_exception( Args: exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` + level: The ``$exception_level``, such as ``"warning"`` or ``"fatal"``. + Defaults to ``"error"``. An unknown value counts as unset. **kwargs: Optional capture arguments including distinct_id, properties, - timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. + timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options. + Reserved exception properties in ``properties``, such as + ``$exception_list`` and ``$exception_level``, are ignored. Details: Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`. @@ -748,7 +819,7 @@ def capture_exception( Events """ - return _proxy("capture_exception", exception=exception, **kwargs) + return _proxy("capture_exception", exception=exception, level=level, **kwargs) def feature_enabled( @@ -1400,6 +1471,7 @@ def setup() -> Client: feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds, feature_flags_request_max_retries=feature_flags_request_max_retries, super_properties=super_properties, + super_options=super_options, metrics=metrics, traces=traces, # TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK) @@ -1423,12 +1495,15 @@ def setup() -> Client: exception_autocapture_bucket_size=exception_autocapture_bucket_size, exception_autocapture_refill_rate=exception_autocapture_refill_rate, exception_autocapture_refill_interval_seconds=exception_autocapture_refill_interval_seconds, - capture_mode=capture_mode, ) # Always set in case user changes it. Preserve Client's auto-disabled state - # for API keys that become empty after trimming. - default_client.disabled = disabled or not default_client.api_key + # for API keys that are empty after trimming or personal (`phx_`). + default_client.disabled = ( + disabled + or not default_client.api_key + or _is_personal_api_key(default_client.api_key) + ) default_client.debug = debug default_client.privacy_mode = bool(privacy_mode) default_client._set_before_send(before_send) diff --git a/posthog/_async_consumer.py b/posthog/_async_consumer.py index 04eeb082b..d2dc5e75d 100644 --- a/posthog/_async_consumer.py +++ b/posthog/_async_consumer.py @@ -9,11 +9,12 @@ from dataclasses import dataclass from typing import Any, Optional -from ._async_request import async_batch_post, async_send_v1_batch +from ._async_request import async_send_v1_batch from .capture_compression import CaptureCompression -from .capture_mode import CaptureMode +from .capture_event import _EventDefaults +from .capture_send import _CAPTURE_V1_PATH, _capture_loss_message from .consumer import BATCH_SIZE_LIMIT, MAX_MSG_SIZE -from .request import APIError, DatetimeSerializer, EVENTS_ENDPOINT +from .request import DatetimeSerializer _STOP = object() _PROCESSING_EVENT = contextvars.ContextVar( @@ -37,6 +38,7 @@ async def _run_outside_processing_event(awaitable): class _QueuedEvent: event: dict[str, Any] context: contextvars.Context + defaults: Optional[_EventDefaults] = None async def _invoke_callback(callback, *args): @@ -48,8 +50,37 @@ async def _invoke_callback(callback, *args): return result +# True while an SDK-invoked `on_error` runs, so a capture that fails inside the +# callback logs instead of re-entering it. +_IN_ON_ERROR: contextvars.ContextVar[bool] = contextvars.ContextVar( + "posthog_in_on_error", default=False +) + + +async def _report_capture_failure( + on_error: Optional[Callable[..., Any]], + log: logging.Logger, + error: Exception, + batch: list[dict[str, Any]], + endpoint: str, +) -> None: + """Hand a failed send to `on_error`, or log one aggregate line without one.""" + if on_error is None or _IN_ON_ERROR.get(): + log.error(_capture_loss_message(error, max(1, len(batch)), endpoint)) + return + token = _IN_ON_ERROR.set(True) + try: + await _invoke_callback(on_error, error, batch) + except Exception as callback_error: + log.error("on_error handler failed (%s)", type(callback_error).__name__) + finally: + _IN_ON_ERROR.reset(token) + + async def _serialized_event_size(event: dict[str, Any]) -> int: - serialized = await asyncio.to_thread(json.dumps, event, cls=DatetimeSerializer) + serialized = await asyncio.to_thread( + json.dumps, event, cls=DatetimeSerializer, allow_nan=False + ) return len(serialized.encode()) @@ -65,16 +96,18 @@ def __init__( *, host: Optional[str], on_error: Optional[Callable[[Exception, list[dict[str, Any]]], Any]], - process_event: Callable[[dict[str, Any]], Awaitable[Optional[dict[str, Any]]]], + process_event: Callable[ + [dict[str, Any], Optional[_EventDefaults]], + Awaitable[Optional[dict[str, Any]]], + ], flush_at: int, flush_interval: float, - gzip: bool, retries: int, - timeout: int, + timeout: float, historical_migration: bool, - capture_mode: CaptureMode, capture_compression: CaptureCompression, - http_client: Optional[Any], + endpoint: str = _CAPTURE_V1_PATH, + max_msg_size: int = MAX_MSG_SIZE, ) -> None: self.queue = queue self.api_key = api_key @@ -83,13 +116,12 @@ def __init__( self.process_event = process_event self.flush_at = flush_at self.flush_interval = flush_interval - self.gzip = gzip self.retries = max(0, retries) self.timeout = timeout self.historical_migration = historical_migration - self.capture_mode = capture_mode self.capture_compression = capture_compression - self.http_client = http_client + self.endpoint = endpoint + self.max_msg_size = max_msg_size self._carryover: Optional[tuple[dict[str, Any], int]] = None self._flush_event = asyncio.Event() @@ -137,11 +169,11 @@ async def _get_or_flush(self, timeout: float) -> tuple[Any, bool]: return None, False async def _process_queued_event( - self, event: dict[str, Any] + self, queued: _QueuedEvent ) -> Optional[dict[str, Any]]: token = _PROCESSING_EVENT.set(True) try: - return await self.process_event(event) + return await self.process_event(queued.event, queued.defaults) finally: _PROCESSING_EVENT.reset(token) @@ -149,18 +181,9 @@ async def upload(self, batch: list[dict[str, Any]]) -> None: try: await self.request(batch) except Exception as error: - self.log.error( - "async capture upload failed (%s, status=%s)", - type(error).__name__, - getattr(error, "status", None), + await _report_capture_failure( + self.on_error, self.log, error, batch, self.endpoint ) - if self.on_error: - try: - await _invoke_callback(self.on_error, error, batch) - except Exception as callback_error: - self.log.error( - "on_error handler failed (%s)", type(callback_error).__name__ - ) finally: for _ in batch: self.queue.task_done() @@ -195,7 +218,7 @@ async def next(self) -> tuple[list[dict[str, Any]], bool]: try: process_task = queued.context.run( - asyncio.create_task, self._process_queued_event(queued.event) + asyncio.create_task, self._process_queued_event(queued) ) item = await process_task except Exception as error: @@ -217,12 +240,15 @@ async def next(self) -> tuple[list[dict[str, Any]], bool]: self.queue.task_done() continue - if item_size > MAX_MSG_SIZE: + if item_size > self.max_msg_size: + # Log only name and size: AI events may carry unredacted + # multimodal payloads that must not leak into logs. self.log.error( - "Event %s (%d bytes) exceeds the %dKiB limit, dropping.", + "Event %s (%d bytes) exceeds the %dKiB limit for %s, dropping.", item.get("event"), item_size, - MAX_MSG_SIZE // 1024, + self.max_msg_size // 1024, + self.endpoint, ) self.queue.task_done() continue @@ -238,50 +264,43 @@ async def next(self) -> tuple[list[dict[str, Any]], bool]: return items, stop async def request(self, batch: list[dict[str, Any]]) -> None: - if self.capture_mode == CaptureMode.V1: - await async_send_v1_batch( - self.api_key, - self.host, - batch, - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.retries, - historical_migration=self.historical_migration, - ) - return + await async_send_v1_batch( + self.api_key, + self.host, + batch, + compression=self.capture_compression, + timeout=self.timeout, + max_retries=self.retries, + historical_migration=self.historical_migration, + path=self.endpoint, + ) - last_error: Optional[Exception] = None - for attempt in range(self.retries + 1): - try: - await async_batch_post( - self.api_key, - self.host, - batch=batch, - path=EVENTS_ENDPOINT, - gzip=self.gzip, - timeout=self.timeout, - historical_migration=self.historical_migration, - client=self.http_client, - ) - return - except Exception as error: - last_error = error - if not self._is_retryable(error) or attempt >= self.retries: - raise - retry_after = getattr(error, "retry_after", None) - delay = max( - min(2**attempt, 30), - min(retry_after, 30) if retry_after and retry_after > 0 else 0, - ) - await asyncio.sleep(delay) - - if last_error is not None: # pragma: no cover - loop always raises first - raise last_error - - @staticmethod - def _is_retryable(error: Exception) -> bool: - if not isinstance(error, APIError): - return True - if not isinstance(error.status, int): - return False - return not (400 <= error.status < 500 and error.status not in (408, 429)) + +class _AsyncLane: + """One capture queue, the consumer tasks that drain it, and the endpoint they post to. + + The client owns one lane per traffic class (analytics, AI), so each gets + its own backpressure, timeout, size cap and compression. + """ + + def __init__( + self, + *, + name: str, + max_queue_size: int, + endpoint: str, + max_msg_size: int, + timeout: float, + capture_compression: CaptureCompression, + ) -> None: + self.name = name + self.queue: asyncio.Queue[Any] = asyncio.Queue(max_queue_size) + self.endpoint = endpoint + self.max_msg_size = max_msg_size + self.timeout = timeout + self.capture_compression = capture_compression + self.consumers: list[_AsyncConsumer] = [] + self.worker_tasks: list[asyncio.Task[None]] = [] + + def pending_items(self) -> int: + return int(getattr(self.queue, "_unfinished_tasks", self.queue.qsize())) diff --git a/posthog/_async_request.py b/posthog/_async_request.py index ae750de9a..1e10c3cd2 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -2,16 +2,12 @@ import asyncio import json -import logging -import zlib from datetime import datetime, timezone -from gzip import GzipFile -from io import BytesIO from typing import Any, Optional -from urllib.parse import quote, urljoin, urlsplit +from urllib.parse import quote from .capture_compression import CaptureCompression -from .capture_v1 import _parse_retry_after, _send_v1_batch +from .capture_send import _CAPTURE_V1_PATH, _parse_retry_after, _send_v1_batch from .request import ( APIError, DatetimeSerializer, @@ -41,54 +37,6 @@ def _build_client(host: Optional[str] = None): return httpx_module.AsyncClient(base_url=base_url, follow_redirects=False) -def _serialize_v0_body( - api_key: str, gzip_enabled: bool, body: dict[str, Any] -) -> tuple[str | bytes, dict[str, str]]: - payload = { - **body, - "sent_at": datetime.now(tz=timezone.utc).isoformat(), - "api_key": api_key, - } - serialized = json.dumps(payload, cls=DatetimeSerializer) - data: str | bytes = serialized - headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} - - if gzip_enabled: - try: - buf = BytesIO() - with GzipFile(fileobj=buf, mode="w") as gz: - gz.write(serialized.encode("utf-8")) - data = buf.getvalue() - headers["Content-Encoding"] = "gzip" - except (OSError, zlib.error) as exc: - logging.getLogger("posthog").warning( - "failed to gzip async request body, sending uncompressed: %s", exc - ) - - return data, headers - - -def _origin(url: str) -> tuple[str, str, Optional[int]]: - parsed = urlsplit(url) - port = parsed.port - if port is None: - port = 443 if parsed.scheme.lower() == "https" else 80 - return parsed.scheme.lower(), (parsed.hostname or "").lower(), port - - -def _same_origin_redirect_url( - base_url: str, current_url: str, location: str -) -> Optional[str]: - target = urlsplit(urljoin(current_url, location)) - if _origin(target.geturl()) != _origin(base_url): - return None - return ( - urlsplit(base_url) - ._replace(path=target.path or "/", query=target.query, fragment="") - .geturl() - ) - - def _serialize_flags_body( project_api_key: str, body: dict[str, Any] ) -> tuple[str, dict[str, str]]: @@ -196,73 +144,16 @@ async def async_remote_config( await http_client.aclose() -async def async_batch_post( - api_key: str, - host: Optional[str], - *, - batch: list[dict[str, Any]], - path: str, - gzip: bool = False, - timeout: int = 15, - historical_migration: bool = False, - client: Optional[Any] = None, -) -> None: - """Post one legacy capture batch without blocking the event loop.""" - if not path.startswith("/") or "://" in path: - raise ValueError("async capture paths must be relative") - - data, headers = await asyncio.to_thread( - _serialize_v0_body, - api_key, - gzip, - { - "batch": batch, - "historical_migration": historical_migration, - }, - ) - - owns_client = client is None - http_client = client or _build_client(host) - try: - logging.getLogger("posthog").debug("making async capture request") - base_url = remove_trailing_slash(normalize_host(host)) - # Absolute URLs avoid reapplying an HTTPX base_url path on redirects. - request_url = f"{base_url}{path}" - for redirect_count in range(6): - response = await http_client.post( - request_url, content=data, headers=headers, timeout=timeout - ) - if response.status_code not in (307, 308): - _process_response(response) - return - - location = response.headers.get("Location") or response.headers.get( - "location" - ) - redirect_url = ( - _same_origin_redirect_url(base_url, request_url, location) - if location - else None - ) - if redirect_url is None: - raise APIError(400, "Cross-origin or invalid redirect blocked") - if redirect_count >= 5: - raise APIError(400, "Too many capture redirects") - request_url = redirect_url - finally: - if owns_client: - await http_client.aclose() - - async def async_send_v1_batch( api_key: str, host: Optional[str], batch: list[dict[str, Any]], *, compression: CaptureCompression, - timeout: int, + timeout: float, max_retries: int, historical_migration: bool, + path: str = _CAPTURE_V1_PATH, ) -> None: """Run the existing capture-v1 submitter off-loop to preserve wire parity.""" await asyncio.to_thread( @@ -274,4 +165,5 @@ async def async_send_v1_batch( timeout=timeout, max_retries=max_retries, historical_migration=historical_migration, + path=path, ) diff --git a/posthog/ai/anthropic/anthropic.py b/posthog/ai/anthropic/anthropic.py index a474fefcf..df8230193 100644 --- a/posthog/ai/anthropic/anthropic.py +++ b/posthog/ai/anthropic/anthropic.py @@ -17,6 +17,7 @@ ToolInProgress as ToolInProgress, ) from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage as call_llm_and_track_usage, merge_usage_stats as merge_usage_stats, ) @@ -29,7 +30,6 @@ handle_anthropic_tool_delta as handle_anthropic_tool_delta, ) from posthog.client import Client as PostHogClient -from posthog import setup class Anthropic(anthropic.Anthropic): @@ -46,7 +46,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Additional arguments passed to the Anthropic client """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) diff --git a/posthog/ai/anthropic/anthropic_async.py b/posthog/ai/anthropic/anthropic_async.py index 9ac091c64..42b7799da 100644 --- a/posthog/ai/anthropic/anthropic_async.py +++ b/posthog/ai/anthropic/anthropic_async.py @@ -10,7 +10,6 @@ import uuid from typing import Any, Dict, List, Optional -from posthog import setup from ..stream import AsyncStreamWrapper as AsyncStreamWrapper from ..types import ( StreamingContentBlock as StreamingContentBlock, @@ -18,6 +17,7 @@ ToolInProgress as ToolInProgress, ) from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage_async as call_llm_and_track_usage_async, merge_usage_stats as merge_usage_stats, ) @@ -46,7 +46,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Additional arguments passed to the Anthropic client """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) diff --git a/posthog/ai/anthropic/anthropic_providers.py b/posthog/ai/anthropic/anthropic_providers.py index 8b816b162..2e8fe2806 100644 --- a/posthog/ai/anthropic/anthropic_providers.py +++ b/posthog/ai/anthropic/anthropic_providers.py @@ -9,8 +9,8 @@ from posthog.ai.anthropic.anthropic import WrappedMessages from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages +from posthog.ai.utils import _resolve_ai_client from posthog.client import Client as PostHogClient -from posthog import setup class AnthropicBedrock(anthropic.AnthropicBedrock): @@ -28,7 +28,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AnthropicBedrock``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) @@ -47,7 +47,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AsyncAnthropicBedrock``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) @@ -66,7 +66,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AnthropicVertex``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) @@ -85,5 +85,5 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AsyncAnthropicVertex``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) diff --git a/posthog/ai/claude_agent_sdk/client.py b/posthog/ai/claude_agent_sdk/client.py index f9a718d80..2766ec925 100644 --- a/posthog/ai/claude_agent_sdk/client.py +++ b/posthog/ai/claude_agent_sdk/client.py @@ -242,14 +242,12 @@ async def disconnect(self) -> None: **self._extra_props, } - if resolved_id is None: - properties["$process_person_profile"] = False - self._processor._capture_event( "$ai_trace", properties, resolved_id or self._trace_id, self._groups, + personless=resolved_id is None, ) try: diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 1d45b61c2..da0afa5d4 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -24,7 +24,6 @@ "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" ) -from posthog import setup from posthog.ai.claude_agent_sdk.formatting import ( format_assistant_blocks, format_tool_result_content, @@ -32,6 +31,7 @@ from posthog.ai.media import ensure_serializable as _ensure_serializable from posthog.ai.utils import ( _capture_processor_event, + _resolve_ai_client, finalize_ai_content, with_privacy_mode as _with_privacy_mode, ) @@ -167,7 +167,7 @@ def __init__( groups: Optional PostHog groups to associate with emitted events. properties: Additional properties included on emitted AI events. """ - self._client = client or setup() + self._client = _resolve_ai_client(client) self._distinct_id = distinct_id self._privacy_mode = privacy_mode self._groups = groups or {} @@ -192,6 +192,7 @@ def _capture_event( properties: Dict[str, Any], distinct_id: Optional[str] = None, groups: Optional[Dict[str, Any]] = None, + personless: bool = False, ) -> None: _capture_processor_event( self._client, @@ -200,6 +201,7 @@ def _capture_event( default_properties=self._properties, distinct_id=distinct_id, groups=groups if groups is not None else self._groups, + personless=personless, ) async def query( @@ -449,11 +451,12 @@ def _emit_generation( if gen.stop_reason is not None: properties["$ai_stop_reason"] = gen.stop_reason - if resolved_id is None: - properties["$process_person_profile"] = False - self._capture_event( - "$ai_generation", properties, resolved_id or trace_id, groups + "$ai_generation", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, ) def _emit_generation_from_result( @@ -522,11 +525,12 @@ def _emit_generation_from_result( if result.total_cost_usd is not None: properties["$ai_total_cost_usd"] = result.total_cost_usd - if resolved_id is None: - properties["$process_person_profile"] = False - self._capture_event( - "$ai_generation", properties, resolved_id or trace_id, groups + "$ai_generation", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, ) def _emit_tool_span( @@ -557,10 +561,13 @@ def _emit_tool_span( _ensure_serializable(block.input), self._client ) - if resolved_id is None: - properties["$process_person_profile"] = False - - self._capture_event("$ai_span", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_span", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, + ) def _emit_trace( self, @@ -592,10 +599,13 @@ def _emit_trace( if result.total_cost_usd is not None: properties["$ai_total_cost_usd"] = result.total_cost_usd - if resolved_id is None: - properties["$process_person_profile"] = False - - self._capture_event("$ai_trace", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_trace", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, + ) # Flush to ensure events are sent before process exits try: diff --git a/posthog/ai/gemini/_shared.py b/posthog/ai/gemini/_shared.py index fc3635589..356abb6fc 100644 --- a/posthog/ai/gemini/_shared.py +++ b/posthog/ai/gemini/_shared.py @@ -9,11 +9,11 @@ "Please install the Google Gemini SDK to use this feature: 'pip install google-genai'" ) -from ... import setup from ...client import Client as PostHogClient from ..types import StreamingEventData, TokenUsage from ..utils import ( _capture_ai_event, + _resolve_ai_client, capture_streaming_event, finalize_ai_content, merge_system_prompt, @@ -27,15 +27,6 @@ _GEMINI_BASE_URL = "https://generativelanguage.googleapis.com" -def _resolve_posthog_client( - posthog_client: Optional[PostHogClient], -) -> PostHogClient: - client = posthog_client or setup() - if client is None: - raise ValueError("posthog_client is required for PostHog tracking") - return client - - def _build_gemini_client_args( *, api_key: Optional[str], @@ -138,7 +129,7 @@ def _initialize_policy( posthog_groups: Optional[Dict[str, Any]], provider_client: Optional[Any] = None, ) -> None: - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) self._default_distinct_id = posthog_distinct_id self._default_properties = posthog_properties or {} self._default_privacy_mode = posthog_privacy_mode @@ -266,13 +257,11 @@ def _capture_embedding_outcome( event_properties["$ai_is_error"] = True event_properties["$ai_error"] = str(error) - if distinct_id is None: - event_properties["$process_person_profile"] = False - _capture_ai_event( self._ph_client, "$ai_embedding", distinct_id=distinct_id or trace_id, properties=event_properties, groups=groups, + personless=distinct_id is None, ) diff --git a/posthog/ai/gemini/gemini.py b/posthog/ai/gemini/gemini.py index ff74bd270..573d56512 100644 --- a/posthog/ai/gemini/gemini.py +++ b/posthog/ai/gemini/gemini.py @@ -6,6 +6,7 @@ from ..types import StreamingEventData as StreamingEventData from ..types import TokenUsage from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage, capture_streaming_event as capture_streaming_event, finalize_ai_content as finalize_ai_content, @@ -17,7 +18,6 @@ _GeminiAioNamespace, _GeminiModelsPolicy, _build_gemini_client, - _resolve_posthog_client, ) from .gemini_async import AsyncModels from .gemini_converter import ( @@ -92,7 +92,7 @@ def __init__( **kwargs: Additional arguments (for future compatibility) """ - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) # Built once here and shared by every surface, so a client that uses both # `models` and `aio.models` still opens a single provider client. diff --git a/posthog/ai/gemini/gemini_async.py b/posthog/ai/gemini/gemini_async.py index edede3dca..7dc11fcd2 100644 --- a/posthog/ai/gemini/gemini_async.py +++ b/posthog/ai/gemini/gemini_async.py @@ -7,6 +7,7 @@ from ..types import StreamingEventData as StreamingEventData from ..types import TokenUsage from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage_async, capture_streaming_event as capture_streaming_event, finalize_ai_content as finalize_ai_content, @@ -18,7 +19,6 @@ _GeminiAioNamespace, _GeminiModelsPolicy, _build_gemini_client, - _resolve_posthog_client, ) from .gemini_converter import ( extract_gemini_content_from_chunk, @@ -87,7 +87,7 @@ def __init__( **kwargs: Additional arguments (for future compatibility) """ - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) # Built once here and shared by every surface, so `models`, `aio.models` # and `files` all go through a single provider client. diff --git a/posthog/ai/langchain/callbacks.py b/posthog/ai/langchain/callbacks.py index 3b9e188b0..103f39e35 100644 --- a/posthog/ai/langchain/callbacks.py +++ b/posthog/ai/langchain/callbacks.py @@ -41,11 +41,11 @@ from langchain_core.outputs import ChatGeneration, LLMResult from pydantic import BaseModel -from posthog import setup from posthog.ai.gateway import warn_if_posthog_ai_gateway from posthog.ai.utils import ( _capture_ai_event, _extract_cache_creation_ttl_breakdown, + _resolve_ai_client, finalize_ai_content, get_model_params, _responses_stop_reason, @@ -145,7 +145,7 @@ def __init__( privacy_mode: Whether to redact the input and output of the trace. groups: Optional additional PostHog groups to use for the trace. """ - self._ph_client = client or setup() + self._ph_client = _resolve_ai_client(client) self._distinct_id = distinct_id self._trace_id = trace_id self._properties = properties or {} @@ -577,15 +577,13 @@ def _capture_trace_or_span( finalize_ai_content(outputs, self._ph_client), ) - if self._distinct_id is None: - event_properties["$process_person_profile"] = False - _capture_ai_event( self._ph_client, event_name, distinct_id=self._distinct_id or run_id, properties=event_properties, groups=self._groups, + personless=self._distinct_id is None, ) def _pop_run_and_capture_generation( @@ -662,9 +660,6 @@ def _capture_generation( if self._properties: event_properties.update(self._properties) - if self._distinct_id is None: - event_properties["$process_person_profile"] = False - if isinstance(output, BaseException): event_properties["$ai_http_status"] = _get_http_status(output) event_properties["$ai_error"] = _stringify_exception(output) @@ -737,6 +732,7 @@ def _capture_generation( distinct_id=self._distinct_id or trace_id, properties=event_properties, groups=self._groups, + personless=self._distinct_id is None, ) def _log_debug_event( diff --git a/posthog/ai/openai/_embeddings.py b/posthog/ai/openai/_embeddings.py index 8cdb04d33..8c6565d3b 100644 --- a/posthog/ai/openai/_embeddings.py +++ b/posthog/ai/openai/_embeddings.py @@ -38,14 +38,12 @@ def _capture_embedding_event( **(properties or {}), } - if distinct_id is None: - event_properties["$process_person_profile"] = False - - if hasattr(posthog_client, "capture"): + if callable(getattr(posthog_client, "capture_ai", None)): _capture_ai_event( posthog_client, "$ai_embedding", distinct_id=distinct_id or trace_id, properties=event_properties, groups=groups, + personless=distinct_id is None, ) diff --git a/posthog/ai/openai/openai.py b/posthog/ai/openai/openai.py index c1950939c..e2cbf7fb6 100644 --- a/posthog/ai/openai/openai.py +++ b/posthog/ai/openai/openai.py @@ -12,6 +12,7 @@ ) from posthog.ai.utils import ( + _resolve_ai_client, call_llm_and_track_usage, extract_available_tool_calls as extract_available_tool_calls, finalize_ai_content as finalize_ai_content, @@ -27,7 +28,6 @@ format_openai_streaming_output as _format_openai_streaming_output, ) from posthog.client import Client as PostHogClient -from posthog import setup from posthog.ai.openai._streaming import ( _ChatCompletionsStreamState, _ResponsesStreamState, @@ -64,7 +64,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _SYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai/openai_async.py b/posthog/ai/openai/openai_async.py index dac3eeb4a..f9e574c4f 100644 --- a/posthog/ai/openai/openai_async.py +++ b/posthog/ai/openai/openai_async.py @@ -12,8 +12,8 @@ "Please install the OpenAI SDK to use this feature: 'pip install openai'" ) -from posthog import setup from posthog.ai.utils import ( + _resolve_ai_client, call_llm_and_track_usage_async, extract_available_tool_calls as extract_available_tool_calls, finalize_ai_content as finalize_ai_content, @@ -66,7 +66,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _ASYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai/openai_providers.py b/posthog/ai/openai/openai_providers.py index 7c0ce04b6..1661903b8 100644 --- a/posthog/ai/openai/openai_providers.py +++ b/posthog/ai/openai/openai_providers.py @@ -23,8 +23,8 @@ ) from .wrapper_utils import _wrap_openai_resources +from posthog.ai.utils import _resolve_ai_client from posthog.client import Client as PostHogClient -from posthog import setup class AzureOpenAI(openai.AzureOpenAI): @@ -49,7 +49,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): ``api_key``, ``azure_endpoint``, or ``api_version``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _SYNC_RESOURCE_WRAPPERS) @@ -76,6 +76,6 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): ``api_key``, ``azure_endpoint``, or ``api_version``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _ASYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index 8bdea6c7e..f54c43e58 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -19,11 +19,11 @@ TranscriptionSpanData, ) -from posthog import setup from posthog.ai.media import ensure_serializable as _ensure_serializable from posthog.ai.sanitization import _full_ai_capture_enabled, _placeholder from posthog.ai.utils import ( _capture_processor_event, + _resolve_ai_client, finalize_ai_content, with_privacy_mode as _with_privacy_mode, ) @@ -88,7 +88,7 @@ def __init__( groups: Optional PostHog groups to associate with all events. properties: Optional additional properties to include with all events. """ - self._client = client or setup() + self._client = _resolve_ai_client(client) self._distinct_id = distinct_id self._privacy_mode = privacy_mode self._groups = groups or {} @@ -159,14 +159,18 @@ def _capture_event( properties: Dict[str, Any], distinct_id: Optional[str] = None, ) -> None: - """Capture an event without allowing telemetry failures to escape.""" + """Capture an event without allowing telemetry failures to escape. + + Without a distinct ID, the event is personless and uses its trace ID. + """ _capture_processor_event( self._client, event, properties, default_properties=self._properties, - distinct_id=distinct_id, + distinct_id=distinct_id or properties.get("$ai_trace_id"), groups=self._groups, + personless=distinct_id is None, ) def on_trace_start(self, trace: Trace) -> None: @@ -228,12 +232,9 @@ def on_trace_end(self, trace: Trace) -> None: if metadata: properties["$ai_trace_metadata"] = _ensure_serializable(metadata) - if distinct_id is None: - properties["$process_person_profile"] = False - self._capture_event( event="$ai_trace", - distinct_id=distinct_id or trace_id, + distinct_id=distinct_id, properties=properties, ) except Exception as e: @@ -267,8 +268,8 @@ def on_span_end(self, span: Span[Any]) -> None: latency = (ended - started) if (started and ended) else 0 # Get user-provided distinct ID from trace metadata (resolved at trace start). - # None means no user-provided ID — use trace_id as fallback in personless mode, - # matching the langchain/openai pattern: `distinct_id or trace_id`. + # None means no user-provided ID: _capture_event falls back to the + # trace ID and makes the event personless. trace_info = self._trace_metadata.get(trace_id, {}) distinct_id = trace_info.get("distinct_id") or self._get_distinct_id(None) @@ -317,11 +318,6 @@ def on_span_end(self, span: Span[Any]) -> None: "$ai_error_type": error_type, } - # Personless mode: no user-provided distinct_id, fallback to trace_id - if distinct_id is None: - error_properties["$process_person_profile"] = False - distinct_id = trace_id - # Dispatch based on span data type if isinstance(span_data, GenerationSpanData): self._handle_generation_span( @@ -471,7 +467,7 @@ def _handle_generation_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -554,7 +550,7 @@ def _handle_function_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -587,7 +583,7 @@ def _handle_agent_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -616,7 +612,7 @@ def _handle_handoff_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -640,7 +636,7 @@ def _handle_guardrail_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -663,7 +659,7 @@ def _handle_response_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -732,7 +728,7 @@ def _handle_custom_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -757,7 +753,7 @@ def _handle_audio_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -832,7 +828,7 @@ def _handle_mcp_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -856,7 +852,7 @@ def _handle_generic_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: diff --git a/posthog/ai/prompts.py b/posthog/ai/prompts.py index 2cbae9db4..c62841561 100644 --- a/posthog/ai/prompts.py +++ b/posthog/ai/prompts.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Union, overload -from posthog.capture_v1 import _parse_retry_after +from posthog.capture_send import _parse_retry_after from posthog.request import USER_AGENT, _get_session from posthog.utils import remove_trailing_slash diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index ffc2c864c..3c86df05d 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -3,9 +3,9 @@ import uuid from typing import Any, Callable, Dict, List, Optional, Tuple, cast -from posthog import get_tags, identify_context, new_context, tag, contexts +from posthog import get_tags, identify_context, new_context, setup, tag, contexts from posthog.ai.gateway import warn_if_posthog_ai_gateway -from posthog.ai.sanitization import _full_ai_capture_enabled, redact_media +from posthog.ai.sanitization import redact_media from posthog.ai.sanitization import sanitize_messages # noqa: F401 -- re-exported for back-compat from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage from posthog.client import Client as PostHogClient @@ -64,22 +64,56 @@ def _get_tokens_source( return "sdk" -def _ai_lane_enabled(ph_client) -> bool: - """The client's full-AI-capture opt-in routes wrapper events onto the AI lane.""" - return _full_ai_capture_enabled(ph_client) +def _capture_ai_event(ph_client, event: str, *, personless: bool = False, **kwargs): + """Capture a wrapper-emitted AI event with the PostHog AI library identity. - -def _capture_ai_event(ph_client, event: str, **kwargs): - """Capture a wrapper-emitted AI event with the PostHog AI library identity.""" + ``personless`` marks an event whose distinct ID is a fallback, such as a + trace ID, not a real user. + """ kwargs["properties"] = { **_AI_LIB_PROPERTIES, **(kwargs.get("properties") or {}), } - if _ai_lane_enabled(ph_client): - capture_ai = getattr(ph_client, "capture_ai", None) - if callable(capture_ai): - return capture_ai(event=event, **kwargs) - return ph_client.capture(event=event, **kwargs) + if personless: + # A per-event option beats legacy properties and global or context + # options, because a person profile for a fallback ID is always wrong. + # before_send can still change it. + kwargs["options"] = { + **(kwargs.get("options") or {}), + "process_person_profile": False, + } + return ph_client.capture_ai(event=event, **kwargs) + + +class _AICaptureDisabled: + """Stands in for a client without ``capture_ai``: AI events are dropped, and + every other attribute comes from the client.""" + + def __init__(self, client: Any) -> None: + self._client = client + + def capture_ai(self, *args: Any, **kwargs: Any) -> None: + return None + + def __getattr__(self, name: str) -> Any: + return getattr(self._client, name) + + +def _resolve_ai_client(posthog_client: Optional[PostHogClient]) -> PostHogClient: + """Return the client an AI integration sends through, or the global client. + + The check runs when the integration is built, so the error log appears at + setup. A client object without ``capture_ai`` turns AI capture off for the + integration, and provider calls still work. + """ + client = posthog_client or setup() + if not callable(getattr(client, "capture_ai", None)): + logging.getLogger("posthog").error( + "The PostHog client for an AI integration must implement capture_ai(). " + "AI events from this integration are not captured." + ) + return cast(PostHogClient, _AICaptureDisabled(client)) + return client def _capture_processor_event( @@ -90,11 +124,11 @@ def _capture_processor_event( default_properties: Optional[Dict[str, Any]] = None, distinct_id: Optional[str] = None, groups: Optional[Dict[str, Any]] = None, + personless: bool = False, ) -> None: """Apply the shared capture policy used by AI SDK processors.""" try: - capture = getattr(ph_client, "capture", None) - if not callable(capture): + if not callable(getattr(ph_client, "capture_ai", None)): return _capture_ai_event( @@ -103,6 +137,7 @@ def _capture_processor_event( distinct_id=distinct_id or "unknown", properties={**properties, **(default_properties or {})}, groups=groups, + personless=personless, ) except Exception as exc: logging.getLogger("posthog").debug("Failed to capture PostHog event: %s", exc) @@ -586,9 +621,6 @@ def call_llm_and_track_usage( if stop_reason is not None: tag("$ai_stop_reason", stop_reason) - if not has_person_distinct_id: - tag("$process_person_profile", False) - # Process instructions for Responses API if provider == "openai" and kwargs.get("instructions") is not None: tag( @@ -599,7 +631,7 @@ def call_llm_and_track_usage( ) # send the event to posthog - if hasattr(ph_client, "capture") and callable(ph_client.capture): + if callable(getattr(ph_client, "capture_ai", None)): sdk_tags = get_tags() merged_properties = { **sdk_tags, @@ -615,6 +647,7 @@ def call_llm_and_track_usage( distinct_id=contexts.get_context_distinct_id(), properties=merged_properties, groups=posthog_groups, + personless=not has_person_distinct_id, ) if error: @@ -753,9 +786,6 @@ async def call_llm_and_track_usage_async( if stop_reason is not None: tag("$ai_stop_reason", stop_reason) - if not has_person_distinct_id: - tag("$process_person_profile", False) - # Process instructions for Responses API if provider == "openai" and kwargs.get("instructions") is not None: tag( @@ -766,7 +796,7 @@ async def call_llm_and_track_usage_async( ) # send the event to posthog - if hasattr(ph_client, "capture") and callable(ph_client.capture): + if callable(getattr(ph_client, "capture_ai", None)): sdk_tags = get_tags() merged_properties = { **sdk_tags, @@ -782,6 +812,7 @@ async def call_llm_and_track_usage_async( distinct_id=contexts.get_context_distinct_id(), properties=merged_properties, groups=posthog_groups, + personless=not has_person_distinct_id, ) if error: @@ -964,15 +995,13 @@ def capture_streaming_event( event_data["kwargs"]["instructions"], ) - if event_data.get("distinct_id") is None: - event_properties["$process_person_profile"] = False - # Send event to PostHog - if hasattr(ph_client, "capture"): + if callable(getattr(ph_client, "capture_ai", None)): _capture_ai_event( ph_client, "$ai_generation", distinct_id=event_data.get("distinct_id") or trace_id, properties=event_properties, groups=event_data.get("groups"), + personless=event_data.get("distinct_id") is None, ) diff --git a/posthog/args.py b/posthog/args.py index 42083b923..1b2aa4118 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -48,12 +48,16 @@ class OptionalCaptureArgs(TypedDict): hidden ``/flags`` request on capture and may return different values than the ones the code branched on. disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False. + options: Capture options for this event, such as ``{"process_person_profile": False}``. + Sent as given, for PostHog to validate. An option wins over its legacy ``$`` property, + such as ``$process_person_profile``. A value that is not a dict is logged and ignored. """ distinct_id: NotRequired[Optional[ID_TYPES]] properties: NotRequired[Optional[Dict[str, Any]]] timestamp: NotRequired[Optional[Union[datetime, str]]] uuid: NotRequired[Optional[Union[str, UUID]]] + options: NotRequired[Optional[Dict[str, Any]]] groups: NotRequired[Optional[Dict[str, str]]] flags: NotRequired[Optional["FeatureFlagEvaluations"]] send_feature_flags: NotRequired[ @@ -83,6 +87,7 @@ class OptionalSetArgs(TypedDict): it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False. + options: Capture options for this event, sent as given. See ``OptionalCaptureArgs``. """ distinct_id: NotRequired[Optional[ID_TYPES]] @@ -90,6 +95,7 @@ class OptionalSetArgs(TypedDict): timestamp: NotRequired[Optional[Union[datetime, str]]] uuid: NotRequired[Optional[Union[str, UUID]]] disable_geoip: NotRequired[Optional[bool]] + options: NotRequired[Optional[Dict[str, Any]]] ExcInfo = Union[ diff --git a/posthog/async_client.py b/posthog/async_client.py index 3f429e81a..87237a1b6 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -9,16 +9,19 @@ import threading import warnings import weakref +from dataclasses import replace from datetime import datetime, timezone from typing import Any, Dict, Mapping, Optional, Union -from uuid import UUID, uuid4 +from uuid import UUID from typing_extensions import Unpack from ._async_consumer import ( _STOP, _AsyncConsumer, + _AsyncLane, _invoke_callback, + _report_capture_failure, _is_processing_event, _QueuedEvent, _run_outside_processing_event, @@ -26,22 +29,36 @@ ) from ._async_request import ( _build_client, - _require_httpx, async_flags as _async_flags, async_remote_config as _async_remote_config, ) from .args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs from .capture_compression import ( CaptureCompression, + _resolve_capture_ai_compression, _resolve_capture_compression, ) -from .capture_mode import CaptureMode, _resolve_capture_mode +from .capture_event import ( + _build_event_defaults, + _event_options, + _EventDefaults, + _fill_event_defaults, + _merge_groups, + _resolve_event_uuid, +) +from .capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from .client import ( MAX_DICT_SIZE as _MAX_DICT_SIZE, _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client as _SyncClient, _metadata_has_experiment, _parse_flag_payload, + _add_context_session_id, + _context_tag_defaults, + _explicit_distinct_id, + _group_identify_distinct_id, + _personless_options, + _positive_config_value, add_context_tags as _add_context_tags, get_identity_state as _get_identity_state, stringify_id as _stringify_id, @@ -54,6 +71,7 @@ get_code_variables_mask_url_credentials_context, get_context_device_id as _get_context_device_id, get_context_distinct_id as _get_context_distinct_id, + get_context_options as _get_context_options, get_context_session_id as _get_context_session_id, ) from .exception_utils import ( @@ -61,7 +79,9 @@ DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS, DEFAULT_CODE_VARIABLES_MASK_PATTERNS, DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS, + _exception_level, _get_current_otel_span_properties, + _without_reserved_exception_properties, exc_info_from_error, exception_is_already_captured, exceptions_from_error_tuple, @@ -69,7 +89,7 @@ mark_exception_as_captured, try_attach_code_variables_to_frames, ) -from .consumer import MAX_MSG_SIZE +from .consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE from .feature_flag_evaluations import ( FeatureFlagEvaluations, _EvaluatedFlagRecord, @@ -78,7 +98,15 @@ from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host from .types import FlagMetadata, FlagValue, normalize_flags_response -from .utils import SizeLimitedDict, _normalize_timestamp, clean, system_context +from .utils import ( + SizeLimitedDict, + _is_personal_api_key, + _normalize_timestamp, + clean, + _drop_invalid_distinct_id, + _drop_invalid_event_name, + system_context, +) from .version import VERSION __all__ = ["AsyncClient", "AsyncPosthog"] @@ -89,7 +117,12 @@ class AsyncClient: ``capture()`` is a synchronous, non-blocking queue write. Use ``await capture_immediate()`` when the caller must wait for delivery. + ``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. + + The ``capture_ai_*``, ``privacy_mode`` and ``enable_full_ai_capture`` + options work the same way as on :class:`posthog.Client`. """ log = logging.getLogger("posthog") @@ -98,13 +131,13 @@ def __init__( self, project_api_key: str, host: Optional[str] = None, + *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, - gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, @@ -113,6 +146,7 @@ def __init__( is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, + super_options: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, @@ -122,8 +156,13 @@ def __init__( code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, - capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_max_queue_size: int = 1000, + capture_ai_timeout: float = 30, + capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, + privacy_mode: bool = False, + enable_full_ai_capture: bool = False, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, @@ -134,6 +173,18 @@ def __init__( raise ValueError("flush_at must be greater than zero") if flush_interval <= 0: raise ValueError("flush_interval must be greater than zero") + capture_ai_max_queue_size = _positive_config_value( + "capture_ai_max_queue_size", capture_ai_max_queue_size, integer=True + ) + capture_ai_timeout = _positive_config_value( + "capture_ai_timeout", capture_ai_timeout + ) + capture_ai_max_event_bytes = _positive_config_value( + "capture_ai_max_event_bytes", + capture_ai_max_event_bytes, + integer=True, + maximum=AI_MAX_MSG_SIZE, + ) self.api_key = (project_api_key or "").strip() self.raw_host = normalize_host(host) @@ -141,20 +192,24 @@ def __init__( self.debug = debug self.send = send self.on_error = on_error - self.gzip = gzip self.max_retries = max(0, max_retries) self.timeout = timeout - self.disabled = disabled or not self.api_key + self.disabled = ( + disabled or not self.api_key or _is_personal_api_key(self.api_key) + ) self.disable_geoip = disable_geoip self.is_server = is_server self.historical_migration = historical_migration self.super_properties = super_properties + self.super_options = super_options self._release_id = _resolve_release_id() - self.capture_mode = _resolve_capture_mode(capture_mode) - self.capture_compression = _resolve_capture_compression( - capture_compression, gzip_fallback=gzip + self.capture_compression = _resolve_capture_compression(capture_compression) + self.capture_ai_compression = _resolve_capture_ai_compression( + capture_ai_compression ) self.capture_trace_context = capture_trace_context + self.privacy_mode = privacy_mode + self.enable_full_ai_capture = enable_full_ai_capture is True if personal_api_key is not None and secret_key is None: warnings.warn( "`personal_api_key` is deprecated; use `secret_key` instead.", @@ -228,13 +283,35 @@ def __init__( self.log.error( "api_key is empty after trimming whitespace; check your project API key" ) + elif _is_personal_api_key(self.api_key): + self.log.error( + "api_key is a personal API key (phx_...), not a project API key; " + "PostHog is disabled. Use the project API key (phc_...)." + ) - self._queue: asyncio.Queue[Any] = asyncio.Queue(max_queue_size) + self._analytics_lane = _AsyncLane( + name="analytics", + max_queue_size=max_queue_size, + endpoint=_CAPTURE_V1_PATH, + max_msg_size=MAX_MSG_SIZE, + timeout=timeout, + capture_compression=self.capture_compression, + ) + # AI events post to their own endpoint, so multi-MB events stay off the + # analytics endpoint's smaller caps. Its workers start on the first AI + # event, so clients that never send one pay nothing. + self._ai_lane = _AsyncLane( + name="ai", + max_queue_size=capture_ai_max_queue_size, + endpoint=_CAPTURE_AI_V1_PATH, + max_msg_size=capture_ai_max_event_bytes, + timeout=capture_ai_timeout, + capture_compression=self.capture_ai_compression, + ) + self._lanes = (self._analytics_lane, self._ai_lane) self._worker_count = max(1, thread) self._flush_at = flush_at self._flush_interval = flush_interval - self._consumers: list[_AsyncConsumer] = [] - self._worker_tasks: list[asyncio.Task[None]] = [] self._immediate_callers: dict[asyncio.Task[Any], int] = {} self._inflight_operations: set[asyncio.Future[None]] = set() self._http_client: Optional[Any] = None @@ -248,7 +325,7 @@ def __init__( self._register_duplicate_client() async def __aenter__(self) -> AsyncClient: - self._ensure_workers_started() + self._ensure_workers_started(self._analytics_lane) return self async def __aexit__(self, exc_type, exc, tb) -> bool: @@ -309,44 +386,47 @@ def _get_http_client(self): self._http_client = _build_client(self.host) return self._http_client - def _new_consumer(self) -> _AsyncConsumer: - http_client = ( - self._get_http_client() if self.capture_mode == CaptureMode.V0 else None - ) + def _new_consumer(self, lane: _AsyncLane) -> _AsyncConsumer: return _AsyncConsumer( - self._queue, + lane.queue, self.api_key, host=self.host, on_error=self.on_error, process_event=self._process_event, flush_at=self._flush_at, flush_interval=self._flush_interval, - gzip=self.gzip, retries=self.max_retries, - timeout=self.timeout, + timeout=lane.timeout, historical_migration=self.historical_migration, - capture_mode=self.capture_mode, - capture_compression=self.capture_compression, - http_client=http_client, + capture_compression=lane.capture_compression, + endpoint=lane.endpoint, + max_msg_size=lane.max_msg_size, ) - def _validate_transport_available(self) -> None: - if self.capture_mode == CaptureMode.V0: - _require_httpx() - - def _ensure_workers_started(self) -> None: - if self.disabled or not self.send or self._closed or self._worker_tasks: + def _ensure_workers_started(self, lane: _AsyncLane) -> None: + if self.disabled or not self.send or self._closed or lane.worker_tasks: return self._bind_loop() for _ in range(self._worker_count): - consumer = self._new_consumer() - self._consumers.append(consumer) - self._worker_tasks.append(asyncio.create_task(consumer.run())) + consumer = self._new_consumer(lane) + lane.consumers.append(consumer) + lane.worker_tasks.append(asyncio.create_task(consumer.run())) + + def _all_worker_tasks(self) -> list[asyncio.Task[None]]: + return [task for lane in self._lanes for task in lane.worker_tasks] - def _enqueue_prepared_event(self, prepared: dict[str, Any]) -> bool: + def _enqueue_prepared_event( + self, prepared: dict[str, Any], lane: _AsyncLane, defaults: _EventDefaults + ) -> bool: if not self._accepting or self._closed: return False - queued_event = _QueuedEvent(prepared, contextvars.copy_context()) + # Shallow copy, as 7.x merged super_properties into a new dict at capture. + defaults = replace( + defaults, + property_layers=tuple(dict(layer) for layer in defaults.property_layers), + option_layers=tuple(dict(layer) for layer in defaults.option_layers), + ) + queued_event = _QueuedEvent(prepared, contextvars.copy_context(), defaults) try: running_loop = asyncio.get_running_loop() except RuntimeError: @@ -354,13 +434,13 @@ def _enqueue_prepared_event(self, prepared: dict[str, Any]) -> bool: if self._loop is None: if running_loop is not None: - self._ensure_workers_started() - self._queue.put_nowait(queued_event) + self._ensure_workers_started(lane) + lane.queue.put_nowait(queued_event) return True if running_loop is self._loop: - self._ensure_workers_started() - self._queue.put_nowait(queued_event) + self._ensure_workers_started(lane) + lane.queue.put_nowait(queued_event) return True if running_loop is not None: raise RuntimeError("AsyncClient cannot be shared across event loops") @@ -374,8 +454,8 @@ def enqueue_on_bound_loop() -> None: if not self._accepting or self._closed: admitted.set_result(False) return - self._ensure_workers_started() - self._queue.put_nowait(queued_event) + self._ensure_workers_started(lane) + lane.queue.put_nowait(queued_event) except BaseException as error: admitted.set_exception(error) else: @@ -385,26 +465,13 @@ def enqueue_on_bound_loop() -> None: return admitted.result() def _normalize_uuid(self, msg: dict[str, Any]) -> str: - raw_uuid = msg.pop("uuid", None) - if raw_uuid is not None: - try: - normalized = str(UUID(str(raw_uuid))) - except (TypeError, ValueError, AttributeError): - self.log.error( - "Invalid UUID %r. Falling back to a generated UUID.", raw_uuid - ) - else: - msg["uuid"] = normalized - return normalized - - normalized = str(uuid4()) + normalized = _resolve_event_uuid(msg.pop("uuid", None)) msg["uuid"] = normalized return normalized def _prepare_event( self, msg: dict[str, Any], - disable_geoip: Optional[bool], property_allowlist=None, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: if self.disabled or not self._accepting: @@ -417,8 +484,8 @@ def _prepare_event( msg["timestamp"] = _normalize_timestamp(timestamp) except ValueError: self.log.warning( - "Invalid timestamp %r. Falling back to the current UTC time.", - timestamp, + "Invalid timestamp; it must be a datetime or an ISO 8601 string. " + "Falling back to the current UTC time." ) msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() @@ -427,16 +494,7 @@ def _prepare_event( properties["$lib"] = "posthog-python" properties["$lib_version"] = VERSION - if disable_geoip is None: - disable_geoip = self.disable_geoip - if disable_geoip: - properties["$geoip_disable"] = True - if self.super_properties: - msg["properties"] = {**properties, **self.super_properties} - if self._release_id is not None: - msg["properties"].setdefault("$release_id", self._release_id) - if self.is_server: - msg["properties"]["$is_server"] = True + msg["options"] = msg.get("options") or {} if property_allowlist is not None: msg["properties"] = { key: value @@ -448,7 +506,20 @@ def _prepare_event( cleaned = clean(msg) return cleaned, sent_uuid - async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: + async def _process_event( + self, msg: dict[str, Any], defaults: Optional[_EventDefaults] + ) -> Optional[dict[str, Any]]: + _fill_event_defaults(msg, defaults) + processed = await self._run_before_send(msg) + if ( + processed is None + or _drop_invalid_event_name(self.log, processed) + or _drop_invalid_distinct_id(self.log, processed) + ): + return None + return processed + + async def _run_before_send(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: if self.before_send is None: return msg @@ -467,21 +538,41 @@ async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: self.log.error("Error in before_send callback (%s)", type(error).__name__) return None + def _event_defaults( + self, + context_properties: Optional[dict[str, Any]] = None, + context_options: Optional[dict[str, Any]] = None, + derived_options: Optional[dict[str, Any]] = None, + property_allowlist=None, + disable_geoip: Optional[bool] = None, + system_properties: Optional[dict[str, Any]] = None, + ) -> _EventDefaults: + return _build_event_defaults( + super_properties=self.super_properties, + super_options=self.super_options, + release_id=self._release_id, + context_properties=context_properties, + context_options=context_options, + derived_options=derived_options, + property_allowlist=property_allowlist, + is_server=self.is_server, + disable_geoip=self.disable_geoip, + call_disable_geoip=disable_geoip, + system_properties=system_properties, + ) + def _build_capture_event( self, event: str, kwargs: OptionalCaptureArgs - ) -> tuple[dict[str, Any], Optional[bool], Any]: - properties = {**(kwargs.get("properties") or {}), **system_context()} + ) -> tuple[dict[str, Any], Any, _EventDefaults]: + properties = dict(kwargs.get("properties") or {}) if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} - properties = _add_context_tags(properties) - assert properties is not None + properties = _add_context_session_id(properties) distinct_id, personless = _get_identity_state(kwargs.get("distinct_id")) - if personless and "$process_person_profile" not in properties: - properties["$process_person_profile"] = False groups = kwargs.get("groups") if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) flags_snapshot = kwargs.get("flags") send_feature_flags = kwargs.get("send_feature_flags") @@ -502,34 +593,61 @@ def _build_capture_event( "distinct_id": distinct_id, "event": event, "uuid": kwargs.get("uuid"), + "options": _event_options(kwargs.get("options")), }, - kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), + self._event_defaults( + context_properties=_context_tag_defaults(), + context_options=_get_context_options(), + derived_options=_personless_options(personless), + property_allowlist=kwargs.get("_property_allowlist"), + disable_geoip=kwargs.get("disable_geoip"), + system_properties=system_context(), + ), ) def capture( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: """Queue an event without blocking for network delivery.""" - try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs - ) - prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist + return self._capture(event, self._analytics_lane, kwargs) + + def capture_ai( + self, event: str, **kwargs: Unpack[OptionalCaptureArgs] + ) -> Optional[str]: + """Queue an AI event for the AI capture endpoint without blocking. + + Takes the same arguments and returns the same value as ``capture()``. + The event uses a separate queue with a larger per-event size cap. The + payload is sent as given, with no redaction or truncation. + """ + self._log_non_ai_event(event) + return self._capture(event, self._ai_lane, kwargs) + + def _log_non_ai_event(self, event: str) -> None: + if isinstance(event, str) and not event.startswith("$ai_"): + self.log.debug( + "capture_ai called with non-AI event name %r; routing it to the AI endpoint anyway.", + event, ) + + def _capture( + self, event: str, lane: _AsyncLane, kwargs: OptionalCaptureArgs + ) -> Optional[str]: + try: + msg, property_allowlist, defaults = self._build_capture_event(event, kwargs) + prepared, sent_uuid = self._prepare_event(msg, property_allowlist) if prepared is None or sent_uuid is None: return None if not self.send: return sent_uuid - self._validate_transport_available() - if not self._enqueue_prepared_event(prepared): + if not self._enqueue_prepared_event(prepared, lane, defaults): return None self.log.debug("queued async event %s", event) return sent_uuid except asyncio.QueueFull: - self.log.warning("PostHog async capture queue is full") + self.log.warning("PostHog async %s capture queue is full", lane.name) return None except Exception as error: if self.debug: @@ -551,6 +669,21 @@ async def capture_immediate( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: """Capture one event and wait until its delivery attempt completes.""" + return await self._capture_immediate(event, self._analytics_lane, kwargs) + + async def capture_ai_immediate( + self, event: str, **kwargs: Unpack[OptionalCaptureArgs] + ) -> Optional[str]: + """Capture one AI event and wait until its delivery attempt completes. + + Uses the AI capture endpoint and the ``capture_ai_*`` settings. + """ + self._log_non_ai_event(event) + return await self._capture_immediate(event, self._ai_lane, kwargs) + + async def _capture_immediate( + self, event: str, lane: _AsyncLane, kwargs: OptionalCaptureArgs + ) -> Optional[str]: current = asyncio.current_task() if current is None: # pragma: no cover - async functions always have a task return None @@ -560,15 +693,11 @@ async def capture_immediate( self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1 error_batch: list[dict[str, Any]] = [] try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs - ) - prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist - ) + msg, property_allowlist, defaults = self._build_capture_event(event, kwargs) + prepared, sent_uuid = self._prepare_event(msg, property_allowlist) if prepared is None or sent_uuid is None: return None - processed = await self._process_event(prepared) + processed = await self._process_event(prepared, defaults) if processed is None: return None error_batch = [processed] @@ -581,33 +710,25 @@ async def capture_immediate( "unable to serialize immediate event for sizing, dropping" ) return None - if event_size > MAX_MSG_SIZE: + if event_size > lane.max_msg_size: self.log.error( - "Event %s (%d bytes) exceeds the %dKiB limit, dropping.", + "Event %s (%d bytes) exceeds the %dKiB limit for %s, dropping.", processed.get("event"), event_size, - MAX_MSG_SIZE // 1024, + lane.max_msg_size // 1024, + lane.endpoint, ) return None - consumer = self._new_consumer() + consumer = self._new_consumer(lane) await consumer.request(error_batch) return sent_uuid except Exception as error: - if self.on_error: - try: - await _invoke_callback(self.on_error, error, error_batch) - except Exception as callback_error: - self.log.error( - "on_error handler failed (%s)", type(callback_error).__name__ - ) + await _report_capture_failure( + self.on_error, self.log, error, error_batch, lane.endpoint + ) if self.debug: raise - self.log.error( - "Immediate async capture failed (%s, status=%s)", - type(error).__name__, - getattr(error, "status", None), - ) return None finally: remaining_calls = self._immediate_callers[current] - 1 @@ -630,6 +751,7 @@ def _build_person_properties_event( property_key: properties, "event": event, "uuid": kwargs.get("uuid"), + "options": _event_options(kwargs.get("options")), } def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: @@ -637,7 +759,9 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: msg = self._build_person_properties_event("$set", "$set", kwargs) if msg is None: return None - return self._enqueue_built_event(msg, kwargs.get("disable_geoip")) + return self._enqueue_built_event( + msg, kwargs.get("disable_geoip"), _get_context_options() + ) except Exception as error: if self.debug: raise @@ -649,7 +773,9 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: msg = self._build_person_properties_event("$set_once", "$set_once", kwargs) if msg is None: return None - return self._enqueue_built_event(msg, kwargs.get("disable_geoip")) + return self._enqueue_built_event( + msg, kwargs.get("disable_geoip"), _get_context_options() + ) except Exception as error: if self.debug: raise @@ -665,6 +791,7 @@ def group_identify( uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: try: if not _stringify_id(group_type): @@ -678,7 +805,9 @@ def group_identify( ) return None - resolved_distinct_id = _get_identity_state(distinct_id)[0] + resolved_distinct_id = _group_identify_distinct_id( + distinct_id, group_type, group_key + ) msg: dict[str, Any] = { "event": "$groupidentify", "properties": { @@ -689,11 +818,16 @@ def group_identify( "distinct_id": resolved_distinct_id, "timestamp": timestamp, "uuid": uuid, + "options": _event_options(options), } - session_id = _get_context_session_id() - if session_id: - msg["properties"]["$session_id"] = str(session_id) - return self._enqueue_built_event(msg, disable_geoip) + # No context session: this event's distinct id is not the session's + # user, and a session takes the person of its latest event. + return self._enqueue_built_event( + msg, + disable_geoip, + _get_context_options(), + context_properties=_context_tag_defaults(), + ) except Exception as error: if self.debug: raise @@ -707,14 +841,17 @@ def alias( timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: try: resolved_previous_id = _stringify_id(previous_id) if not resolved_previous_id: self.log.warning("alias() called without a previous_id, dropping event") return None - resolved_distinct_id, personless = _get_identity_state(distinct_id) - if personless: + # The alias target always comes from the caller: a context id can + # come from a client-controlled request header. + resolved_distinct_id = _explicit_distinct_id(distinct_id) + if not resolved_distinct_id: self.log.warning("alias() called without a distinct_id, dropping event") return None msg: dict[str, Any] = { @@ -726,11 +863,17 @@ def alias( "event": "$create_alias", "distinct_id": resolved_previous_id, "uuid": uuid, + "options": _event_options(options), } session_id = _get_context_session_id() if session_id: msg["properties"]["$session_id"] = str(session_id) - return self._enqueue_built_event(msg, disable_geoip) + return self._enqueue_built_event( + msg, + disable_geoip, + _get_context_options(), + context_properties=_context_tag_defaults(), + ) except Exception as error: if self.debug: raise @@ -738,24 +881,39 @@ def alias( return None def _enqueue_built_event( - self, msg: dict[str, Any], disable_geoip: Optional[bool] + self, + msg: dict[str, Any], + disable_geoip: Optional[bool], + context_options: Optional[dict[str, Any]] = None, + context_properties: Optional[dict[str, Any]] = None, ) -> Optional[str]: - prepared, sent_uuid = self._prepare_event(msg, disable_geoip) + prepared, sent_uuid = self._prepare_event(msg) if prepared is None or sent_uuid is None: return None if not self.send: return sent_uuid - self._validate_transport_available() - if not self._enqueue_prepared_event(prepared): + defaults = self._event_defaults( + context_properties=context_properties, + context_options=context_options, + disable_geoip=disable_geoip, + ) + if not self._enqueue_prepared_event(prepared, self._analytics_lane, defaults): return None return sent_uuid def capture_exception( self, exception: Optional[ExceptionArg] = None, + *, + level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs], ) -> Optional[str]: - """Capture an exception. This method never raises, including in debug mode.""" + """Capture an exception. This method never raises, including in debug mode. + + ``level`` sets ``$exception_level`` and defaults to ``"error"``. An unknown + value counts as unset. Reserved exception properties in ``properties``, + such as ``$exception_list`` and ``$exception_level``, are ignored. + """ try: if exception is not None and exception_is_already_captured(exception): self.log.debug("Exception already captured, skipping") @@ -777,8 +935,9 @@ def capture_exception( ) exceptions = event["exception"]["values"] properties = { + **_without_reserved_exception_properties(kwargs.get("properties")), "$exception_list": exceptions, - **(kwargs.get("properties") or {}), + "$exception_level": _exception_level(None, level), } context_enabled = get_capture_exception_code_variables_context() context_mask = get_code_variables_mask_patterns_context() @@ -829,6 +988,7 @@ def capture_exception( groups=kwargs.get("groups"), flags=kwargs.get("flags"), disable_geoip=kwargs.get("disable_geoip"), + options=kwargs.get("options"), ) if exception is not None and result is not None: mark_exception_as_captured(exception, result) @@ -1072,40 +1232,43 @@ def _capture_feature_flag_called_if_needed( reported_flags.add(reported_key) def _pending_queue_items(self) -> int: - return int(getattr(self._queue, "_unfinished_tasks", self._queue.qsize())) + return sum(lane.pending_items() for lane in self._lanes) def _defer_lifecycle_call(self, awaitable) -> None: task = asyncio.create_task(_run_outside_processing_event(awaitable)) self._deferred_lifecycle_tasks.add(task) task.add_done_callback(self._deferred_lifecycle_tasks.discard) - def _discard_undrainable_queue(self) -> None: + def _discard_undrainable_queue(self, lane: _AsyncLane) -> None: discarded = 0 while True: try: - self._queue.get_nowait() + lane.queue.get_nowait() except asyncio.QueueEmpty: break - self._queue.task_done() + lane.queue.task_done() discarded += 1 - orphaned = self._pending_queue_items() + orphaned = lane.pending_items() for _ in range(orphaned): - self._queue.task_done() + lane.queue.task_done() discarded += orphaned if discarded: self.log.warning( - "discarded %d async capture items because all workers exited", + "discarded %d async %s capture items because all workers exited", discarded, + lane.name, ) - async def _wait_for_queue_drain(self, deadline: Optional[float]) -> None: - live_workers = [task for task in self._worker_tasks if not task.done()] + async def _wait_for_queue_drain( + self, lane: _AsyncLane, deadline: Optional[float] + ) -> None: + live_workers = [task for task in lane.worker_tasks if not task.done()] if not live_workers: - self._discard_undrainable_queue() + self._discard_undrainable_queue(lane) return - queue_join = asyncio.create_task(self._queue.join()) + queue_join = asyncio.create_task(lane.queue.join()) async def wait_for_workers() -> None: await asyncio.wait(live_workers, return_when=asyncio.ALL_COMPLETED) @@ -1128,7 +1291,7 @@ async def wait_for_workers() -> None: await queue_join return - self._discard_undrainable_queue() + self._discard_undrainable_queue(lane) await queue_join finally: for task in (queue_join, workers_finished): @@ -1137,22 +1300,28 @@ async def wait_for_workers() -> None: await asyncio.gather(queue_join, workers_finished, return_exceptions=True) async def flush(self, timeout_seconds: Optional[float] = 10) -> None: - if asyncio.current_task() in self._worker_tasks or _is_processing_event(): + if asyncio.current_task() in self._all_worker_tasks() or _is_processing_event(): self._defer_lifecycle_call(self.flush(timeout_seconds)) return if not self.send or self.disabled or self._pending_queue_items() == 0: return - self._ensure_workers_started() + pending_lanes = [lane for lane in self._lanes if lane.pending_items()] + for lane in pending_lanes: + self._ensure_workers_started(lane) deadline = ( None if timeout_seconds is None else asyncio.get_running_loop().time() + timeout_seconds ) try: - for consumer in self._consumers: - consumer.request_flush() - - await self._wait_for_queue_drain(deadline) + # Wake every lane before waiting on any, so one lane's partial + # batch does not wait out its flush_interval behind the other. + for lane in pending_lanes: + for consumer in lane.consumers: + consumer.request_flush() + + for lane in pending_lanes: + await self._wait_for_queue_drain(lane, deadline) except asyncio.TimeoutError: self.log.warning( "flush timed out after %s seconds with %s items pending", @@ -1169,7 +1338,7 @@ async def _close_transport(self) -> None: async def shutdown(self) -> None: current = asyncio.current_task() if ( - current in self._worker_tasks + current in self._all_worker_tasks() or current in self._immediate_callers or _is_processing_event() ): @@ -1199,17 +1368,20 @@ async def shutdown(self) -> None: errors.append(error) try: - live_workers = [task for task in self._worker_tasks if not task.done()] - for _ in live_workers: - await self._queue.put(_STOP) - if self._worker_tasks: - await asyncio.gather(*self._worker_tasks, return_exceptions=True) + for lane in self._lanes: + live_workers = [t for t in lane.worker_tasks if not t.done()] + for _ in live_workers: + await lane.queue.put(_STOP) + worker_tasks = self._all_worker_tasks() + if worker_tasks: + await asyncio.gather(*worker_tasks, return_exceptions=True) except Exception as error: self.log.exception("Failed to stop async capture workers") errors.append(error) finally: - self._worker_tasks.clear() - self._consumers.clear() + for lane in self._lanes: + lane.worker_tasks.clear() + lane.consumers.clear() try: await self._close_transport() diff --git a/posthog/capture_compression.py b/posthog/capture_compression.py index 659c12fab..257c80c88 100644 --- a/posthog/capture_compression.py +++ b/posthog/capture_compression.py @@ -19,10 +19,9 @@ class CaptureCompression(str, Enum): - """Selects the request-body compression for capture-v1 uploads. + """Selects the request-body compression for capture uploads. - Only honored when ``capture_mode`` is ``V1``; the legacy ``/batch/`` path - keeps using its own ``gzip`` flag. ``NONE`` sends the body uncompressed. + ``NONE`` sends the body uncompressed. ``GZIP`` and ``DEFLATE`` (zlib, RFC 1950) are both stdlib / zero-dependency; ``ZSTD`` is faster and compresses better but needs the optional zstandard package (``pip install posthog[zstd]``) until stdlib support lands in @@ -55,6 +54,7 @@ def _zstd_available() -> bool: def _coerce_explicit( value: Union[CaptureCompression, str], + name: str = "capture_compression", ) -> CaptureCompression: """Normalize an explicitly-supplied compression to a ``CaptureCompression``. @@ -69,22 +69,19 @@ def _coerce_explicit( if resolved is not None: return resolved raise ValueError( - f"invalid capture_compression {value!r}; expected a CaptureCompression " + f"invalid {name} {value!r}; expected a CaptureCompression " f"or one of {sorted(_ALIASES)}" ) def _resolve_capture_compression( capture_compression: Optional[Union[CaptureCompression, str]] = None, - *, - gzip_fallback: bool = False, ) -> CaptureCompression: - """Resolve the effective v1 compression. + """Resolve the effective capture compression. Precedence: explicit ``capture_compression`` argument > - ``POSTHOG_CAPTURE_COMPRESSION`` env var > the legacy ``gzip`` flag - (``GZIP`` when set) > ``NONE``. An unrecognized env value logs a warning and - falls back to the ``gzip`` flag, so a typo never silently changes encoding. + ``POSTHOG_CAPTURE_COMPRESSION`` env var > ``NONE``. An unrecognized env + value logs a warning and falls back to ``NONE``. ``ZSTD`` requires the optional zstandard package: explicitly requesting it without the package raises ``ValueError`` (programming error, fail loud), @@ -100,7 +97,7 @@ def _resolve_capture_compression( ) return resolved - fallback = CaptureCompression.GZIP if gzip_fallback else CaptureCompression.NONE + fallback = CaptureCompression.NONE raw = os.environ.get(CAPTURE_COMPRESSION_ENV_VAR) if raw is None or raw.strip() == "": @@ -126,3 +123,22 @@ def _resolve_capture_compression( ) return fallback return env_resolved + + +def _resolve_capture_ai_compression( + capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, +) -> CaptureCompression: + """Resolve the AI lane's request-body compression. + + Explicit argument only, defaulting to ``NONE``. ``POSTHOG_CAPTURE_COMPRESSION`` + does not apply, so changing analytics compression never changes AI uploads. + """ + if capture_ai_compression is None: + return CaptureCompression.NONE + resolved = _coerce_explicit(capture_ai_compression, "capture_ai_compression") + if resolved is CaptureCompression.ZSTD and not _zstd_available(): + raise ValueError( + "capture_ai_compression 'zstd' requires the zstandard package; " + "install posthog[zstd]" + ) + return resolved diff --git a/posthog/capture_event.py b/posthog/capture_event.py new file mode 100644 index 000000000..a5c37f284 --- /dev/null +++ b/posthog/capture_event.py @@ -0,0 +1,328 @@ +"""Event shaping for the capture v1 wire protocol. + +Transforms a legacy-shaped queued message into a v1 wire event and assembles +the batch envelope. :mod:`posthog.capture_send` posts the result. + +The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from +the legacy queued-message shape in a few load-bearing ways that this module +encodes: + +- An ``options`` object carries per-event processing options. Options the + caller sets are sent as given, for PostHog to validate. Four legacy ``$`` + properties fill the matching option when the caller left it unset, and are + always removed from ``properties``. +- ``$set``/``$set_once`` have no top-level form in v1; the server reads them + from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at + the top level, so they are relocated into ``properties`` here. +- ``$lib``/``$lib_version`` are injected server-side from the required + ``PostHog-Sdk-Info`` header and are stripped from v1 properties. +""" + +import logging +import re +from collections.abc import Collection, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional +from uuid import UUID + +from posthog.utils import _normalize_timestamp, _uuid7 +from posthog.utils import clean as _clean + +log = logging.getLogger("posthog") + +# Sentinel properties lifted to top-level string fields on the event. +_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( + ("$session_id", "session_id"), + ("$window_id", "window_id"), +) + +# Top-level legacy keys relocated into properties (v1 has no top-level form). +_RELOCATE_TO_PROPERTIES = ("$set", "$set_once") + +# Properties that defaults fill one level deep when both values are dicts. +_NESTED_FILL_PROPERTIES = frozenset({"$set", "$set_once", "$groups", "$group_set"}) + +# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). +_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") + +# Legacy properties and the option each one fills. The order matches posthog-rs +# and posthog-go. +_LEGACY_OPTION_PROPERTIES: tuple[tuple[str, str], ...] = ( + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$process_person_profile", "process_person_profile"), +) + +# The uuid forms Go's uuid.Validate accepts. Python's UUID() also accepts +# misplaced hyphens and a bare "uuid:" prefix, which other SDKs reject. +_EVENT_UUID_PATTERN = re.compile( + r"(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + r"|\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}" + r"|[0-9a-f]{32}", + re.IGNORECASE, +) + + +def _canonical_event_uuid(value: Any) -> Optional[str]: + """Return the canonical form of a caller's event uuid, or None if invalid. + + Capture keys per-event results by the canonical lowercase hyphenated form, + so a uuid sent in any other form would never match its result. + """ + if isinstance(value, UUID): + return str(value) + if not isinstance(value, str) or not _EVENT_UUID_PATTERN.fullmatch(value): + return None + return str(UUID(value.lower())) + + +def _resolve_event_uuid(value: Any) -> str: + """Return the canonical form of a caller's event uuid, or a generated one. + + A missing or empty uuid is generated silently. An invalid one is replaced + and logs one warning. The warning names the rule and not the value, + because callers can put their own data in the uuid field. + """ + if value is not None and value != "": + canonical = _canonical_event_uuid(value) + if canonical is not None: + return canonical + log.warning( + "Event uuid is not a valid UUID string or uuid.UUID. " + "Sending the event with a generated UUID." + ) + return str(_uuid7()) + + +def _json_type_name(value: Any) -> str: + """Name a value's JSON type, the same names posthog-go and posthog-rs log.""" + if isinstance(value, bool): + return "bool" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, (list, tuple)): + return "array" + if isinstance(value, Mapping): + return "object" + return type(value).__name__ + + +def _event_options(value: Any) -> dict[str, Any]: + """Return a copy of a caller's ``options``, or ``{}`` when it is not a dict.""" + if value is None: + return {} + if not isinstance(value, dict): + log.error( + "options must be a dict, got %s. Sending the event without them.", + type(value).__name__, + ) + return {} + return dict(value) + + +@dataclass(frozen=True) +class _EventDefaults: + """Per-call, context, global and SDK-derived values for one event, highest layer first. + + They fill in before ``before_send``, so the hook sees them and can change + or remove them. The event's own values win over every default. + """ + + property_layers: tuple[Mapping[str, Any], ...] = () + option_layers: tuple[Mapping[str, Any], ...] = () + property_allowlist: Optional[Collection[str]] = None + + +def _build_event_defaults( + *, + super_properties: Optional[Mapping[str, Any]], + super_options: Any, + release_id: Optional[str], + context_properties: Optional[Mapping[str, Any]] = None, + context_options: Optional[Mapping[str, Any]] = None, + derived_options: Optional[Mapping[str, Any]] = None, + property_allowlist: Optional[Collection[str]] = None, + is_server: bool = False, + disable_geoip: bool = False, + call_disable_geoip: Optional[bool] = None, + system_properties: Optional[Mapping[str, Any]] = None, +) -> _EventDefaults: + """Order the layers: per-call arguments, context, global, then SDK values. + + ``disable_geoip`` is the client setting, an SDK value. ``call_disable_geoip`` + is the argument of one call: it is a value of that event, so only the + event's own ``$geoip_disable`` property beats it. + """ + call_properties = ( + {} if call_disable_geoip is None else {"$geoip_disable": call_disable_geoip} + ) + # A value in the event, the context or super properties wins over every + # value the SDK adds, including `$is_server` and `$geoip_disable`. + sdk_properties = dict(system_properties or {}) + if release_id is not None: + sdk_properties["$release_id"] = release_id + if is_server: + sdk_properties["$is_server"] = True + if disable_geoip: + sdk_properties["$geoip_disable"] = True + return _EventDefaults( + property_layers=( + call_properties, + context_properties or {}, + super_properties or {}, + sdk_properties, + ), + option_layers=( + context_options or {}, + _event_options(super_options), + derived_options or {}, + ), + property_allowlist=property_allowlist, + ) + + +def _fill_event_defaults( + msg: dict[str, Any], defaults: Optional[_EventDefaults] +) -> None: + """Fill the keys an event left unset from ``defaults``, layer by layer. + + A property is unset only when its key is missing. An option is unset when + it is missing or ``None``, the same rule hoisting uses. ``$set``, + ``$set_once``, ``$groups`` and ``$group_set`` fill one level deep when the + event's value and the default are both dicts. + """ + if defaults is None: + return + allowlist = defaults.property_allowlist + properties = msg.get("properties") + if not isinstance(properties, dict): + properties = {} + msg["properties"] = properties + for property_layer in defaults.property_layers: + for key, value in property_layer.items(): + if allowlist is not None and key not in allowlist: + continue + if key not in properties: + properties[key] = _clean(value) + continue + existing = properties[key] + if ( + key in _NESTED_FILL_PROPERTIES + and isinstance(existing, dict) + and isinstance(value, Mapping) + ): + properties[key] = {**_clean(dict(value)), **existing} + options = _event_options(msg.get("options")) + for option_layer in defaults.option_layers: + for key, value in option_layer.items(): + if options.get(key) is None: + options[key] = _clean(value) + msg["options"] = options + + +def _merge_groups(properties: dict[str, Any], groups: Mapping[str, Any]) -> None: + """Merge typed ``groups`` into the ``$groups`` property; ``groups`` wins key by key.""" + existing = properties.get("$groups") + if isinstance(existing, dict): + properties["$groups"] = {**existing, **groups} + else: + properties["$groups"] = dict(groups) + + +def _v1_timestamp(timestamp: Any) -> str: + """Return a UTC RFC3339 timestamp string. + + Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` + normalizes canonical datetimes), so that is passed through. A ``datetime`` + is normalized to UTC and serialized; a missing value defaults to now in UTC. + The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and + rejects naive timestamps. + """ + if timestamp is None: + return datetime.now(timezone.utc).isoformat() + return _normalize_timestamp(timestamp) + + +def _to_v1_event(msg: dict) -> dict: + """Transform a legacy-shaped queued message into a v1 wire event. + + Pure: the input ``msg`` is not mutated (a fresh ``properties`` dict is + built), so it remains safe to keep the original for retries or callbacks. + """ + properties = dict(msg.get("properties") or {}) + + # Relocate top-level $set/$set_once into properties; v1 has no top-level + # form. The top-level value comes from the set() or set_once() call, so it + # wins key by key over a $set in properties, as ingestion merges them. + for key in _RELOCATE_TO_PROPERTIES: + top_val = msg.get(key) + if top_val is None: + continue + existing = properties.get(key) + if isinstance(top_val, dict) and isinstance(existing, dict): + properties[key] = {**existing, **top_val} + else: + properties[key] = top_val + + for key in _STRIP_FROM_PROPERTIES: + properties.pop(key, None) + + options = _event_options(msg.get("options")) + for prop_key, option_key in _LEGACY_OPTION_PROPERTIES: + if prop_key not in properties: + continue + legacy = properties.pop(prop_key) + # A null option counts as unset, so the legacy value fills it. + if options.get(option_key) is None: + options[option_key] = legacy + + top_level: dict[str, str] = {} + for prop_key, field_name in _TOPLEVEL_SENTINELS: + if prop_key not in properties: + continue + # Always removed. A non-string value would fail the whole batch, so it + # is dropped. None counts as unset and drops silently. + value = properties.pop(prop_key) + if isinstance(value, str): + top_level[field_name] = value + elif value is not None: + log.warning( + "dropping %s: a %s value is not a string", + prop_key, + _json_type_name(value), + ) + + event = { + "event": msg["event"], + "uuid": msg["uuid"], + "distinct_id": msg["distinct_id"], + "timestamp": _v1_timestamp(msg.get("timestamp")), + # Always a dict so it serializes as "{}" rather than null when empty. + "options": options, + "properties": properties, + } + event.update(top_level) + return event + + +def _build_v1_batch_body( + events: list[dict], + historical_migration: bool = False, + created_at: Optional[str] = None, +) -> dict: + """Assemble the v1 batch envelope. + + Carries no ``api_key`` (Bearer auth) and no ``sent_at``. + ``historical_migration`` is omitted when False (the server defaults it). + ``created_at`` defaults to now in UTC; :func:`_send_v1_batch` passes a value + hoisted once so it stays stable across retry attempts. + """ + body: dict[str, Any] = { + "created_at": created_at or datetime.now(timezone.utc).isoformat(), + "batch": events, + } + if historical_migration: + body["historical_migration"] = True + return body diff --git a/posthog/capture_mode.py b/posthog/capture_mode.py deleted file mode 100644 index 7ea3696f2..000000000 --- a/posthog/capture_mode.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import os -from enum import Enum -from typing import Optional, Union - -__all__ = ["CAPTURE_MODE_ENV_VAR", "CaptureMode"] - -log = logging.getLogger("posthog") - -CAPTURE_MODE_ENV_VAR = "POSTHOG_CAPTURE_MODE" - - -class CaptureMode(str, Enum): - """Selects the capture wire protocol used for event ingestion. - - ``V0`` is the legacy ``POST /batch/`` endpoint and the default, so upgrading - is transparent to existing callers. ``V1`` opts into - ``POST /i/v1/analytics/events`` (Bearer auth, per-event results, partial - retry). Inheriting from ``str`` keeps the members directly comparable to and - serializable as their ``"v0"`` / ``"v1"`` values. - """ - - V0 = "v0" - V1 = "v1" - - -# Accepted spellings for both the explicit kwarg and the env var. Aliases mirror -# the posthog-go naming (``legacy`` / ``analytics_v1``) so the two SDKs are -# configured with the same vocabulary. -_ALIASES: dict[str, CaptureMode] = { - "v0": CaptureMode.V0, - "legacy": CaptureMode.V0, - "v1": CaptureMode.V1, - "analytics_v1": CaptureMode.V1, -} - - -def _coerce_explicit(value: Union[CaptureMode, str]) -> CaptureMode: - """Normalize an explicitly-supplied capture mode to a ``CaptureMode``. - - Accepts a ``CaptureMode`` or one of the string aliases. An explicit but - unrecognized value is a programming error, so it raises ``ValueError`` rather - than silently defaulting (unlike the env var, which is operator-supplied and - defaults defensively). - """ - if isinstance(value, CaptureMode): - return value - if isinstance(value, str): - resolved = _ALIASES.get(value.strip().lower()) - if resolved is not None: - return resolved - raise ValueError( - f"invalid capture_mode {value!r}; expected a CaptureMode or one of " - f"{sorted(_ALIASES)}" - ) - - -def _resolve_capture_mode( - capture_mode: Optional[Union[CaptureMode, str]] = None, -) -> CaptureMode: - """Resolve the effective capture mode. - - Precedence: explicit ``capture_mode`` argument > ``POSTHOG_CAPTURE_MODE`` env - var > ``CaptureMode.V0``. An unrecognized env value logs a warning and falls - back to ``V0`` so a typo never silently flips the wire protocol. - """ - if capture_mode is not None: - return _coerce_explicit(capture_mode) - - raw = os.environ.get(CAPTURE_MODE_ENV_VAR) - if raw is None or raw.strip() == "": - return CaptureMode.V0 - - resolved = _ALIASES.get(raw.strip().lower()) - if resolved is None: - log.warning( - "Unrecognized %s=%r; falling back to %s. Expected one of %s.", - CAPTURE_MODE_ENV_VAR, - raw, - CaptureMode.V0.value, - sorted(_ALIASES), - ) - return CaptureMode.V0 - return resolved diff --git a/posthog/capture_v1.py b/posthog/capture_send.py similarity index 58% rename from posthog/capture_v1.py rename to posthog/capture_send.py index 478175484..9310df46f 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_send.py @@ -1,22 +1,9 @@ -"""Serialization and transport for the Capture V1 wire protocol. - -This module owns everything specific to ``POST /i/v1/analytics/events``: the -*transform* layer (legacy-shaped queued message -> v1 wire event + batch -envelope) and the *transport* layer (a single HTTP attempt, response parsing, -and the partial-retry send loop). - -The v1 contract (see ``rust/capture/src/v1/analytics/types.rs``) differs from -the legacy ``/batch/`` shape in a few load-bearing ways that this module -encodes: - -- A typed ``options`` object carries a handful of sentinel properties, renamed - and strictly typed. Wrong JSON types fail deserialization of the *whole - batch*, so values are coerced to native types or omitted entirely. -- ``$set``/``$set_once`` have no top-level form in v1; the server reads them - from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at - the top level, so they are relocated into ``properties`` here. -- ``$lib``/``$lib_version`` are injected server-side from the required - ``PostHog-Sdk-Info`` header and are stripped from v1 properties. +"""Transport for the capture v1 wire protocol. + +This module sends batches to the capture v1 endpoints +(``POST /i/v1/analytics/events`` and ``POST /i/v1/ai/events``, which share one +wire contract): a single HTTP attempt, response parsing, and the partial-retry +send loop. :mod:`posthog.capture_event` builds the events and batch envelope. The response is per-event: a 200 carries a ``results`` map keyed by event uuid, each tagged ``ok``/``warning`` (terminal-success), ``drop`` (terminal-failure), @@ -24,7 +11,7 @@ attempt, holding the ``PostHog-Request-Id`` and batch ``created_at`` stable across attempts while incrementing ``PostHog-Attempt``. ``ok``/``warning``/absent events succeed; ``drop`` and retry-exhaustion are carried on the -:class:`CaptureV1Error` raised on batch-level/terminal failure, so the consumer's +:class:`CaptureError` raised on batch-level/terminal failure, so the consumer's existing ``on_error(exc, batch)`` path surfaces them unchanged (no per-event logging of its own). @@ -34,18 +21,23 @@ import json import logging +import random import time import zlib -from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from email.utils import parsedate_to_datetime from gzip import GzipFile from io import BytesIO -from typing import TYPE_CHECKING, Any, Optional -from uuid import uuid4 +from typing import TYPE_CHECKING, Optional +from urllib.parse import urljoin, urlsplit +from uuid import UUID + +from requests.exceptions import RequestException +from requests.models import PreparedRequest from posthog.capture_compression import CaptureCompression, _zstandard +from posthog.capture_event import _build_v1_batch_body, _to_v1_event from posthog.request import ( DatetimeSerializer, USER_AGENT, @@ -53,19 +45,20 @@ _get_session, normalize_host, ) -from posthog.utils import _normalize_timestamp, remove_trailing_slash +from posthog.utils import _uuid7, remove_trailing_slash if TYPE_CHECKING: import requests log = logging.getLogger("posthog") -# Only the error type is public API: it reaches user code through `on_error` -# callbacks, so callers may want to catch/inspect it. Everything else is +# Only the error types are public API: they reach user code through `on_error` +# callbacks, so callers may want to catch/inspect them. Everything else is # submitter plumbing. -__all__ = ["CaptureV1Error"] +__all__ = ["CaptureError", "CaptureEventResult"] _CAPTURE_V1_PATH = "/i/v1/analytics/events" +_CAPTURE_AI_V1_PATH = "/i/v1/ai/events" # Required request/response headers for the v1 endpoint. Defined here as the # single source of truth; the transport layer builds requests from them. @@ -84,6 +77,9 @@ # HTTP status classification. 429 is terminal in v1 (unlike v0, where it is # retried) — the backend signals overload via retryable 5xx + Retry-After. _RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504}) +# Only these keep the method and body, so a resent batch is unchanged. +_REDIRECT_STATUSES = frozenset({307, 308}) +_MAX_REDIRECTS = 5 _TERMINAL_STATUSES = frozenset({400, 401, 402, 413, 415, 429}) # Single ceiling (seconds) for the retry backoff: caps the exponential schedule @@ -92,152 +88,23 @@ # unifies the default with posthog-go/posthog-rs (all 30s). _MAX_BACKOFF_SECONDS = 30 -# Sentinel properties lifted to top-level string fields on the event. -_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = ( - ("$session_id", "session_id"), - ("$window_id", "window_id"), -) - -# Top-level legacy keys relocated into properties (v1 has no top-level form). -_RELOCATE_TO_PROPERTIES = ("$set", "$set_once") - -# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info). -_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version") +# First retry delay; it doubles per attempt up to `_MAX_BACKOFF_SECONDS`. Matches +# posthog-go. +_BACKOFF_BASE_SECONDS = 0.1 - -def _coerce_bool(value: Any) -> Optional[bool]: - """Coerce a sentinel value to ``bool`` using the backend's truthiness rules. - - Native bool passes through; ``"true"``/``"1"`` and ``"false"``/``"0"`` - (case-insensitive, trimmed) map to the obvious bool; any other numeric value - is nonzero-truthy. Anything else returns ``None`` so the option is omitted - rather than sent with a type the strict v1 schema would reject. - """ - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in ("true", "1"): - return True - if normalized in ("false", "0"): - return False - return None - if isinstance(value, (int, float)): - return value != 0 - return None - - -def _coerce_str(value: Any) -> Optional[str]: - """Accept only ``str`` (the backend's ``product_tour_id`` is ``Option``).""" - return value if isinstance(value, str) else None - - -# Sentinel properties lifted into the typed `options` object: legacy property -# key, the backend's field name, and the coercer enforcing its strict type -# (wrong JSON types fail deserialization of the whole batch, so a value that -# won't coerce is omitted). The coercer is stored directly to keep the dispatch -# type-checked rather than keyed by a stringly-typed name. -_OPTION_SENTINELS: tuple[tuple[str, str, Callable[[Any], Any]], ...] = ( - ("$cookieless_mode", "cookieless_mode", _coerce_bool), - ("$ignore_sent_at", "disable_skew_correction", _coerce_bool), - ("$product_tour_id", "product_tour_id", _coerce_str), - ("$process_person_profile", "process_person_profile", _coerce_bool), -) +# Spread each backoff by up to a quarter, so clients refused together do not +# return together. +_BACKOFF_JITTER = 0.25 -def _v1_timestamp(timestamp: Any) -> str: - """Return a UTC RFC3339 timestamp string. +@dataclass(frozen=True) +class CaptureEventResult: + """One event's verdict from a 2xx capture response. - Messages off the queue already carry a UTC ISO-8601 string (``_enqueue`` - normalizes canonical datetimes), so that is passed through. A ``datetime`` - is normalized to UTC and serialized; a missing value defaults to now in UTC. - The v1 server parses strictly with ``DateTime::parse_from_rfc3339`` and - rejects naive timestamps. + ``result`` is ``ok``, ``warning``, ``drop`` or ``retry``, or a value newer + than this SDK, which counts as success. ``details`` is the server's reason + tag, such as ``llm_events_over_quota``. """ - if timestamp is None: - return datetime.now(timezone.utc).isoformat() - return _normalize_timestamp(timestamp) - - -def _to_v1_event(msg: dict) -> dict: - """Transform a legacy-shaped queued message into a v1 wire event. - - Pure: the input ``msg`` is not mutated (a fresh ``properties`` dict is - built), so it remains safe to keep the original for retries or callbacks. - """ - properties = dict(msg.get("properties") or {}) - - # Relocate top-level $set/$set_once into properties; v1 has no top-level - # form. On the unusual collision where properties already carries the key, - # the properties value wins. - for key in _RELOCATE_TO_PROPERTIES: - top_val = msg.get(key) - if top_val is None: - continue - existing = properties.get(key) - if isinstance(top_val, dict) and isinstance(existing, dict): - properties[key] = {**top_val, **existing} - elif key not in properties: - properties[key] = top_val - - for key in _STRIP_FROM_PROPERTIES: - properties.pop(key, None) - - options: dict[str, Any] = {} - for prop_key, wire_key, coercer in _OPTION_SENTINELS: - if prop_key not in properties: - continue - # Always removed from properties — these sentinels must never reach v1 - # backend properties — but only emitted as an option when coercible. - coerced = coercer(properties.pop(prop_key)) - if coerced is not None: - options[wire_key] = coerced - - top_level: dict[str, str] = {} - for prop_key, field_name in _TOPLEVEL_SENTINELS: - if prop_key not in properties: - continue - coerced_str = _coerce_str(properties.pop(prop_key)) - if coerced_str is not None: - top_level[field_name] = coerced_str - - event = { - "event": msg["event"], - "uuid": msg["uuid"], - "distinct_id": msg["distinct_id"], - "timestamp": _v1_timestamp(msg.get("timestamp")), - # Always a dict so it serializes as "{}" rather than null when empty. - "options": options, - "properties": properties, - } - event.update(top_level) - return event - - -def _build_v1_batch_body( - events: list[dict], - historical_migration: bool = False, - created_at: Optional[str] = None, -) -> dict: - """Assemble the v1 batch envelope. - - Carries no ``api_key`` (Bearer auth) and no ``sent_at``. - ``historical_migration`` is omitted when False (the server defaults it). - ``created_at`` defaults to now in UTC; :func:`_send_v1_batch` passes a value - hoisted once so it stays stable across retry attempts. - """ - body: dict[str, Any] = { - "created_at": created_at or datetime.now(timezone.utc).isoformat(), - "batch": events, - } - if historical_migration: - body["historical_migration"] = True - return body - - -@dataclass -class _V1EventResult: - """A single event's directive from a 2xx ``results`` map.""" result: Optional[str] details: Optional[str] = None @@ -256,17 +123,22 @@ class _V1ParsedResponse: status_code: int is_success: bool retry_after: Optional[float] = None - results: Optional[dict[str, _V1EventResult]] = None + results: Optional[dict[str, CaptureEventResult]] = None malformed: bool = False error_message: str = "" -class CaptureV1Error(APIError): - """Batch-level failure of a capture-v1 send. +class CaptureError(APIError): + """A capture batch that was not fully delivered. - Subclasses :class:`APIError` so the consumer's existing ``on_error`` handling - (which already inspects ``status``/``retry_after``) keeps working; the extra - fields carry v1 specifics for richer logging/callbacks. + Passed to ``on_error`` with the batch. ``status`` is the HTTP status of the + last attempt, or ``0`` when no response arrived (``__cause__`` holds the + transport error). A 2xx ``status`` means the request succeeded but some + events were dropped or ran out of retries. + + ``event_results`` maps each event uuid to its last verdict from a 2xx + response. Events missing from it never got a verdict, because every + attempt that reached them failed as a whole request. """ def __init__( @@ -274,25 +146,101 @@ def __init__( status: int | str, message: str, *, + endpoint: str, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None, + event_results: Optional[dict[str, CaptureEventResult]] = None, ): super().__init__(status, message, retry_after=retry_after) + # Capture path the batch was sent to, so one on_error handler can tell + # the analytics and AI lanes apart. + self.endpoint = endpoint self.request_id = request_id self.attempts = attempts # uuids the server told us to retry but we never delivered (exhausted). self.retry_exhausted = retry_exhausted or [] # (uuid, details) pairs the server told us to drop on a 2xx response. self.drops = drops or [] + self.event_results = event_results or {} + + def verdict_summary(self) -> str: + """Count the undelivered events by verdict and reason, for example + ``drop/llm_events_over_quota=2, retry/not_persisted=1``. + + Counts the events in ``drops`` and ``retry_exhausted``: events the + server dropped, and events still pending retry after the last 2xx + response. A retry verdict followed by a failed request, for example a + 503 on the last attempt, is not counted; ``event_results`` keeps it. + Empty when no event is counted. + """ + counts: dict[str, int] = {} + failed = [uid for uid, _ in self.drops] + self.retry_exhausted + for uid in failed: + verdict = self.event_results.get(uid) + if verdict is None or not verdict.result: + continue + tag = verdict.result + if verdict.details: + tag = f"{tag}/{verdict.details}" + counts[tag] = counts.get(tag, 0) + 1 + return ", ".join(f"{tag}={n}" for tag, n in sorted(counts.items())) def _is_success_status(status: int) -> bool: return 200 <= status < 300 +def _canonical_uuid(value: str) -> str: + """Return ``value`` in the lowercase hyphenated form capture keys results by. + + ``before_send`` can set any uuid form capture parses. A value that does not + parse is returned unchanged; capture rejects its whole batch anyway. + """ + try: + return str(UUID(value)) + except (TypeError, ValueError, AttributeError): + return value + + +def _undelivered_count(error: Exception, batch_size: int) -> int: + if not isinstance(error, CaptureError): + return batch_size + delivered = sum( + 1 + for r in error.event_results.values() + if r.result not in (_RESULT_DROP, _RESULT_RETRY) + ) + return max(0, batch_size - delivered) + + +def _capture_loss_message(error: Exception, batch_size: int, endpoint: str) -> str: + """One aggregate line for a failed batch, for callers with no ``on_error``. + + It never names individual events, includes payloads, or repeats the server's + error text: any of them may carry sensitive content, and per-event lines + scale with event volume. + """ + count = _undelivered_count(error, batch_size) + if isinstance(error, CaptureError): + endpoint = error.endpoint + if isinstance(error.status, int) and _is_success_status(error.status): + detail = f"{len(error.drops)} dropped, {len(error.retry_exhausted)} out of retries" + summary = error.verdict_summary() + if summary: + detail = f"{detail} ({summary})" + return f"{count} event(s) not persisted by {endpoint}: {detail}" + detail = type(error).__name__ + if error.__cause__ is not None: + detail = f"{detail} from {type(error.__cause__).__name__}" + status = getattr(error, "status", None) + if status is not None: + detail = f"{detail} (status={status})" + return f"{count} event(s) not persisted by {endpoint}: {detail}" + + def _parse_retry_after(header_value: Optional[str]) -> Optional[float]: """Parse a ``Retry-After`` header (delta-seconds or HTTP-date) to seconds.""" if not header_value: @@ -339,6 +287,37 @@ def _compress_v1( return data, None +def _origin(url: str) -> Optional[tuple[str, str, int]]: + # Prepare the URL as requests sends it, so a Unicode host matches its IDNA form. + prepared = PreparedRequest() + try: + prepared.prepare_url(url, None) + parsed = urlsplit(prepared.url or "") + port = parsed.port + except (RequestException, UnicodeError, ValueError): + return None + if port is None: + port = 443 if parsed.scheme.lower() == "https" else 80 + return parsed.scheme.lower(), (parsed.hostname or "").lower(), port + + +def _same_origin_redirect_url( + base_url: str, current_url: str, location: Optional[str] +) -> Optional[str]: + """Return the redirect target on ``base_url``'s origin, or ``None``.""" + if not location: + return None + target = urlsplit(urljoin(current_url, location)) + base_origin = _origin(base_url) + if base_origin is None or _origin(target.geturl()) != base_origin: + return None + return ( + urlsplit(base_url) + ._replace(path=target.path or "/", query=target.query, fragment="") + .geturl() + ) + + def _post_v1( api_key: str, host: Optional[str], @@ -347,11 +326,12 @@ def _post_v1( attempt: int, request_id: str, compression: CaptureCompression = CaptureCompression.NONE, - timeout: int = 15, + timeout: float = 15, sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, + path: str = _CAPTURE_V1_PATH, ) -> "requests.Response": - """Perform a single ``POST /i/v1/analytics/events`` attempt. + """Perform a single capture v1 ``POST`` to ``path``. Bearer-authed (no ``api_key`` in the body) with the required v1 headers. ``attempt`` (1-based) and the stable ``request_id`` are echoed via @@ -359,10 +339,17 @@ def _post_v1( retries. The body is compressed per ``compression`` (advertised via ``Content-Encoding``). Returns the raw response; classification is left to the caller. + + Follows only 307/308 redirects to the origin of ``host``, at most + :data:`_MAX_REDIRECTS` times, resending the same body and headers. Any other + redirect comes back as the response, which the caller treats as a terminal + failure, so a batch never goes to another origin. """ trimmed_host = remove_trailing_slash(normalize_host(host)) - url = trimmed_host + _CAPTURE_V1_PATH - data = json.dumps(batch_body, cls=DatetimeSerializer) + url = trimmed_host + path + # Every event was serialized on its own with allow_nan=False before it + # joined the batch, so this never raises for an event the SDK accepted. + data = json.dumps(batch_body, cls=DatetimeSerializer, allow_nan=False) headers = { "Content-Type": "application/json", "User-Agent": sdk_info, @@ -377,9 +364,25 @@ def _post_v1( headers["Content-Encoding"] = encoding log.debug("capture v1 POST %s attempt=%s request_id=%s", url, attempt, request_id) - return (session or _get_session()).post( - url, data=body, headers=headers, timeout=timeout - ) + http = session or _get_session() + for _ in range(_MAX_REDIRECTS + 1): + res = http.post( + url, data=body, headers=headers, timeout=timeout, allow_redirects=False + ) + if res.status_code not in _REDIRECT_STATUSES: + return res + target = _same_origin_redirect_url( + trimmed_host, url, res.headers.get("Location") + ) + if target is None: + log.debug( + "capture v1 did not follow a %s redirect to another origin", + res.status_code, + ) + return res + url = target + log.debug("capture v1 stopped after %d redirects", _MAX_REDIRECTS) + return res def _parse_v1_response(res: "requests.Response") -> _V1ParsedResponse: @@ -392,7 +395,7 @@ def _parse_v1_response(res: "requests.Response") -> _V1ParsedResponse: payload = res.json() raw_results = payload["results"] results = { - uid: _V1EventResult( + uid: CaptureEventResult( result=(r or {}).get("result"), details=(r or {}).get("details"), ) @@ -421,18 +424,25 @@ def _parse_v1_response(res: "requests.Response") -> _V1ParsedResponse: return _V1ParsedResponse(status, False, retry_after, error_message=message) +def _draw_jitter() -> float: + return 1 - _BACKOFF_JITTER + random.random() * _BACKOFF_JITTER * 2 + + def _backoff(attempt_index: int, retry_after: Optional[float]) -> None: """Sleep before the next attempt. - Exponential backoff capped at :data:`_MAX_BACKOFF_SECONDS` is the base. When - the server sent a ``Retry-After`` it acts as a *minimum*, not a replacement: + Exponential backoff, spread by :data:`_BACKOFF_JITTER` and then capped at + :data:`_MAX_BACKOFF_SECONDS`, is the base. When the server sent a + ``Retry-After`` it acts as a *minimum*, not a replacement: the client waits the longer of the configured backoff and ``Retry-After``, so a small ``Retry-After`` never retries earlier than the normal schedule (matching posthog-go / posthog-rs). ``Retry-After`` is itself clamped to :data:`_MAX_BACKOFF_SECONDS`, so both sides share one ceiling and a hostile/buggy header can't park the consumer thread. """ - configured = min(2**attempt_index, _MAX_BACKOFF_SECONDS) + configured = min( + _BACKOFF_BASE_SECONDS * 2**attempt_index * _draw_jitter(), _MAX_BACKOFF_SECONDS + ) clamped_retry_after = ( min(retry_after, _MAX_BACKOFF_SECONDS) if retry_after and retry_after > 0 else 0 ) @@ -440,7 +450,7 @@ def _backoff(attempt_index: int, retry_after: Optional[float]) -> None: def _log_result_summary( - request_id: str, attempt: int, results: dict[str, _V1EventResult] + request_id: str, attempt: int, results: dict[str, CaptureEventResult] ) -> None: tally = {_RESULT_OK: 0, _RESULT_WARNING: 0, _RESULT_DROP: 0, _RESULT_RETRY: 0} other = 0 @@ -468,44 +478,68 @@ def _send_v1_batch( batch: list[dict], *, compression: CaptureCompression = CaptureCompression.NONE, - timeout: int = 15, + timeout: float = 15, max_retries: int = 3, historical_migration: bool = False, sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, + path: str = _CAPTURE_V1_PATH, ) -> None: - """Deliver ``batch`` to the v1 endpoint with partial retry. + """Deliver ``batch`` to the v1 endpoint at ``path`` with partial retry. - The v1 sibling of ``Consumer._send``: it loops up to ``max_retries + 1`` - attempts, but unlike v0 it shrinks the batch to only the events the server - tagged ``retry`` after each 2xx. ``ok``/``warning``/absent events succeed. + Loops up to ``max_retries + 1`` attempts. After each 2xx it resends only + the events the server tagged ``retry``. ``ok``/``warning`` events, events + with an unrecognized verdict, and events absent from ``results`` succeed + (matching posthog-go and posthog-rs). Results are matched by canonical + uuid, because capture keys them that way whatever form was sent. A server-chosen ``drop`` is a terminal per-event rejection. Drops are - accumulated across attempts and surfaced via :class:`CaptureV1Error` even + accumulated across attempts and surfaced via :class:`CaptureError` even when the request itself was a 2xx (a success status is not full delivery) and even when a later attempt clears the outstanding retries — matching posthog-go (per-event failure callback) and posthog-rs (``on_error`` on a - 2xx with undelivered verdicts). Raises :class:`CaptureV1Error` on any drop, - batch-level terminal failure, or retry exhaustion — carrying the accumulated - ``drops`` and any exhausted uuids — so the caller's ``on_error`` fires - unchanged. A transport failure re-raises the underlying exception (drops - collected on an earlier attempt are still tallied in the DEBUG summary). + 2xx with undelivered verdicts). Raises :class:`CaptureError` on any drop, + batch-level terminal failure, retry exhaustion, or transport failure + (``status`` 0, chained to the transport error), carrying the endpoint, the + accumulated ``drops``, any exhausted uuids, and every verdict seen so far, + so the caller's ``on_error`` gets the full picture. ``request_id`` and the batch ``created_at`` are stable across attempts; ``PostHog-Attempt`` increments. Negative ``max_retries`` values are treated as zero, so delivery is always attempted at least once. """ max_retries = max(0, max_retries) - request_id = str(uuid4()) + request_id = str(_uuid7()) # Hoisted once so the batch envelope is byte-identical across retry attempts # (only the events list shrinks and the attempt header increments). created_at = datetime.now(timezone.utc).isoformat() pending_events = [_to_v1_event(m) for m in batch] - pending_uuids = [e["uuid"] for e in pending_events] - last_exc: Optional[Exception] = None + pending_uuids = [_canonical_uuid(e["uuid"]) for e in pending_events] # (uuid, details) for every event the server dropped, across all attempts. # Accumulated (not per-attempt) so a drop seen early is not lost when a # later attempt succeeds or clears the outstanding retries. all_drops: list[tuple[str, Optional[str]]] = [] + # Latest 2xx verdict per uuid, across all attempts. + event_results: dict[str, CaptureEventResult] = {} + + def capture_error( + status: int, + message: str, + *, + attempts: int, + retry_after: Optional[float] = None, + retry_exhausted: Optional[list[str]] = None, + ) -> CaptureError: + return CaptureError( + status, + message, + endpoint=path, + retry_after=retry_after, + request_id=request_id, + attempts=attempts, + retry_exhausted=retry_exhausted, + drops=all_drops, + event_results=event_results, + ) for attempt_index in range(max_retries + 1): attempt = attempt_index + 1 @@ -525,12 +559,14 @@ def _send_v1_batch( timeout=timeout, sdk_info=sdk_info, session=session, + path=path, ) except Exception as e: - # Transport-level failure (connection/timeout): retry like v0 does. - last_exc = e + # Transport-level failure (connection/timeout): retryable. if last_attempt: - raise + raise capture_error( + 0, f"{type(e).__name__}: {e}", attempts=attempt + ) from e _backoff(attempt_index, None) continue @@ -538,12 +574,10 @@ def _send_v1_batch( if parsed.is_success: if parsed.malformed: - raise CaptureV1Error( + raise capture_error( parsed.status_code, "capture v1 returned a success status with an unparseable body", - request_id=request_id, attempts=attempt, - drops=all_drops, ) results = parsed.results or {} _log_result_summary(request_id, attempt, results) @@ -553,8 +587,11 @@ def _send_v1_batch( for event, uid in zip(pending_events, pending_uuids): directive = results.get(uid) if directive is None: - # Absent from the map: treated as accepted (matches posthog-rs). + # Absent from the map: treated as accepted (matches + # posthog-go and posthog-rs). Capture answers every event + # of a 2xx, so this only happens if it breaks that contract. continue + event_results[uid] = directive if directive.result == _RESULT_RETRY: retry_events.append(event) retry_uuids.append(uid) @@ -569,22 +606,18 @@ def _send_v1_batch( # surface them via on_error even though the request was a 2xx — # a success status does not mean every event was delivered. if all_drops: - raise CaptureV1Error( + raise capture_error( parsed.status_code, f"{len(all_drops)} event(s) dropped by the server", - request_id=request_id, attempts=attempt, - drops=all_drops, ) return if last_attempt: - raise CaptureV1Error( + raise capture_error( parsed.status_code, f"{len(retry_uuids)} event(s) still pending retry after {attempt} attempt(s)", - request_id=request_id, attempts=attempt, retry_exhausted=retry_uuids, - drops=all_drops, ) pending_events, pending_uuids = retry_events, retry_uuids _backoff(attempt_index, parsed.retry_after) @@ -593,22 +626,13 @@ def _send_v1_batch( # Non-2xx. Retryable transient statuses back off; everything else # (400/401/402/413/415/429/...) is terminal. Any drops collected from a # prior 2xx attempt ride along so on_error still sees them. - v1_error = CaptureV1Error( + v1_error = capture_error( parsed.status_code, parsed.error_message, retry_after=parsed.retry_after, - request_id=request_id, attempts=attempt, - drops=all_drops, ) - if parsed.status_code in _RETRYABLE_STATUSES: - last_exc = v1_error - if last_attempt: - raise v1_error + if parsed.status_code in _RETRYABLE_STATUSES and not last_attempt: _backoff(attempt_index, parsed.retry_after) continue raise v1_error - - # Unreachable in practice (every branch returns or continues). - if last_exc: - raise last_exc diff --git a/posthog/client.py b/posthog/client.py index 2bb16c492..34db581d9 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2,7 +2,9 @@ import hashlib as _hashlib import inspect import json +from contextlib import contextmanager import logging +import math import os import sys import threading @@ -29,10 +31,22 @@ from posthog.tracing._span import inert_span as _inert_span from posthog.capture_compression import ( CaptureCompression, + _resolve_capture_ai_compression, _resolve_capture_compression, ) -from posthog.capture_mode import CaptureMode, _resolve_capture_mode -from posthog.capture_v1 import _send_v1_batch +from posthog.capture_event import ( + _build_event_defaults, + _event_options, + _fill_event_defaults, + _merge_groups, + _resolve_event_uuid, +) +from posthog.capture_send import ( + _CAPTURE_AI_V1_PATH, + _CAPTURE_V1_PATH, + _capture_loss_message, + _send_v1_batch, +) from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE, Consumer, _DrainSignal from posthog.contexts import ( _get_current_context, @@ -44,11 +58,13 @@ get_context_device_id, get_context_distinct_id, get_context_session_id, + get_context_options as _context_get_context_options, get_tags as _context_get_tags, identify_context as _context_identify_context, _scoped as _context_scoped, new_context, set_context_device_id as _context_set_context_device_id, + set_context_option as _context_set_context_option, set_context_session as _context_set_context_session, tag as _context_tag, ) @@ -62,9 +78,11 @@ exc_info_from_error, exception_is_already_captured, exceptions_from_error_tuple, + _exception_level, _get_current_otel_span_properties, handle_in_app, mark_exception_as_captured, + _without_reserved_exception_properties, try_attach_code_variables_to_frames, ) from posthog.feature_flag_evaluations import ( @@ -85,16 +103,11 @@ from posthog.poller import Poller from posthog.release_id import _resolve_release_id from posthog.request import ( - AI_EVENTS_ENDPOINT, - EVENTS_ENDPOINT, - USER_AGENT as _USER_AGENT, APIError, + DatetimeSerializer as _DatetimeSerializer, QuotaLimitError, RequestsConnectionError, RequestsTimeout, - _get as _get_with_identity, - _remote_config as _remote_config_with_identity, - batch_post, determine_server_host, flags, get, @@ -119,11 +132,15 @@ to_values, ) from posthog.utils import ( + _uuid7, FlagCache, RedisFlagCache, SizeLimitedDict, clean, _normalize_timestamp, + _is_personal_api_key, + _drop_invalid_distinct_id, + _drop_invalid_event_name, guess_timezone as guess_timezone, system_context, ) @@ -141,6 +158,9 @@ # overrun it by up to the client's request timeout. _TRACES_SHUTDOWN_FLUSH_SECONDS = 30.0 _atexit_deadline: Optional[float] = None +# Marks a thread that is running a sync-mode `on_error`, so a capture that fails +# inside the callback logs instead of re-entering it. +_on_error_state = threading.local() _atexit_deadline_lock = threading.Lock() @@ -222,37 +242,64 @@ def _get_atexit_deadline() -> float: return _atexit_deadline +def _positive_config_value( + name: str, value, *, integer: bool = False, maximum: Optional[int] = None +): + """Return ``value`` if it is finite, positive and no larger than ``maximum``. + + Bad lane config is a programming error, so it raises instead of falling + back to a default. + """ + allowed = (int,) if integer else (int, float) + if ( + isinstance(value, bool) + or not isinstance(value, allowed) + or not math.isfinite(value) + or value <= 0 + ): + kind = "integer" if integer else "number" + raise ValueError(f"{name} must be a positive {kind}, got {value!r}") + if maximum is not None and value > maximum: + raise ValueError(f"{name} must be at most {maximum}, got {value!r}") + return value + + +def _explicit_distinct_id(passed) -> Optional[str]: + """The caller's distinct id, or None when it is missing, empty or whitespace.""" + stringified = stringify_id(passed) + if stringified and stringified.strip(): + return stringified + return None + + def get_identity_state(passed) -> tuple[str, bool]: """Returns the distinct id to use, and whether this is a personless event or not""" - stringified = stringify_id(passed) - if stringified and len(stringified): - return (stringified, False) + explicit = _explicit_distinct_id(passed) + if explicit: + return (explicit, False) context_id = get_context_distinct_id() if context_id: return (context_id, False) - return (str(uuid4()), True) + return (str(_uuid7()), True) -def _stringify_event_uuid(value) -> str: - if isinstance(value, UUID): - return str(value) +def _group_identify_distinct_id(passed, group_type, group_key) -> str: + """The caller's id, else the context id, else one stable id per group. - stringified = stringify_id(value) - if not stringified: - raise ValueError( - f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance." - ) + Ingestion creates a person for each new distinct id, so a random id + would add a person on every call. + """ + distinct_id, personless = get_identity_state(passed) + if personless: + return f"${group_type}_{group_key}" + return distinct_id - try: - UUID(stringified) - except ValueError: - raise ValueError( - f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance." - ) from None - return stringified +def _personless_options(personless: bool) -> dict[str, Any]: + """The lowest option layer: no person profile for a generated distinct ID.""" + return {"process_person_profile": False} if personless else {} def add_context_tags(properties): @@ -265,12 +312,24 @@ def add_context_tags(properties): context_tags.update(properties) properties = context_tags + return _add_context_session_id(properties) + + +def _add_context_session_id(properties): if "$session_id" not in properties and get_context_session_id(): properties["$session_id"] = get_context_session_id() - return properties +def _context_tag_defaults() -> dict[str, Any]: + """The current context's tags, as event property defaults.""" + current_context = _get_current_context() + if not current_context: + return {} + context_tags = current_context.collect_tags() + return {**context_tags, "$context_tags": set(context_tags.keys())} + + def no_throw(default_return=None): """ Decorator to prevent raising exceptions from public API methods. @@ -322,8 +381,14 @@ def wrapper(self, *args, **kwargs): # Correctness-required "$groups", "$process_person_profile", + # Option sources: legacy properties move into options after this + # allowlist runs, so dropping them would drop the option. + "$cookieless_mode", + "$ignore_sent_at", + "$product_tour_id", # Linkage / SDK identity "$session_id", + "$window_id", "$lib", "$lib_version", "$is_server", @@ -374,13 +439,11 @@ def __init__( send, flush_at, flush_interval, - gzip, max_retries, timeout, historical_migration, endpoint, max_msg_size, - capture_mode, capture_compression, sdk_info, eager_start, @@ -392,13 +455,11 @@ def __init__( self.send = send self.flush_at = flush_at self.flush_interval = flush_interval - self.gzip = gzip self.max_retries = max_retries self.timeout = timeout self.historical_migration = historical_migration self.endpoint = endpoint self.max_msg_size = max_msg_size - self.capture_mode = capture_mode self.capture_compression = capture_compression self.sdk_info = sdk_info self._max_queue_size = max_queue_size @@ -427,13 +488,11 @@ def _start_locked(self) -> None: on_error=self.on_error, flush_at=self.flush_at, flush_interval=self.flush_interval, - gzip=self.gzip, retries=self.max_retries, timeout=self.timeout, historical_migration=self.historical_migration, endpoint=self.endpoint, max_msg_size=self.max_msg_size, - capture_mode=self.capture_mode, capture_compression=self.capture_compression, ) consumer._sdk_info = self.sdk_info @@ -671,13 +730,13 @@ def __init__( self, project_api_key: str, host=None, + *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, - gzip=False, max_retries=3, sync_mode=False, timeout=15, @@ -691,6 +750,7 @@ def __init__( feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, + super_options=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, @@ -709,13 +769,14 @@ def __init__( exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, - capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, + capture_ai_max_queue_size: int = 1000, + capture_ai_timeout: float = 30, + capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, - # Appended rather than grouped with the other `capture_*` options so - # existing positional arguments keep their slots. capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, @@ -731,17 +792,21 @@ def __init__( the corresponding ingestion host. debug: Enable verbose SDK logging and re-raise errors from public API methods. - max_queue_size: Maximum number of events buffered before upload. + max_queue_size: Maximum number of analytics events buffered before + upload. AI events use ``capture_ai_max_queue_size``. send: If False, queueing succeeds but events are not sent. - on_error: Optional callback invoked by background consumers when an - upload fails. Keep it short and non-blocking. Calling lifecycle + on_error: Optional callback ``(error, batch)`` invoked when an upload + fails: by background consumers, or on the calling thread in + ``sync_mode``. Capture failures arrive as ``CaptureError``. + Without it, each failed batch logs one aggregate line. A capture + that fails inside the callback logs that line instead of calling + it again. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback. flush_at: Number of queued events that triggers a batch upload. flush_interval: Maximum seconds a background consumer waits before flushing a partial batch. - gzip: Whether to gzip event upload payloads. max_retries: Number of upload retries. Values below 0 are treated as 0. sync_mode: If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in @@ -761,17 +826,31 @@ def __init__( accepts a Project Secret API Key. disabled: If True, disable captures and API requests. Useful in tests. disable_geoip: Whether to disable server-side GeoIP enrichment. - Defaults to True. + Defaults to True. Events get ``$geoip_disable`` only when the + event, context tags and ``super_properties`` leave it unset. is_server: Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI - so the device OS is attributed to the person normally. + so the device OS is attributed to the person normally. Events + get ``$is_server`` only when the event, context tags and + ``super_properties`` leave it unset. historical_migration: Mark events as historical migration imports. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests. feature_flags_request_max_retries: Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries. - super_properties: Properties merged into every captured event. + super_properties: Properties for every captured event. They fill + only keys the event leaves unset, before ``before_send`` runs. + ``$set``, ``$set_once``, ``$groups`` and ``$group_set`` fill + one level deep. An event's own properties and context tags + override them, and ``before_send`` can change or remove them. + super_options: Capture options for every captured event, such as + ``{"cookieless_mode": True}``. They fill only options the event + leaves unset, before ``before_send`` runs. An event's own + ``options`` and context options override them, and + ``before_send`` can change them. They also win over an event's legacy property + for the same key, such as ``$cookieless_mode``, so pass + per-event overrides of that key as ``options``. enable_exception_autocapture: Automatically capture uncaught exceptions. log_captured_exceptions: Also log exceptions captured by error @@ -780,13 +859,17 @@ def __init__( captured exceptions. Defaults to the current working directory. privacy_mode: For AI observability, capture usage metadata without prompt inputs or outputs. - enable_full_ai_capture: Route PostHog AI wrapper events through - the dedicated AI capture endpoint and capture full AI content: - skips string truncation and passes media (base64/data URIs) - through unredacted. ``privacy_mode`` always wins. Defaults to - False. + enable_full_ai_capture: Capture full AI content in PostHog AI + wrapper events: skips string truncation and passes media + (base64/data URIs) through unredacted. ``privacy_mode`` always + wins. Wrapper events use the AI capture endpoint whether or not + this is set. Defaults to False. before_send: Optional callback that can modify or drop events before - upload. Return ``None`` to drop an event. + upload. Return ``None`` to drop an event. Context tags, + context options, ``super_properties``, ``super_options`` and + the values the SDK adds, such as ``$is_server``, + ``$geoip_disable`` and ``$os``, fill in before it runs, so it + can change or remove them. flag_fallback_cache_url: Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL. enable_local_evaluation: Whether to poll feature flag definitions for @@ -840,16 +923,26 @@ def __init__( interval for each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. - capture_mode: Capture wire protocol to use. Defaults to - ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` - (or pass the string ``"v1"``) to opt into - ``/i/v1/analytics/events``. When omitted, the - ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``. - capture_compression: Request-body compression for capture-v1 uploads - (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` - or ``DEFLATE`` (or the strings ``"gzip"``/``"deflate"``). When - omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, - then the legacy ``gzip`` flag, then no compression. + capture_compression: 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. + capture_ai_compression: 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. + capture_ai_max_queue_size: Maximum number of AI events buffered + before upload. Defaults to 1000, lower than ``max_queue_size`` + because AI events are much larger. + capture_ai_timeout: Seconds allowed for one AI upload request. + Defaults to 30, longer than ``timeout`` because AI batches are + much larger. + capture_ai_max_event_bytes: Largest serialized AI event the SDK + sends; a larger one is dropped with an error log. Defaults to + the AI endpoint's ceiling plus envelope headroom, and may only + be lowered. Examples: ```python @@ -890,7 +983,6 @@ def __init__( self.raw_host = normalize_host(host) self.host = determine_server_host(host) self._duplicate_client_registry_key: Optional[tuple[str, str]] = None - self.gzip = gzip self.timeout = timeout self.max_retries = max(0, max_retries) self._feature_flags: Optional[list[Any]] = ( @@ -929,7 +1021,9 @@ def __init__( _BackgroundEventLoopRunner ] = None self._flag_definition_cache_provider_async_runner_lock = threading.Lock() - self.disabled = disabled or not self.api_key + self.disabled = ( + disabled or not self.api_key or _is_personal_api_key(self.api_key) + ) self.disable_geoip = disable_geoip self._metrics_config = metrics self._metrics: Optional[PostHogMetrics] = None @@ -950,19 +1044,27 @@ def __init__( ) self.is_server = is_server self.historical_migration = historical_migration - # Selects the capture wire protocol (V0 legacy `/batch/` vs V1 - # `/i/v1/analytics/events`). Resolved here so the env-var fallback is - # applied once; V0 is the default and keeps upgrades transparent. - self.capture_mode = _resolve_capture_mode(capture_mode) self._library_id = "posthog-python" self._library_version = VERSION self._sdk_info = f"{self._library_id}/{self._library_version}" - # v1-only request compression; falls back to the legacy `gzip` flag when - # neither the kwarg nor POSTHOG_CAPTURE_COMPRESSION is set. - self.capture_compression = _resolve_capture_compression( - capture_compression, gzip_fallback=gzip + self.capture_compression = _resolve_capture_compression(capture_compression) + self.capture_ai_compression = _resolve_capture_ai_compression( + capture_ai_compression + ) + capture_ai_max_queue_size = _positive_config_value( + "capture_ai_max_queue_size", capture_ai_max_queue_size, integer=True + ) + capture_ai_timeout = _positive_config_value( + "capture_ai_timeout", capture_ai_timeout + ) + capture_ai_max_event_bytes = _positive_config_value( + "capture_ai_max_event_bytes", + capture_ai_max_event_bytes, + integer=True, + maximum=AI_MAX_MSG_SIZE, ) self.super_properties = super_properties + self.super_options = super_options # Release id from POSTHOG_RELEASE_ID, attached to every event. Resolved # here so the env var is read once per client. self._release_id = _resolve_release_id() @@ -1047,6 +1149,11 @@ def __init__( self.log.error( "api_key is empty after trimming whitespace; check your project API key" ) + elif _is_personal_api_key(self.api_key): + self.log.error( + "api_key is a personal API key (phx_...), not a project API key; " + "PostHog is disabled. Use the project API key (phc_...)." + ) self._set_before_send(before_send) @@ -1071,39 +1178,34 @@ def __init__( api_key=self.api_key, host=self.host, on_error=on_error, - max_queue_size=max_queue_size, thread_count=thread, send=send, flush_at=flush_at, flush_interval=flush_interval, - gzip=gzip, max_retries=self.max_retries, - timeout=timeout, historical_migration=historical_migration, sdk_info=self._sdk_info, ) self._analytics_lane = _Lane( name="analytics", **lane_defaults, - endpoint=EVENTS_ENDPOINT, + max_queue_size=max_queue_size, + timeout=timeout, + endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, - capture_mode=self.capture_mode, capture_compression=self.capture_compression, eager_start=not sync_mode, ) - # The AI lane is pinned to the v0 submitter: the AI endpoint has no v1 - # form, and this keeps multi-MB AI events away from capture v1's - # smaller caps. The `capture_compression` pin is inert on v0 — its wire - # compression is the `gzip` flag, inherited from client config. Lazy - # start, so the many clients that never emit AI events pay for no - # extra threads. + # A separate endpoint keeps multi-MB AI events off the analytics caps. + # Lazy start, so clients that never send AI events pay for no threads. self._ai_lane = _Lane( name="ai", **lane_defaults, - endpoint=AI_EVENTS_ENDPOINT, - max_msg_size=AI_MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, - capture_compression=CaptureCompression.NONE, + max_queue_size=capture_ai_max_queue_size, + timeout=capture_ai_timeout, + endpoint=_CAPTURE_AI_V1_PATH, + max_msg_size=capture_ai_max_event_bytes, + capture_compression=self.capture_ai_compression, eager_start=False, ) self._lanes = [self._analytics_lane, self._ai_lane] @@ -1116,19 +1218,6 @@ def __init__( self._warn_if_duplicate_async_client() - def _set_library_identity(self, library_id: str, library_version: str) -> None: - """Override the SDK identity stamped on events and outbound requests.""" - self._library_id = library_id - self._library_version = library_version - self._sdk_info = f"{library_id}/{library_version}" - for lane in self._lanes: - lane.sdk_info = self._sdk_info - for consumer in lane.consumers: - consumer._sdk_info = self._sdk_info - - def _request_identity_kwargs(self) -> Dict[str, str]: - return {"_user_agent": self._sdk_info} if self._sdk_info != _USER_AGENT else {} - @property def queue(self) -> Queue: """The analytics lane's queue (kept for backwards compatibility).""" @@ -1278,6 +1367,36 @@ def get_tags(self) -> Dict[str, Any]: """ return _context_get_tags() + def set_context_option(self, key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Context options fill options an event leaves unset, before + ``before_send`` runs, so the hook sees them. They override + ``super_options``. An event's own ``options`` override them, and + ``before_send`` can change them. + + Args: + key: The option name, such as ``"process_person_profile"``. + value: The option value, sent as given. + + Category: + Contexts + """ + _context_set_context_option(key, value) + + def get_context_options(self) -> Dict[str, Any]: + """ + Get all capture options from the current context. + + Returns: + Dict of all capture options in the current context. + + Category: + Contexts + """ + return _context_get_context_options() + def identify_context(self, distinct_id: str) -> None: """ Identify the current context with a distinct ID. @@ -1552,8 +1671,6 @@ def _get_flags_decision( if flag_keys_to_evaluate: request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate - if self._sdk_info != _USER_AGENT: - request_data["_user_agent"] = self._sdk_info resp_data = flags( self.api_key, @@ -1588,14 +1705,25 @@ def capture( uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. - groups: A dictionary of group information. + groups: A dictionary of group information. It merges into a + ``$groups`` property and wins key by key. flags: A FeatureFlagEvaluations snapshot from evaluate_flags(). The exact values from the snapshot are attached with no extra /flags request. send_feature_flags: Deprecated. Prefer flags=... from evaluate_flags(). When truthy, evaluates flags during capture and attaches them to the event. - disable_geoip: Whether to disable GeoIP for this event. + disable_geoip: Whether to disable GeoIP for this event. It wins + over a ``$geoip_disable`` in context tags or + ``super_properties``. A ``$geoip_disable`` property in this + call's ``properties`` wins over it. + options: Capture options for this event, such as + ``{"process_person_profile": False}``. Sent as given, for + PostHog to validate. They override context options and + ``super_options``, which fill in before ``before_send`` runs. + An option set at any layer wins over its + legacy ``$`` property, such as ``$process_person_profile``, + set at any layer. A ``None`` option counts as unset. Examples: ```python @@ -1670,33 +1798,31 @@ def _capture( flags_snapshot = kwargs.get("flags", None) send_feature_flags = kwargs.get("send_feature_flags", False) disable_geoip = kwargs.get("disable_geoip", None) + options = _event_options(kwargs.get("options", None)) # Internal, set for minimal $feature_flag_called events: a strict allowlist # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) - properties = {**(properties or {}), **system_context()} + properties = dict(properties or {}) if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} - properties = add_context_tags(properties) - assert properties is not None # Type hint for mypy + properties = _add_context_session_id(properties) (distinct_id, personless) = get_identity_state(distinct_id) - if personless and "$process_person_profile" not in properties: - properties["$process_person_profile"] = False - msg = { "properties": properties, "timestamp": timestamp, "distinct_id": distinct_id, "event": event, "uuid": uuid, + "options": options, } if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) extra_properties: dict[str, Any] = {} @@ -1790,7 +1916,14 @@ def _capture( msg["properties"] = properties return self._enqueue( - msg, disable_geoip, lane, property_allowlist=property_allowlist + msg, + disable_geoip, + lane, + property_allowlist=property_allowlist, + context_properties=_context_tag_defaults(), + context_options=_context_get_context_options(), + derived_options=_personless_options(personless), + system_properties=system_context(), ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -1878,9 +2011,12 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set": properties, "event": "$set", "uuid": uuid, + "options": _event_options(kwargs.get("options", None)), } - return self._enqueue(msg, disable_geoip) + return self._enqueue( + msg, disable_geoip, context_options=_context_get_context_options() + ) @no_throw() def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: @@ -1927,9 +2063,12 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: "$set_once": properties, "event": "$set_once", "uuid": uuid, + "options": _event_options(kwargs.get("options", None)), } - return self._enqueue(msg, disable_geoip) + return self._enqueue( + msg, disable_geoip, context_options=_context_get_context_options() + ) @no_throw() def group_identify( @@ -1941,6 +2080,7 @@ def group_identify( uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Identify a group and set its properties. @@ -1958,6 +2098,9 @@ def group_identify( ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP for this event. distinct_id: The distinct ID of the user performing the action. + Falls back to the context distinct ID, then to + ``$_``. + options: Capture options for this event, sent as given. Examples: ```python @@ -1986,8 +2129,7 @@ def group_identify( properties = properties or {} - # group_identify is purposefully always personful - distinct_id = get_identity_state(distinct_id)[0] + distinct_id = _group_identify_distinct_id(distinct_id, group_type, group_key) msg: Dict[str, Any] = { "event": "$groupidentify", @@ -1999,13 +2141,17 @@ def group_identify( "distinct_id": distinct_id, "timestamp": timestamp, "uuid": uuid, + "options": _event_options(options), } - # NOTE - group_identify doesn't generally use context properties - should it? - if get_context_session_id(): - msg["properties"]["$session_id"] = str(get_context_session_id()) - - return self._enqueue(msg, disable_geoip) + # No context session: this event's distinct id is not the session's + # user, and a session takes the person of its latest event. + return self._enqueue( + msg, + disable_geoip, + context_properties=_context_tag_defaults(), + context_options=_context_get_context_options(), + ) @no_throw() def alias( @@ -2015,6 +2161,7 @@ def alias( timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, + options: Optional[Dict[str, Any]] = None, ) -> Optional[str]: """ Create an alias between two distinct IDs. @@ -2022,15 +2169,16 @@ def alias( Args: previous_id: The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty. - distinct_id: The new distinct ID to alias to. Falls back to the - context distinct ID; the call is dropped with a warning if - neither is available. + distinct_id: The new distinct ID to alias to. Required - the call is + dropped with a warning if it is missing or empty. The context + distinct ID is never used here. timestamp: The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID. disable_geoip: Whether to disable GeoIP for this event. + options: Capture options for this event, sent as given. Examples: ```python @@ -2049,10 +2197,10 @@ def alias( ) return None - (distinct_id, personless) = get_identity_state(distinct_id) - - if personless: - # No alias target was passed and none is available from context. + # The alias target always comes from the caller: a context id can come + # from a client-controlled request header. + distinct_id = _explicit_distinct_id(distinct_id) + if not distinct_id: self.log.warning( "alias() called without a distinct_id, dropping the $create_alias event" ) @@ -2067,16 +2215,24 @@ def alias( "event": "$create_alias", "distinct_id": previous_id, "uuid": uuid, + "options": _event_options(options), } if get_context_session_id(): msg["properties"]["$session_id"] = str(get_context_session_id()) - return self._enqueue(msg, disable_geoip) + return self._enqueue( + msg, + disable_geoip, + context_properties=_context_tag_defaults(), + context_options=_context_get_context_options(), + ) def capture_exception( self, exception: Optional[ExceptionArg], + *, + level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs], ) -> Optional[str]: """ @@ -2087,8 +2243,12 @@ def capture_exception( Args: exception: The exception to capture. + level: The ``$exception_level``, such as ``"warning"`` or ``"fatal"``. + Defaults to ``"error"``. An unknown value counts as unset. distinct_id: The distinct ID of the user. - properties: A dictionary of additional properties. + properties: A dictionary of additional properties. Reserved exception + properties, such as ``$exception_list`` and ``$exception_level``, + are ignored. flags: A ``FeatureFlagEvaluations`` snapshot from ``evaluate_flags()``. Attaches those exact flag values to the captured `$exception` event. send_feature_flags: Deprecated. Pass ``flags`` from ``evaluate_flags()`` instead. @@ -2131,7 +2291,16 @@ def capture_exception( return None # Format stack trace for cymbal - all_exceptions_with_trace = exceptions_from_error_tuple(exc_info) + capture_metadata_input = dict(kwargs).get("_capture_metadata") + capture_metadata = ( + capture_metadata_input + if isinstance(capture_metadata_input, dict) + else {} + ) + mechanism = capture_metadata.get("mechanism") + all_exceptions_with_trace = exceptions_from_error_tuple( + exc_info, mechanism=mechanism if isinstance(mechanism, dict) else None + ) # Add in-app property to frames in the exceptions event = handle_in_app( @@ -2145,11 +2314,18 @@ def capture_exception( ) all_exceptions_with_trace_and_in_app = event["exception"]["values"] + caller_properties = properties properties = { - "$exception_list": all_exceptions_with_trace_and_in_app, **_get_current_otel_span_properties(), - **properties, + "$exception_list": all_exceptions_with_trace_and_in_app, + "$exception_level": _exception_level( + capture_metadata.get("level"), level + ), } + source = capture_metadata.get("source") + if isinstance(source, str) and source: + properties["$exception_source"] = source + properties.update(_without_reserved_exception_properties(caller_properties)) context_enabled = get_capture_exception_code_variables_context() context_mask = get_code_variables_mask_patterns_context() @@ -2211,6 +2387,7 @@ def capture_exception( flags=flags_snapshot, send_feature_flags=send_feature_flags, disable_geoip=disable_geoip, + options=kwargs.get("options", None), ) # Mark the exception as captured to prevent duplicate captures @@ -2329,19 +2506,39 @@ def _reinit_after_fork(self): def _normalize_event_uuid(self, msg): # type: (...) -> None """Ensure `msg["uuid"]` is a valid uuid string, generating one if missing or invalid.""" - if "uuid" in msg: - uuid = msg.pop("uuid") - if uuid is not None: - try: - msg["uuid"] = _stringify_event_uuid(uuid) - except ValueError as e: - self.log.error("%s Falling back to a generated UUID.", e) + # Always send a uuid, so we can always return one + msg["uuid"] = _resolve_event_uuid(msg.pop("uuid", None)) + + def _report_capture_failure( + self, error: Exception, batch: list[dict], endpoint: str + ) -> None: + """Hand a failed sync-mode send to `on_error`, like the queued path does. - if "uuid" not in msg: - # Always send a uuid, so we can always return one - msg["uuid"] = stringify_id(uuid4()) + Without a handler, or when the failed capture ran inside `on_error`, + log one aggregate line instead, so the loss stays visible without recursion. + """ + if not self.on_error or getattr(_on_error_state, "active", False): + self.log.error(_capture_loss_message(error, len(batch), endpoint)) + return + _on_error_state.active = True + try: + self.on_error(error, batch) + except Exception as callback_error: + self.log.error("on_error handler failed: %s", callback_error) + finally: + _on_error_state.active = False - def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): + def _enqueue( + self, + msg, + disable_geoip, + lane=None, + property_allowlist=None, + context_properties=None, + context_options=None, + derived_options=None, + system_properties=None, + ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2360,7 +2557,8 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): msg["timestamp"] = _normalize_timestamp(timestamp) except ValueError: self.log.warning( - "Invalid timestamp %r. Falling back to the current UTC time.", timestamp + "Invalid timestamp; it must be a datetime or an ISO 8601 string. " + "Falling back to the current UTC time." ) msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() @@ -2371,28 +2569,28 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): msg["properties"]["$lib"] = self._library_id msg["properties"]["$lib_version"] = self._library_version - if disable_geoip is None: - disable_geoip = self.disable_geoip - - if disable_geoip: - msg["properties"]["$geoip_disable"] = True - - if self.super_properties: - msg["properties"] = {**msg["properties"], **self.super_properties} - - # Set after the super_properties merge so an explicit `$release_id` from - # the caller's properties or the super properties wins over the env var. - if self._release_id is not None: - msg["properties"].setdefault("$release_id", self._release_id) - - # Set after the super_properties merge so this SDK's server classification - # can't be silently overridden by a user-provided super property. - if self.is_server: - msg["properties"]["$is_server"] = True + msg["options"] = msg.get("options") or {} + + _fill_event_defaults( + msg, + _build_event_defaults( + super_properties=self.super_properties, + super_options=self.super_options, + release_id=self._release_id, + context_properties=context_properties, + context_options=context_options, + derived_options=derived_options, + property_allowlist=property_allowlist, + is_server=self.is_server, + disable_geoip=self.disable_geoip, + call_disable_geoip=disable_geoip, + system_properties=system_properties, + ), + ) - # Applied after every enrichment step (system context, context tags, super - # properties, $lib/$lib_version) so the final event shape is exactly the - # allowlist regardless of where a property came from. + # Applied after every enrichment step (system context, $lib/$lib_version, + # filled defaults), so the final event shape is exactly the allowlist + # regardless of where a property came from. if property_allowlist is not None: msg["properties"] = { k: v for k, v in msg["properties"].items() if k in property_allowlist @@ -2420,7 +2618,12 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): self._normalize_event_uuid(msg) sent_uuid = msg["uuid"] - self.log.debug("queueing: %s", msg) + if _drop_invalid_event_name(self.log, msg): + return None + if _drop_invalid_distinct_id(self.log, msg): + return None + + self.log.debug("queueing %s on the %s lane", msg["event"], lane.name) # if send is False, return msg as if it was successfully queued, unless # shutdown has already closed this lane's admission. @@ -2437,35 +2640,48 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): if self.sync_mode: self.log.debug("enqueued with blocking %s.", msg["event"]) - def send_sync() -> None: - # Sync mode bypasses the lane's queue but keeps its wire config: - # the AI lane is pinned to v0, so its events post to the AI - # endpoint regardless of `capture_mode`. - if lane.capture_mode == CaptureMode.V1: - _send_v1_batch( - self.api_key, - self.host, - [msg], - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.max_retries, - historical_migration=self.historical_migration, - sdk_info=self._sdk_info, - ) - return + try: + event_size = len( + json.dumps(msg, cls=_DatetimeSerializer, allow_nan=False).encode() + ) + except Exception: + self.log.error("Unable to serialize event for sizing, dropping.") + return None + if event_size > lane.max_msg_size: + # Log only name and size: AI events may carry unredacted + # multimodal payloads that must not leak into logs. + self.log.error( + "Event %s (%d bytes) exceeds the %dKiB limit for %s, dropping.", + msg["event"], + event_size, + lane.max_msg_size // 1024, + lane.endpoint, + ) + return None - batch_post( + def send_sync() -> None: + # Sync mode bypasses the lane's queue but keeps its wire config, + # so AI events still post to the AI endpoint. + _send_v1_batch( self.api_key, self.host, - gzip=self.gzip, - timeout=self.timeout, - batch=[msg], + [msg], + compression=lane.capture_compression, + timeout=lane.timeout, + max_retries=self.max_retries, historical_migration=self.historical_migration, + sdk_info=self._sdk_info, path=lane.endpoint, - **self._request_identity_kwargs(), ) - if lane.run_sync_if_open(send_sync): + try: + admitted = lane.run_sync_if_open(send_sync) + except Exception as e: + self._report_capture_failure(e, [msg], lane.endpoint) + if self.debug: + raise + return None + if admitted: return sent_uuid self.log.warning( "%s lane received event %s after shutdown, dropping it", @@ -2689,13 +2905,14 @@ def flush(self, timeout_seconds: Optional[float] = 10) -> None: # Spans drain with events: serverless handlers call flush(), not # shutdown(), and leaving spans on their own timer would lose them. span_flush = self._start_span_flush(timeout_seconds) - if timeout_seconds is None: - for lane in self._lanes: - lane.flush(None) - else: - deadline = time.monotonic() + timeout_seconds - for lane in self._lanes: - lane.flush(max(0.0, deadline - time.monotonic())) + with self._drain_lanes_together(): + if timeout_seconds is None: + for lane in self._lanes: + lane.flush(None) + else: + deadline = time.monotonic() + timeout_seconds + for lane in self._lanes: + lane.flush(max(0.0, deadline - time.monotonic())) if span_flush is not None: # The last span request is bounded only by the request # timeout, so the wait is not. @@ -2832,7 +3049,36 @@ def _run_lifecycle_cleanup( self.log.exception(log_message) errors.append(error) + @contextmanager + def _drain_lanes_together(self): + """Signal every lane to drain before waiting on any of them. + + Lanes then drain in parallel under one budget. Otherwise a lane keeps + batching on its normal cadence while the client waits on the lane before it. + """ + signals: list[_DrainSignal] = [] + try: + for lane in self._lanes: + signal = lane._drain_signal + signal.request() + signals.append(signal) + yield + finally: + for signal in signals: + signal.complete() + def _flush_or_discard_queues(self, errors: list[Exception]) -> None: + try: + with self._drain_lanes_together(): + self._flush_or_discard_each_lane(errors) + return + except Exception as error: + self.log.exception("Failed to signal lane drains during lifecycle cleanup") + errors.append(error) + # Each lane's flush signals its own drain, so lanes still drain one by one. + self._flush_or_discard_each_lane(errors) + + def _flush_or_discard_each_lane(self, errors: list[Exception]) -> None: for lane in self._lanes: try: if any(consumer.is_alive() for consumer in lane.consumers): @@ -3262,14 +3508,12 @@ def _fetch_feature_flags_from_api(self): cache_data_to_store: Optional[FlagDefinitionCacheData] = None old_fingerprint: Optional[str] = None try: - request_get = _get_with_identity if self._request_identity_kwargs() else get - response = request_get( + response = get( personal_api_key, f"/flags/definitions?token={self.api_key}&send_cohorts", self.host, timeout=10, etag=request_etag, - **self._request_identity_kwargs(), ) # Canonical serialization can be expensive; do it before publication. @@ -4253,18 +4497,12 @@ def get_remote_config_payload(self, key: str): return None try: - request_remote_config = ( - _remote_config_with_identity - if self._request_identity_kwargs() - else remote_config - ) - return request_remote_config( + return remote_config( self.personal_api_key, self.api_key, self.host, key, timeout=self.feature_flags_request_timeout_seconds, - **self._request_identity_kwargs(), ) except Exception as e: self.log.exception( diff --git a/posthog/consumer.py b/posthog/consumer.py index c604b9756..1f9c9f316 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -6,30 +6,37 @@ from posthog._logging import _configure_posthog_logging from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode -from posthog.capture_v1 import _backoff, _send_v1_batch +from posthog.capture_send import ( + _CAPTURE_V1_PATH, + _capture_loss_message, + _send_v1_batch, +) from posthog.request import ( - EVENTS_ENDPOINT, USER_AGENT as _USER_AGENT, - APIError, DatetimeSerializer, - batch_post, ) from queue import Empty -MAX_MSG_SIZE = 900 * 1024 # 900KiB per event - -# AI events carry LLM inputs/outputs and post to a dedicated endpoint whose -# pipeline accepts larger messages than analytics ingestion, so the AI lane -# grants a higher per-event ceiling. `next()` appends an item before checking -# BATCH_SIZE_LIMIT, so worst-case request body is BATCH_SIZE_LIMIT + -# AI_MAX_MSG_SIZE (~13MiB) — keep that sum under the 20MiB server body cap. -AI_MAX_MSG_SIZE = 8 * 1024 * 1024 # 8MiB per event - -# The maximum request body size is currently 20MiB, let's be conservative -# in case we want to lower it in the future. +# Capture's request body limit. An analytics event larger than this can never +# be accepted, so the SDK drops it locally; anything smaller is sent, and +# capture reports an event over its own per-event limit in the response. +MAX_MSG_SIZE = 10 * 1024 * 1024 + +# The AI endpoint's per-event ceiling. The endpoint applies it to the +# serialized properties alone. +AI_MAX_PROPERTIES_SIZE = 8 * 1024 * 1024 +# The local guard measures the whole event, not only its properties, so it +# allows this much more to keep events at the endpoint's ceiling. +AI_ENVELOPE_HEADROOM = 64 * 1024 +# The AI lane's per-event guard, and the upper bound for +# `capture_ai_max_event_bytes`. +AI_MAX_MSG_SIZE = AI_MAX_PROPERTIES_SIZE + AI_ENVELOPE_HEADROOM + +# A batch closes before it appends an event that would take it past this, so +# a request carries at most this much event data, or one larger event alone. +# Half of capture's request body limit, which applies before decompression. BATCH_SIZE_LIMIT = 5 * 1024 * 1024 _configure_posthog_logging() @@ -109,13 +116,11 @@ def __init__( host=None, on_error=None, flush_interval=5.0, - gzip=False, - retries=10, + retries=3, timeout=15, historical_migration=False, - endpoint=EVENTS_ENDPOINT, + endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, - capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, ): """Create a consumer thread.""" @@ -128,10 +133,8 @@ def __init__( self.host = host self.on_error = on_error self.queue = queue - self.gzip = gzip self.endpoint = endpoint self.max_msg_size = max_msg_size - self.capture_mode = capture_mode self.capture_compression = capture_compression self._sdk_info = _USER_AGENT self._drain_signal: Optional[_DrainSignal] = None @@ -180,13 +183,14 @@ def upload(self): self.request(batch) success = True except Exception as e: - self.log.error("error uploading: %s", e) success = False if self.on_error: try: self.on_error(e, batch) - except Exception as e: - self.log.error("on_error handler failed: %s", e) + except Exception as callback_error: + self.log.error("on_error handler failed: %s", callback_error) + else: + self.log.error(_capture_loss_message(e, len(batch), self.endpoint)) finally: # mark items as acknowledged from queue for item in batch: @@ -244,7 +248,9 @@ def next(self): pending_items += 1 try: item_size = len( - json.dumps(item, cls=DatetimeSerializer).encode() + json.dumps( + item, cls=DatetimeSerializer, allow_nan=False + ).encode() ) except Exception: # Callback-modified events can still contain invalid mapping @@ -268,6 +274,11 @@ def next(self): queue.task_done() pending_items -= 1 continue + if items and total_size + item_size > BATCH_SIZE_LIMIT: + self._return_to_queue_head(item) + pending_items -= 1 + self.log.debug("hit batch size limit (size: %d)", total_size) + break items.append(item) total_size += item_size if total_size >= BATCH_SIZE_LIMIT: @@ -287,67 +298,29 @@ def next(self): return items - def request(self, batch): - """Upload the batch via the wire protocol selected by `capture_mode`. + def _return_to_queue_head(self, item) -> None: + """Put a dequeued event back at the head of the queue for the next batch. - V1 uses the partial-retry submitter (which posts to its own path); V0 - posts the batch to this consumer's `endpoint`. + The event stays counted in ``unfinished_tasks``, because it was never + marked done. Keeping it in the queue, not in the consumer, means a + stop, a discard or a fork accounts for it like any other queued event. """ - if self.capture_mode == CaptureMode.V1: - _send_v1_batch( - self.api_key, - self.host, - batch, - compression=self.capture_compression, - timeout=self.timeout, - max_retries=self.retries, - historical_migration=self.historical_migration, - sdk_info=self._sdk_info, - ) - return - self._send(batch, self.endpoint) - - def _send(self, batch, path): - """Attempt to upload a single batch to `path`, retrying before raising an error""" - - def is_retryable(exc): - if isinstance(exc, APIError): - # retry on server errors and client errors - # with 408 (request timeout) or 429 (rate limited), - # don't retry on other client errors - if isinstance(exc.status, int): - return not ( - (400 <= exc.status < 500) and exc.status not in (408, 429) - ) - return False - else: - # retry on all other errors (eg. network) - return True + queue = self.queue + with queue.not_empty: + queue.queue.appendleft(item) + queue.not_empty.notify() - last_exc = None - for attempt in range(self.retries + 1): - try: - batch_post( - self.api_key, - self.host, - gzip=self.gzip, - timeout=self.timeout, - batch=batch, - historical_migration=self.historical_migration, - path=path, - **( - {"_user_agent": self._sdk_info} - if self._sdk_info != _USER_AGENT - else {} - ), - ) - return - except Exception as e: - last_exc = e - if not is_retryable(e): - raise - if attempt < self.retries: - _backoff(attempt, getattr(e, "retry_after", None)) - - if last_exc: - raise last_exc + def request(self, batch): + """Upload the batch to this consumer's `endpoint` with the capture v1 + partial-retry submitter.""" + _send_v1_batch( + self.api_key, + self.host, + batch, + compression=self.capture_compression, + timeout=self.timeout, + max_retries=self.retries, + historical_migration=self.historical_migration, + sdk_info=self._sdk_info, + path=self.endpoint, + ) diff --git a/posthog/contexts.py b/posthog/contexts.py index f897d1d96..a6af01635 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -28,6 +28,7 @@ def __init__( self.distinct_id: Optional[str] = None self.device_id: Optional[str] = None self.tags: Dict[str, Any] = {} + self.options: Dict[str, Any] = {} self.capture_exception_code_variables: Optional[bool] = None self.code_variables_mask_patterns: Optional[list] = None self.code_variables_ignore_patterns: Optional[list] = None @@ -46,6 +47,9 @@ def set_device_id(self, device_id: str): def add_tag(self, key: str, value: Any): self.tags[key] = value + def add_option(self, key: str, value: Any): + self.options[key] = value + def set_capture_exception_code_variables(self, enabled: bool): self.capture_exception_code_variables = enabled @@ -94,6 +98,13 @@ def collect_tags(self) -> Dict[str, Any]: return tags return self.tags.copy() + def collect_options(self) -> Dict[str, Any]: + if self.parent and not self.fresh: + options = self.parent.collect_options() + options.update(self.options) + return options + return self.options.copy() + def get_capture_exception_code_variables(self) -> Optional[bool]: if self.capture_exception_code_variables is not None: return self.capture_exception_code_variables @@ -288,6 +299,51 @@ def get_tags() -> Dict[str, Any]: return {} +def set_context_option(key: str, value: Any) -> None: + """ + Set a capture option for every event captured within the current context. + + Context options fill options an event leaves unset, before ``before_send`` + runs, so the hook sees them. They override the client's ``super_options``. + An event's own ``options`` override them, and ``before_send`` can change + them. Child contexts inherit them unless they are fresh. + + Args: + key: The option name, such as ``"process_person_profile"`` + value: The option value, sent as given + + Example: + ```python + with posthog.new_context(): + posthog.set_context_option("process_person_profile", False) + posthog.capture("health_check") + ``` + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + current_context.add_option(key, value) + + +def get_context_options() -> Dict[str, Any]: + """ + Get all capture options from the current context. Note, modifying + the returned dictionary will not affect the current context. + + Returns: + Dict of all capture options in the current context + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + return current_context.collect_options() + return {} + + def identify_context(distinct_id: str) -> None: """ Identify the current context with a distinct ID, associating all events captured in this or diff --git a/posthog/exception_capture.py b/posthog/exception_capture.py index 9c4c723a9..6dac4057f 100644 --- a/posthog/exception_capture.py +++ b/posthog/exception_capture.py @@ -7,9 +7,13 @@ import logging import sys import threading -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from posthog.bucketed_rate_limiter import BucketedRateLimiter +from .exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) if TYPE_CHECKING: from posthog.client import Client @@ -80,7 +84,18 @@ def close(self): def exception_handler(self, exc_type, exc_value, exc_traceback): if not self._closed: - self.capture_exception((exc_type, exc_value, exc_traceback)) + capture_metadata: _ExceptionCaptureMetadata = { + "level": "fatal", + "source": "python.sys_excepthook", + "mechanism": { + "type": "onuncaughtexception", + "handled": False, + }, + } + self._capture_exception( + (exc_type, exc_value, exc_traceback), + capture_metadata=capture_metadata, + ) previous_hook = self._resolve_hook( self.original_excepthook, "exception_handler", @@ -90,7 +105,18 @@ def exception_handler(self, exc_type, exc_value, exc_traceback): def thread_exception_handler(self, args): if not self._closed: - self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback)) + capture_metadata: _ExceptionCaptureMetadata = { + "level": "error", + "source": "python.threading_excepthook", + "mechanism": { + "type": "onuncaughtexception", + "handled": False, + }, + } + self._capture_exception( + (args.exc_type, args.exc_value, args.exc_traceback), + capture_metadata=capture_metadata, + ) previous_hook = self._resolve_hook( self._original_threading_excepthook, "thread_exception_handler", @@ -117,6 +143,14 @@ def exception_receiver(self, exc_info, extra_properties): self.capture_exception((exc_info[0], exc_info[1], exc_info[2]), metadata) def capture_exception(self, exception, metadata=None): + self._capture_exception(exception, metadata) + + def _capture_exception( + self, + exception, + metadata=None, + capture_metadata: Optional[_ExceptionCaptureMetadata] = None, + ): try: if self._rate_limiter is not None: exception_type = self._exception_type(exception) @@ -127,7 +161,15 @@ def capture_exception(self, exception, metadata=None): return distinct_id = metadata.get("distinct_id") if metadata else None - self.client.capture_exception(exception, distinct_id=distinct_id) + resolved_capture_metadata: _ExceptionCaptureMetadata = ( + capture_metadata or {} + ) + _capture_exception_with_metadata( + self.client, + exception, + resolved_capture_metadata, + distinct_id=distinct_id, + ) except Exception as e: self.log.exception(f"Failed to capture exception: {e}") diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index c0bb345d1..65037ca01 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -571,6 +571,115 @@ def get_error_message(exc_value): return safe_str(message) +def _valid_mechanism(mechanism): + # type: (Optional[Dict[str, Any]]) -> Dict[str, Any] + """Validate common mechanism fields without dropping safe extensions.""" + if not isinstance(mechanism, dict): + return {} + + result = { + key: value + for key, value in mechanism.items() + if key + not in {"type", "handled", "source", "synthetic", "exception_id", "parent_id"} + } + if isinstance(mechanism.get("type"), str) and mechanism["type"]: + result["type"] = mechanism["type"] + if isinstance(mechanism.get("handled"), bool): + result["handled"] = mechanism["handled"] + if isinstance(mechanism.get("source"), str) and mechanism["source"]: + result["source"] = mechanism["source"] + if isinstance(mechanism.get("synthetic"), bool): + result["synthetic"] = mechanism["synthetic"] + return result + + +class _ExceptionMechanismMetadata(TypedDict, total=False): + """Typed common mechanism fields accepted from SDK-owned integrations.""" + + type: str + handled: bool + source: str + synthetic: bool + + +class _ExceptionCaptureMetadata(TypedDict, total=False): + """Typed capture-boundary metadata for SDK-owned integrations.""" + + level: Literal["fatal", "error", "warning", "log", "info", "debug"] + source: str + mechanism: _ExceptionMechanismMetadata + + +_EXCEPTION_LEVELS = { + "fatal": "fatal", + "critical": "fatal", + "alert": "fatal", + "emergency": "fatal", + "error": "error", + "warning": "warning", + "warn": "warning", + "log": "log", + "notice": "info", + "info": "info", + "trace": "debug", + "debug": "debug", +} + + +def _normalize_exception_level(level): + # type: (Any) -> Optional[str] + return _EXCEPTION_LEVELS.get(level.lower()) if isinstance(level, str) else None + + +# Exception properties the SDK or PostHog owns. A caller's per-call +# `properties` cannot set them. +_RESERVED_EXCEPTION_PROPERTIES = frozenset( + { + "$exception_list", + "$exception_level", + "$exception_source", + "$debug_images", + "$exception_handled", + "$exception_types", + "$exception_values", + "$exception_sources", + "$exception_functions", + "$exception_fingerprint_version", + "$exception_fingerprint_record", + "$exception_issue_id", + "$exception_release", + "$cymbal_errors", + } +) + + +def _exception_level(integration_level, level): + # type: (Any, Any) -> str + """An integration's level wins over the caller's ``level=``; an invalid value counts as unset.""" + return ( + _normalize_exception_level(integration_level) + or _normalize_exception_level(level) + or "error" + ) + + +def _without_reserved_exception_properties(properties): + # type: (Optional[Dict[str, Any]]) -> Dict[str, Any] + return { + key: value + for key, value in (properties or {}).items() + if key not in _RESERVED_EXCEPTION_PROPERTIES + } + + +def _capture_exception_with_metadata(client, exception, capture_metadata, **kwargs): + # type: (Any, ExceptionArg, _ExceptionCaptureMetadata, **Any) -> Optional[str] + """Call capture_exception through the SDK-internal typed integration channel.""" + capture = client.capture_exception # type: Any + return capture(exception, _capture_metadata=capture_metadata, **kwargs) + + def single_exception_from_error_tuple( exc_type, # type: Optional[type] exc_value, # type: Optional[BaseException] @@ -585,9 +694,7 @@ def single_exception_from_error_tuple( Creates a dict that goes into the events `exception.values` list """ exception_value = {} # type: Dict[str, Any] - exception_value["mechanism"] = ( - mechanism.copy() if mechanism else {"type": "generic", "handled": True} - ) + exception_value["mechanism"] = _valid_mechanism(mechanism) if exception_id is not None: exception_value["mechanism"]["exception_id"] = exception_id @@ -601,16 +708,23 @@ def single_exception_from_error_tuple( "errno", {} ).setdefault("number", errno) - if source is not None: + if isinstance(source, str) and source: exception_value["mechanism"]["source"] = source is_root_exception = exception_id == 0 if not is_root_exception and parent_id is not None: exception_value["mechanism"]["parent_id"] = parent_id exception_value["mechanism"]["type"] = "chained" + exception_value["mechanism"].pop("handled", None) + + if is_root_exception: + exception_value["mechanism"].setdefault("type", "generic") + exception_value["mechanism"].setdefault("handled", True) + exception_value["mechanism"].pop("source", None) - if is_root_exception and "type" not in exception_value["mechanism"]: - exception_value["mechanism"]["type"] = "generic" + # Python capture inputs are runtime exceptions and this builder never + # replaces their stack with an SDK-generated current stack. + exception_value["mechanism"].setdefault("synthetic", False) is_exception_group = BaseExceptionGroup is not None and isinstance( exc_value, BaseExceptionGroup @@ -680,7 +794,17 @@ def walk_exception_chain(exc_info): yield exc_info -def exceptions_from_error( +_MAX_EXCEPTION_ENTRIES = 50 +_MAX_EXCEPTION_GROUP_MEMBER_INSPECTIONS = 1_000 + + +@dataclasses.dataclass +class _ExceptionTraversalState: + seen_exception_ids: Set[int] = dataclasses.field(default_factory=set) + inspected_group_members: int = 0 + + +def _exceptions_from_error( exc_type, # type: Optional[type] exc_value, # type: Optional[BaseException] tb, # type: Optional[TracebackType] @@ -688,12 +812,20 @@ def exceptions_from_error( exception_id=0, # type: int parent_id=0, # type: int source=None, # type: Optional[str] + traversal_state=None, # type: Optional[_ExceptionTraversalState] ): # type: (...) -> Tuple[int, List[Dict[str, Any]]] - """ - Creates the list of exceptions. - This can include chained exceptions and exceptions from an ExceptionGroup. - """ + """Build a bounded, depth-first flattened exception tree.""" + + if traversal_state is None: + traversal_state = _ExceptionTraversalState() + if exc_value is not None: + if ( + id(exc_value) in traversal_state.seen_exception_ids + or exception_id >= _MAX_EXCEPTION_ENTRIES + ): + return (exception_id, []) + traversal_state.seen_exception_ids.add(id(exc_value)) parent = single_exception_from_error_tuple( exc_type=exc_type, @@ -709,67 +841,89 @@ def exceptions_from_error( parent_id = exception_id exception_id += 1 - should_supress_context = ( + causing_exception = None # type: Optional[BaseException] + relationship = None # type: Optional[str] + should_suppress_context = ( hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore ) - if should_supress_context: - # Add direct cause. - # The field `__cause__` is set when raised with the exception (using the `from` keyword). - exception_has_cause = ( - exc_value - and hasattr(exc_value, "__cause__") - and exc_value.__cause__ is not None - ) - if exception_has_cause: - cause = exc_value.__cause__ # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( - exc_type=type(cause), - exc_value=cause, - tb=getattr(cause, "__traceback__", None), - mechanism=mechanism, - exception_id=exception_id, - source="__cause__", - ) - exceptions.extend(child_exceptions) - + if should_suppress_context and exc_value is not None: + causing_exception = getattr(exc_value, "__cause__", None) + relationship = "cause" else: - # Add indirect cause. - # The field `__context__` is assigned if another exception occurs while handling the exception. - exception_has_content = ( - exc_value - and hasattr(exc_value, "__context__") - and exc_value.__context__ is not None + causing_exception = getattr(exc_value, "__context__", None) + relationship = "context" + + if causing_exception is not None and exception_id < _MAX_EXCEPTION_ENTRIES: + (exception_id, child_exceptions) = _exceptions_from_error( + exc_type=type(causing_exception), + exc_value=causing_exception, + tb=getattr(causing_exception, "__traceback__", None), + mechanism=None, + exception_id=exception_id, + parent_id=parent_id, + source=relationship, + traversal_state=traversal_state, ) - if exception_has_content: - context = exc_value.__context__ # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( - exc_type=type(context), - exc_value=context, - tb=getattr(context, "__traceback__", None), - mechanism=mechanism, - exception_id=exception_id, - source="__context__", - ) - exceptions.extend(child_exceptions) + exceptions.extend(child_exceptions) - # Add exceptions from an ExceptionGroup. - is_exception_group = exc_value and hasattr(exc_value, "exceptions") + # Aggregate-member inspection has its own shared budget. Duplicate and cyclic + # references still consume it even though they do not produce output entries. + # Cause/context traversal does not consume this budget, so the final inspected + # member can retain its complete cause chain within the output limit. + is_exception_group = BaseExceptionGroup is not None and isinstance( + exc_value, BaseExceptionGroup + ) if is_exception_group: - for idx, e in enumerate(exc_value.exceptions): # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( + members = exc_value.exceptions # type: ignore + member_index = 0 + while ( + member_index < len(members) + and exception_id < _MAX_EXCEPTION_ENTRIES + and traversal_state.inspected_group_members + < _MAX_EXCEPTION_GROUP_MEMBER_INSPECTIONS + ): + # Charge before reading the member, as required by the canonical + # exception metadata traversal contract. + traversal_state.inspected_group_members += 1 + e = members[member_index] + member_index += 1 + (exception_id, child_exceptions) = _exceptions_from_error( exc_type=type(e), exc_value=e, tb=getattr(e, "__traceback__", None), - mechanism=mechanism, + mechanism=None, exception_id=exception_id, parent_id=parent_id, - source="exceptions[%s]" % idx, + source="member", + traversal_state=traversal_state, ) exceptions.extend(child_exceptions) return (exception_id, exceptions) +def exceptions_from_error( + exc_type, # type: Optional[type] + exc_value, # type: Optional[BaseException] + tb, # type: Optional[TracebackType] + mechanism=None, # type: Optional[Dict[str, Any]] + exception_id=0, # type: int + parent_id=0, # type: int + source=None, # type: Optional[str] +): + # type: (...) -> Tuple[int, List[Dict[str, Any]]] + """Compatibility wrapper around the bounded exception-tree traversal.""" + return _exceptions_from_error( + exc_type, + exc_value, + tb, + mechanism=mechanism, + exception_id=exception_id, + parent_id=parent_id, + source=source, + ) + + def exceptions_from_error_tuple( exc_info, # type: ExcInfo mechanism=None, # type: Optional[Dict[str, Any]] @@ -777,27 +931,15 @@ def exceptions_from_error_tuple( # type: (...) -> List[Dict[str, Any]] exc_type, exc_value, tb = exc_info - is_exception_group = BaseExceptionGroup is not None and isinstance( - exc_value, BaseExceptionGroup + (_, exceptions) = _exceptions_from_error( + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + mechanism=mechanism, + exception_id=0, + parent_id=0, ) - if is_exception_group: - (_, exceptions) = exceptions_from_error( - exc_type=exc_type, - exc_value=exc_value, - tb=tb, - mechanism=mechanism, - exception_id=0, - parent_id=0, - ) - - else: - exceptions = [] - for exc_type, exc_value, tb in walk_exception_chain(exc_info): - exceptions.append( - single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism) - ) - # Canonical ordering: $exception_list[0] is the caught/outermost exception, # with each cause appended after its wrapper in unwrap order and the root # cause last. Both branches above already build the list in this order diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py new file mode 100644 index 000000000..670be43ec --- /dev/null +++ b/posthog/integrations/asgi.py @@ -0,0 +1,263 @@ +"""Framework-independent ASGI request context and exception capture. + +The middleware speaks the ASGI protocol directly and has no dependency on an ASGI +framework. It can therefore be used with FastAPI, Starlette, Litestar, or a raw +ASGI application:: + + from fastapi import FastAPI + from posthog.integrations.asgi import PosthogASGIMiddleware + + app = FastAPI() + app.add_middleware(PosthogASGIMiddleware) + +It can also wrap an application directly:: + + app = PosthogASGIMiddleware(app) + +Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI +scope types pass through unchanged. + +The request context enriches events captured by any client while the request is +running. Passing ``client`` only selects where automatically captured exceptions +are sent; it does not redirect application calls to ``posthog.capture`` or +``another_client.capture``. + +``X-PostHog-Distinct-ID`` and ``X-PostHog-Session-ID`` are client-controlled +analytics context, not authentication. They are ignored by default. Set +``trust_tracing_headers=True`` only when deliberately accepting browser analytics +attribution or when a trusted upstream strips and replaces incoming values. +""" + +import inspect +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, Optional, Union, cast + +from .. import contexts +from ..client import Client +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) + +_ASGIApp = Callable[ + [ + dict[str, Any], + Callable[[], Awaitable[dict[str, Any]]], + Callable[[dict[str, Any]], Awaitable[None]], + ], + Awaitable[None], +] +_RequestFilterResult = Union[bool, Awaitable[bool]] +_RequestFilter = Callable[[dict[str, Any]], _RequestFilterResult] +_ExtraPropertiesResult = Union[ + Optional[Mapping[str, Any]], Awaitable[Optional[Mapping[str, Any]]] +] +_ExtraProperties = Callable[[dict[str, Any]], _ExtraPropertiesResult] + +_MAX_HEADER_LENGTH = 1000 +_MAX_PATH_LENGTH = 2048 +_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_CAPTURE_METADATA: _ExceptionCaptureMetadata = { + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, +} + + +def _sanitize_text( + value: object, max_length: int = _MAX_HEADER_LENGTH +) -> Optional[str]: + if not isinstance(value, str) or not value: + return None + return _CONTROL_CHARS_RE.sub("", value).strip()[:max_length] or None + + +def _decode_header(value: object) -> Optional[str]: + if not isinstance(value, bytes): + return None + return _sanitize_text(value.decode("latin-1")) + + +def _headers_from_scope(scope: Mapping[str, Any]) -> dict[bytes, bytes]: + result: dict[bytes, bytes] = {} + headers = scope.get("headers", ()) + if not isinstance(headers, (list, tuple)): + return result + + for item in headers: + if ( + isinstance(item, (list, tuple)) + and len(item) == 2 + and isinstance(item[0], bytes) + and isinstance(item[1], bytes) + ): + # Keep the first value. Tracing headers are singular, and joining arbitrary + # duplicate user input can create misleading identity values. + result.setdefault(item[0].lower(), item[1]) + return result + + +def _server_host(scope: Mapping[str, Any]) -> Optional[str]: + server = scope.get("server") + if not isinstance(server, (list, tuple)) or len(server) != 2: + return None + + hostname, port = server + if not isinstance(hostname, str) or not isinstance(port, int): + return None + + hostname = _sanitize_text(hostname) + if not hostname: + return None + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + + scheme = scope.get("scheme") + default_port = (scheme == "http" and port == 80) or ( + scheme == "https" and port == 443 + ) + return hostname if default_port else f"{hostname}:{port}" + + +def _extract_properties( + scope: Mapping[str, Any], headers: Mapping[bytes, bytes] +) -> dict[str, Any]: + properties: dict[str, Any] = {} + + method = _sanitize_text(scope.get("method"), max_length=32) + if method: + properties["$request_method"] = method + + path = _sanitize_text(scope.get("path"), max_length=_MAX_PATH_LENGTH) + if path: + properties["$request_path"] = path + + user_agent = _decode_header(headers.get(b"user-agent")) + if user_agent: + properties["$user_agent"] = user_agent + properties["$raw_user_agent"] = user_agent + + forwarded_for = _decode_header(headers.get(b"x-forwarded-for")) + if forwarded_for: + ip_address = _sanitize_text(forwarded_for.split(",", 1)[0]) + else: + client = scope.get("client") + ip_address = ( + _sanitize_text(client[0]) + if isinstance(client, (list, tuple)) + and client + and isinstance(client[0], str) + else None + ) + if ip_address: + properties["$ip"] = ip_address + + scheme = _sanitize_text(scope.get("scheme"), max_length=16) + host = _decode_header(headers.get(b"host")) or _server_host(scope) + if scheme and host and path: + # Deliberately omit query strings: they commonly contain secrets and + # high-cardinality values. The path remains available separately. + properties["$current_url"] = f"{scheme}://{host}{path}" + + return properties + + +async def _resolve_callback_result(value): + if inspect.isawaitable(value): + return await value + return value + + +class PosthogASGIMiddleware: + """Add PostHog context and automatic exception capture to an ASGI app. + + Args: + app: The downstream ASGI application. + client: Optional destination for automatically captured exceptions. The + global client is used by default. Request context still enriches events + captured through any client and does not redirect capture calls. + capture_exceptions: Capture exceptions escaping the downstream app. If + omitted, inherit the effective client's ``enable_exception_autocapture`` + setting. Pass ``True`` or ``False`` to override it. + request_filter: Optional sync or async callback receiving the ASGI scope. + Returning ``False`` bypasses all instrumentation for that scope. + extra_properties: Optional sync or async callback receiving the ASGI scope and + returning additional event properties. + trust_tracing_headers: Use client-provided PostHog distinct and session ID + headers as analytics context. Disabled by default because these headers + are not authenticated. Enable only when deliberately accepting browser + attribution or when a trusted upstream replaces incoming values. Never + use these identifiers for authorization. + """ + + def __init__( + self, + app: _ASGIApp, + client: Optional[Client] = None, + capture_exceptions: Optional[bool] = None, + request_filter: Optional[_RequestFilter] = None, + extra_properties: Optional[_ExtraProperties] = None, + trust_tracing_headers: bool = False, + ) -> None: + self.app = app + self.client = client + self.capture_exceptions = capture_exceptions + self.request_filter = request_filter + self.extra_properties = extra_properties + self.trust_tracing_headers = trust_tracing_headers + + async def __call__(self, scope, receive, send) -> None: + if scope.get("type") not in {"http", "websocket"}: + await self.app(scope, receive, send) + return + + if self.request_filter and not await _resolve_callback_result( + self.request_filter(scope) + ): + await self.app(scope, receive, send) + return + + # Exception capture is explicit below so integration-specific mechanism + # metadata is preserved. The context itself must not capture a second time. + with contexts.new_context(capture_exceptions=False, client=self.client): + headers = _headers_from_scope(scope) + if self.trust_tracing_headers: + session_id = _decode_header(headers.get(b"x-posthog-session-id")) + if session_id: + contexts.set_context_session(session_id) + + distinct_id = _decode_header(headers.get(b"x-posthog-distinct-id")) + if distinct_id: + contexts.identify_context(distinct_id) + + properties = _extract_properties(scope, headers) + if self.extra_properties: + extra_properties = await _resolve_callback_result( + self.extra_properties(scope) + ) + if extra_properties: + properties.update(extra_properties) + for key, value in properties.items(): + contexts.tag(key, value) + + try: + await self.app(scope, receive, send) + except Exception as exception: + capture_exceptions = ( + self.capture_exceptions + if self.capture_exceptions is not None + else contexts._default_capture_exceptions(self.client) + ) + if capture_exceptions: + if self.client: + _capture_exception_with_metadata( + self.client, exception, _CAPTURE_METADATA + ) + else: + from .. import capture_exception + + cast(Any, capture_exception)( + exception, _capture_metadata=_CAPTURE_METADATA + ) + raise diff --git a/posthog/integrations/celery.py b/posthog/integrations/celery.py index bcd7b140f..59a513be4 100644 --- a/posthog/integrations/celery.py +++ b/posthog/integrations/celery.py @@ -67,10 +67,14 @@ import json import logging import time -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast from .. import contexts from ..client import Client +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) CONTEXT_DISTINCT_ID_HEADER = "X-POSTHOG-DISTINCT-ID" @@ -475,12 +479,17 @@ def _capture_event(self, event: str, properties: dict[str, Any]) -> None: capture(event, properties=properties) def _capture_exception(self, exception: Exception) -> None: + capture_metadata: _ExceptionCaptureMetadata = { + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + } if self.client: - self.client.capture_exception(exception) + _capture_exception_with_metadata(self.client, exception, capture_metadata) else: from posthog import capture_exception - capture_exception(exception) + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) __all__ = [ diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 6eba55058..7b973c0b7 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -1,8 +1,12 @@ import re -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast from .. import contexts from ..client import Client +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) try: from asgiref.sync import iscoroutinefunction, markcoroutinefunction @@ -65,18 +69,26 @@ class PosthogContextMiddleware: - Forwarded IP address as `$ip` - User agent as `$user_agent` - The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting - `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the - global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance - - The middleware behaviour is customisable through 3 additional functions: - - `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context. - - `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked. - - `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context. - - You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values. - - Context tags are automatically included as properties on all events captured within a context, including exceptions. + Configure the integration with these Django settings: + - `POSTHOG_MW_CLIENT`: client used for automatic exception capture, or the global client. + This does not redirect events explicitly captured through other clients. + - `POSTHOG_MW_CAPTURE_EXCEPTIONS`: `False` enables context enrichment only; + `True` enables automatic exception capture; `None` inherits the effective client's + `enable_exception_autocapture`. When omitted, the legacy default is `True`. + - `POSTHOG_MW_REQUEST_FILTER`: a callback receiving the request. Returning `False` + disables both enrichment and automatic exception capture for that request. + - `POSTHOG_MW_EXTRA_PROPERTIES`: a callback returning additional event properties. + - `POSTHOG_MW_TRUST_TRACING_HEADERS`: accept the client-controlled PostHog identity + and session headers only when `True` (default: `False`). These values are analytics + context, never authentication or authorization. Authenticated Django user identity + is still available when headers are disabled. + - `POSTHOG_MW_PROPERTIES_MAP`: an optional callback transforming request properties. + + The old `POSTHOG_MW_EXTRA_TAGS` and `POSTHOG_MW_TAG_MAP` settings remain aliases; + the property-named settings take precedence when both are configured. + + Request properties are included on all events captured within the active context, + including when automatic exception capture is disabled. See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to associate all events captured in the middleware context with the same distinct ID and session as currently active on the frontend. See the documentation for `set_context_session` @@ -109,15 +121,16 @@ def __init__(self, get_response): from django.conf import settings - if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable( - settings.POSTHOG_MW_EXTRA_TAGS - ): - self.extra_tags = cast( - "Optional[Callable[[HttpRequest], Dict[str, Any]]]", - settings.POSTHOG_MW_EXTRA_TAGS, - ) - else: - self.extra_tags = None + extra_properties = ( + settings.POSTHOG_MW_EXTRA_PROPERTIES + if hasattr(settings, "POSTHOG_MW_EXTRA_PROPERTIES") + else getattr(settings, "POSTHOG_MW_EXTRA_TAGS", None) + ) + self.extra_properties = ( + cast("Callable[[HttpRequest], Dict[str, Any]]", extra_properties) + if callable(extra_properties) + else None + ) if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable( settings.POSTHOG_MW_REQUEST_FILTER @@ -129,22 +142,29 @@ def __init__(self, get_response): else: self.request_filter = None - if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable( - settings.POSTHOG_MW_TAG_MAP - ): - self.tag_map = cast( - "Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]", - settings.POSTHOG_MW_TAG_MAP, - ) - else: - self.tag_map = None + properties_map = ( + settings.POSTHOG_MW_PROPERTIES_MAP + if hasattr(settings, "POSTHOG_MW_PROPERTIES_MAP") + else getattr(settings, "POSTHOG_MW_TAG_MAP", None) + ) + self.properties_map = ( + cast("Callable[[Dict[str, Any]], Dict[str, Any]]", properties_map) + if callable(properties_map) + else None + ) - if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance( - settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool - ): - self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS - else: - self.capture_exceptions = True + capture_exceptions = getattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS", True) + self.capture_exceptions: Optional[bool] = ( + capture_exceptions + if capture_exceptions is None or isinstance(capture_exceptions, bool) + else True + ) + trust_tracing_headers = getattr( + settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", False + ) + self.trust_tracing_headers = ( + trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False + ) if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance( settings.POSTHOG_MW_CLIENT, Client @@ -153,9 +173,32 @@ def __init__(self, get_response): else: self.client = None + @property + def extra_tags(self): + """Compatibility alias for ``extra_properties``.""" + return self.extra_properties + + @extra_tags.setter + def extra_tags(self, callback): + self.extra_properties = callback + + @property + def tag_map(self): + """Compatibility alias for ``properties_map``.""" + return self.properties_map + + @tag_map.setter + def tag_map(self, callback): + self.properties_map = callback + def extract_tags(self, request): # type: (HttpRequest) -> Dict[str, Any] - """Extract tags from request in sync context.""" + """Compatibility alias for ``extract_properties``.""" + return self.extract_properties(request) + + def extract_properties(self, request): + # type: (HttpRequest) -> Dict[str, Any] + """Extract event properties and identity from a synchronous request.""" user_id, user_email = self.extract_request_user(request) return self._build_tags(request, user_id, user_email) @@ -168,15 +211,17 @@ def _build_tags(self, request, user_id, user_email): """ tags = {} - # Extract session ID from X-POSTHOG-SESSION-ID header - session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID") + # Client-controlled headers are analytics context, not authenticated identity. + session_id = None + distinct_id = user_id + if self.trust_tracing_headers: + session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID") + distinct_id = ( + _get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID") + or user_id + ) if session_id: contexts.set_context_session(session_id) - - # Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id - distinct_id = ( - _get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID") or user_id - ) if distinct_id: contexts.identify_context(distinct_id) @@ -209,15 +254,14 @@ def _build_tags(self, request, user_id, user_email): tags["$user_agent"] = user_agent tags["$raw_user_agent"] = user_agent - # Apply extra tags if configured - if self.extra_tags: - extra = self.extra_tags(request) + # Apply application-specific properties, then the optional transform. + if self.extra_properties: + extra = self.extra_properties(request) if extra: tags.update(extra) - # Apply tag mapping if configured - if self.tag_map: - tags = self.tag_map(tags) + if self.properties_map: + tags = self.properties_map(tags) return tags @@ -228,9 +272,14 @@ def extract_request_user(self, request): return self._resolve_user_details(user) async def aextract_tags(self, request): + # type: (HttpRequest) -> Dict[str, Any] + """Compatibility alias for ``aextract_properties``.""" + return await self.aextract_properties(request) + + async def aextract_properties(self, request): # type: (HttpRequest) -> Dict[str, Any] """ - Async version of extract_tags for use in async request handling. + Async version of extract_properties for use in async request handling. Uses await request.auser() instead of request.user to avoid SynchronousOnlyOperation in async context. @@ -357,14 +406,24 @@ def process_exception(self, request, exception): if self.request_filter and not self.request_filter(request): return - if not self.capture_exceptions: + capture_exceptions = ( + self.capture_exceptions + if self.capture_exceptions is not None + else contexts._default_capture_exceptions(self.client) + ) + if not capture_exceptions: return # Context and tags already set by __call__ or __acall__ # Just capture the exception + capture_metadata: _ExceptionCaptureMetadata = { + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + } if self.client: - self.client.capture_exception(exception) + _capture_exception_with_metadata(self.client, exception, capture_metadata) else: - from posthog import capture_exception + from .. import capture_exception - capture_exception(exception) + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) diff --git a/posthog/integrations/drf.py b/posthog/integrations/drf.py new file mode 100644 index 000000000..6c66d342a --- /dev/null +++ b/posthog/integrations/drf.py @@ -0,0 +1,211 @@ +"""Django REST Framework exception handling integration. + +Django REST Framework (DRF) converts many exceptions into ``Response`` objects +before Django's middleware can observe them. Configure this module's +``exception_handler`` alongside :class:`PosthogContextMiddleware` to capture +handled server errors while leaving DRF's response behavior unchanged:: + + REST_FRAMEWORK = { + "EXCEPTION_HANDLER": "posthog.integrations.drf.exception_handler", + } + +This is an error-only handler: it captures handled response errors but does +not create the request context. Keep :class:`PosthogContextMiddleware` enabled +to attach Django request properties and capture exceptions that DRF re-raises. +By default, only responses with a 5xx status are captured; expected 4xx API +errors are ignored. + +``capture_exceptions`` follows the Django middleware setting. An explicit +factory argument wins over ``POSTHOG_MW_CAPTURE_EXCEPTIONS``. A boolean setting +wins next. Setting it explicitly to ``None`` inherits the effective PostHog +client's ``enable_exception_autocapture`` option, while an omitted or malformed +setting preserves the legacy Django default of ``True``. + +Projects that already have a custom DRF exception handler can wrap it in an +application module:: + + from myapp.api import existing_exception_handler + from posthog.integrations.drf import create_exception_handler + + exception_handler = create_exception_handler(existing_exception_handler) + +Then point ``REST_FRAMEWORK["EXCEPTION_HANDLER"]`` at that application-level +``exception_handler``. DRF is imported lazily, so importing the PostHog SDK does +not require DRF to be installed. +""" + +import logging +from typing import Any, Callable, Mapping, Optional, cast + +from ..client import Client +from ..contexts import _default_capture_exceptions +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, + exception_is_already_captured as _exception_is_already_captured, +) + +_logger = logging.getLogger("posthog") + +_CAPTURE_METADATA: _ExceptionCaptureMetadata = { + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": {"type": "middleware", "handled": True}, +} + + +def _default_exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + from rest_framework.views import exception_handler as drf_exception_handler + + return drf_exception_handler(exc, context) + + +def _configured_client(client: Optional[Client]) -> Optional[Client]: + if client is not None: + return client + + try: + from django.conf import settings + + for setting_name in ("POSTHOG_DRF_CLIENT", "POSTHOG_MW_CLIENT"): + configured_client = getattr(settings, setting_name, None) + if isinstance(configured_client, Client): + return configured_client + except Exception: + # Django may not be configured when a handler is created at import time. + pass + + return None + + +def _capture_exceptions_enabled( + configured: Optional[bool], client: Optional[Client] +) -> bool: + """Resolve explicit, Django, and client exception-capture configuration.""" + if configured is not None: + return configured + + try: + from django.conf import settings + + if not hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS"): + return True + django_setting = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS + except Exception: + return True + + if isinstance(django_setting, bool): + return django_setting + if django_setting is not None: + return True + + try: + client_default = _default_capture_exceptions(client) + except Exception: + return True + return client_default if isinstance(client_default, bool) else True + + +def _passes_django_request_filter(context: Mapping[str, Any]) -> bool: + """Apply the Django middleware request filter to the underlying request.""" + try: + from django.conf import settings + + request_filter = getattr(settings, "POSTHOG_MW_REQUEST_FILTER", None) + except Exception: + return True + + request = context.get("request") + if not callable(request_filter) or request is None: + return True + + # DRF wraps Django's HttpRequest. Pass the same object that + # PosthogContextMiddleware evaluates so filters behave consistently. + django_request = getattr(request, "_request", request) + return bool(request_filter(django_request)) + + +def _capture_exception(client: Optional[Client], exc: Exception) -> None: + if _exception_is_already_captured(exc): + return + + if client is not None: + _capture_exception_with_metadata(client, exc, _CAPTURE_METADATA) + else: + from .. import capture_exception + + cast(Any, capture_exception)(exc, _capture_metadata=_CAPTURE_METADATA) + + +def create_exception_handler( + handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, + *, + client: Optional[Client] = None, + capture_exceptions: Optional[bool] = None, + capture_4xx: bool = False, + exception_filter: Optional[ + Callable[[Exception, Any, Mapping[str, Any]], bool] + ] = None, +) -> Callable[[Exception, Mapping[str, Any]], Any]: + """Create a PostHog-instrumented DRF exception handler. + + Args: + handler: Handler to delegate to. Defaults to DRF's standard exception + handler. Its return value and raised exceptions are preserved. + client: Optional PostHog client. Client precedence is this argument, + the legacy ``POSTHOG_DRF_CLIENT`` alias, ``POSTHOG_MW_CLIENT``, then + the global PostHog client. + capture_exceptions: Whether to capture handled DRF exceptions. An + explicit value takes precedence over ``POSTHOG_MW_CAPTURE_EXCEPTIONS``. + A boolean setting is used directly; an explicit ``None`` setting + inherits the effective client's ``enable_exception_autocapture``. + An omitted or malformed setting preserves the legacy ``True`` default. + capture_4xx: Also capture handled 4xx responses. Disabled by default to + avoid reporting expected API errors. + exception_filter: Optional final filter called with ``(exception, + response, context)``. Returning ``False`` suppresses capture. + + When ``POSTHOG_MW_REQUEST_FILTER`` is configured, it is also applied to the + underlying Django request before capture so handled errors cannot bypass the + middleware's per-request exclusion. + + The delegated handler is called first. A ``None`` response is never + captured here because DRF will re-raise that exception, allowing Django's + middleware to capture it as unhandled. + """ + delegate = handler or _default_exception_handler + + def posthog_exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + response = delegate(exc, context) + if response is None: + return None + resolved_client = _configured_client(client) + if not _capture_exceptions_enabled(capture_exceptions, resolved_client): + return response + + try: + status_code = int(response.status_code) + should_capture = status_code >= 500 or ( + capture_4xx and 400 <= status_code < 500 + ) + if should_capture: + should_capture = _passes_django_request_filter(context) + if should_capture and exception_filter is not None: + should_capture = bool(exception_filter(exc, response, context)) + if should_capture: + _capture_exception(resolved_client, exc) + except Exception: + # Error tracking must never alter DRF's exception response. + _logger.exception("Failed to capture Django REST Framework exception") + + return response + + return posthog_exception_handler + + +def exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + """Capture handled DRF 5xx exceptions using DRF's default handler.""" + return _DEFAULT_EXCEPTION_HANDLER(exc, context) + + +_DEFAULT_EXCEPTION_HANDLER = create_exception_handler() diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py new file mode 100644 index 000000000..b23b62095 --- /dev/null +++ b/posthog/integrations/flask.py @@ -0,0 +1,285 @@ +"""Flask request context and exception tracking integration. + +Flask is imported lazily when :meth:`PosthogFlaskIntegration.init_app` is called, +so importing the PostHog SDK does not require Flask to be installed. + +Example:: + + from flask import Flask + from posthog.integrations.flask import PosthogFlaskIntegration + + app = Flask(__name__) + PosthogFlaskIntegration(app) + +The request context enriches events captured by any client while the request is +running. Passing ``client`` only selects where automatically captured exceptions +are sent; it does not redirect application calls to ``posthog.capture`` or +``another_client.capture``. +""" + +from __future__ import annotations + +import re +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, cast + +from .. import contexts +from ..client import Client +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) + +if TYPE_CHECKING: + from flask import Flask, Request + + +__all__ = ["PosthogFlaskIntegration"] + +_MAX_TRACING_HEADER_LENGTH = 1000 +_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_EXTENSION_KEY = "posthog" +_REQUEST_STATE_KEY = "_posthog_integration_state" + + +def _sanitize_tracing_header_value(value: object) -> Optional[str]: + """Return a bounded tracing header value safe for event properties.""" + if not isinstance(value, str) or not value: + return None + + return ( + _TRACING_HEADER_CONTROL_CHARS_RE.sub("", value).strip()[ + :_MAX_TRACING_HEADER_LENGTH + ] + or None + ) + + +@dataclass +class _RequestState: + scope: AbstractContextManager[None] + tracked: bool = True + + +@dataclass(frozen=True) +class _ExceptionPrivacySettings: + capture_code_variables: Optional[bool] + mask_patterns: Optional[list] + ignore_patterns: Optional[list] + mask_url_credentials: Optional[bool] + detect_secrets: Optional[bool] + + @classmethod + def from_current_context(cls) -> _ExceptionPrivacySettings: + """Snapshot effective privacy controls before entering a fresh request scope.""" + return cls( + capture_code_variables=contexts.get_capture_exception_code_variables_context(), + mask_patterns=contexts.get_code_variables_mask_patterns_context(), + ignore_patterns=contexts.get_code_variables_ignore_patterns_context(), + mask_url_credentials=contexts.get_code_variables_mask_url_credentials_context(), + detect_secrets=contexts.get_code_variables_detect_secrets_context(), + ) + + def apply(self) -> None: + """Apply inherited privacy controls without restoring identity or properties.""" + if self.capture_code_variables is not None: + contexts.set_capture_exception_code_variables_context( + self.capture_code_variables + ) + if self.mask_patterns is not None: + contexts.set_code_variables_mask_patterns_context(self.mask_patterns) + if self.ignore_patterns is not None: + contexts.set_code_variables_ignore_patterns_context(self.ignore_patterns) + if self.mask_url_credentials is not None: + contexts.set_code_variables_mask_url_credentials_context( + self.mask_url_credentials + ) + if self.detect_secrets is not None: + contexts.set_code_variables_detect_secrets_context(self.detect_secrets) + + +class PosthogFlaskIntegration: + """Add PostHog request context and exception tracking to a Flask app. + + Args: + app: An optional Flask application. If omitted, call :meth:`init_app` + later (the Flask application-factory pattern). + client: Optional destination for automatically captured exceptions. The + global client is used by default. Request context still enriches events + captured through any client and does not redirect capture calls. + capture_exceptions: Capture exceptions that reach Flask's unhandled + exception machinery. If omitted, inherit the effective client's + ``enable_exception_autocapture`` setting. Pass ``True`` or ``False`` + to override it. + request_filter: Optional callback receiving Flask's request object. A + false return value disables both context and exception capture for + that request. + extra_properties: Optional callback returning additional event properties. + This is useful for application-specific metadata such as an authenticated + user's role. Values should not contain secrets or request bodies. + trust_tracing_headers: Use client-provided PostHog distinct and session ID + headers as analytics context. Disabled by default because these headers + are not authenticated. Enable only when deliberately accepting browser + attribution or when a trusted upstream replaces incoming values. Never + use these identifiers for authorization. + + The integration intentionally does not capture exceptions handled by an + application error handler, expected HTTP exceptions, request or response + bodies, query strings, cookies, authorization headers, or arbitrary headers. + Call ``capture_exception`` explicitly from a custom error handler if a + handled exception should be reported. + """ + + def __init__( + self, + app: Optional[Flask] = None, + *, + client: Optional[Client] = None, + capture_exceptions: Optional[bool] = None, + request_filter: Optional[Callable[[Request], bool]] = None, + extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None, + trust_tracing_headers: bool = False, + ) -> None: + self.client = client + self.capture_exceptions = capture_exceptions + self.request_filter = request_filter + self.extra_properties = extra_properties + self.trust_tracing_headers = trust_tracing_headers + + if app is not None: + self.init_app(app) + + def init_app(self, app: Flask) -> None: + """Register the integration with a Flask application once.""" + try: + from flask import got_request_exception + except ImportError as error: # pragma: no cover - exercised without Flask + raise RuntimeError( + "PosthogFlaskIntegration requires Flask to be installed" + ) from error + + if _EXTENSION_KEY in app.extensions: + raise RuntimeError("PostHog is already initialized for this Flask app") + + app.extensions[_EXTENSION_KEY] = self + app.before_request(self._before_request) + app.teardown_request(self._teardown_request) + got_request_exception.connect(self._handle_unhandled_exception, app, weak=False) + + def _before_request(self) -> None: + from flask import g, request + + if self.request_filter is not None and not self.request_filter(request): + setattr(g, _REQUEST_STATE_KEY, None) + return + + # A request gets fresh identity and event properties, but privacy controls + # must remain at least as strict as the effective enclosing context. + privacy_settings = _ExceptionPrivacySettings.from_current_context() + + # Flask handles application exceptions before returning control through + # the request stack, so capture through got_request_exception rather than + # through new_context. This also avoids duplicate capture. + scope = contexts.new_context( + fresh=True, + capture_exceptions=False, + client=self.client, + ) + scope.__enter__() + privacy_settings.apply() + setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope)) + + if self.trust_tracing_headers: + session_id = _sanitize_tracing_header_value( + request.headers.get("X-POSTHOG-SESSION-ID") + ) + if session_id: + contexts.set_context_session(session_id) + + distinct_id = _sanitize_tracing_header_value( + request.headers.get("X-POSTHOG-DISTINCT-ID") + ) + if distinct_id: + contexts.identify_context(distinct_id) + + for key, value in self._request_properties(request).items(): + contexts.tag(key, value) + + if self.extra_properties is not None: + extra_properties = self.extra_properties(request) + if extra_properties: + for key, value in extra_properties.items(): + contexts.tag(key, value) + + @staticmethod + def _request_properties(request: Request) -> dict[str, Any]: + properties: dict[str, Any] = { + # base_url deliberately excludes query strings, which commonly + # contain credentials, tokens, and other sensitive values. + "$current_url": request.base_url, + "$request_method": request.method, + "$request_path": request.path, + } + + if request.remote_addr: + properties["$ip"] = request.remote_addr + + user_agent = request.headers.get("User-Agent") + if user_agent: + properties["$user_agent"] = user_agent + properties["$raw_user_agent"] = user_agent + + url_rule = getattr(request, "url_rule", None) + if url_rule is not None: + properties["$request_route"] = str(url_rule) + + return properties + + def _handle_unhandled_exception( + self, sender: Flask, exception: BaseException, **kwargs: Any + ) -> None: + if not self._request_is_tracked(): + return + capture_exceptions = ( + self.capture_exceptions + if self.capture_exceptions is not None + else contexts._default_capture_exceptions(self.client) + ) + if not capture_exceptions: + return + + capture_metadata: _ExceptionCaptureMetadata = { + "level": "error", + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, + } + if self.client is not None: + _capture_exception_with_metadata(self.client, exception, capture_metadata) + else: + # Keep this import relative so the generated posthoganalytics mirror + # resolves its own global client rather than the posthog package. + from .. import capture_exception + + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) + + @staticmethod + def _request_is_tracked() -> bool: + from flask import g, has_request_context + + if not has_request_context(): + return False + state = getattr(g, _REQUEST_STATE_KEY, None) + return isinstance(state, _RequestState) and state.tracked + + def _teardown_request(self, exception: Optional[BaseException]) -> None: + from flask import g + + state = getattr(g, _REQUEST_STATE_KEY, None) + if not isinstance(state, _RequestState): + return + + # The Flask signal already captured any unhandled exception. Close the + # context normally so new_context cannot capture it a second time. + setattr(g, _REQUEST_STATE_KEY, None) + state.scope.__exit__(None, None, None) diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 9db9bc31e..0e8cfe1a4 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -31,10 +31,9 @@ analytics = instrument(server, posthog) Install is just `pip install posthog`. `instrument()` needs the MCP SDK at runtime, but anyone wrapping a server already has it. -MCP analytics events report `$lib: "posthog-python-mcp"` and the installed `posthog` package version in `$lib_version`. -Request headers use the same identity and package version, so SDK Health can compare MCP traffic with Python SDK releases. -Because `$lib` is a client-level identity, `instrument()` relabels every event sent by the client passed to it. -Use a client dedicated to MCP analytics if the application also captures unrelated events. +MCP analytics events report `$lib: "posthog-python"` and the installed `posthog` package version in `$lib_version`, like every other event the client sends. +PostHog sets both from the client's `PostHog-Sdk-Info` request header, so `instrument()` and `PostHogMCP` do not relabel the client. +Filter MCP traffic by its `$mcp_*` events and properties instead. ## Defaults and opt-outs diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 70d37eb48..015c9ee58 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -351,11 +351,6 @@ def instrument( "on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server." ) - if client is not None: - from ._lib_identity import apply_mcp_lib_identity - - apply_mcp_lib_identity(client) - # Zero-config stateless minting: wrap the server's ASGI-app factories so a # stateless/multi-pod deployment keeps one $session_id + the client harness # across pods with no extra setup. No-op for stdio / low-level servers. diff --git a/posthog/mcp/_argument_ownership.py b/posthog/mcp/_argument_ownership.py new file mode 100644 index 000000000..3ade16fec --- /dev/null +++ b/posthog/mcp/_argument_ownership.py @@ -0,0 +1,98 @@ +"""Resolve ownership of arguments that PostHog adds to MCP tool schemas.""" + +from __future__ import annotations + +import inspect +from collections.abc import Mapping +from typing import Any, Dict, FrozenSet, Optional, Tuple + +from ._context_parameters import is_context_enabled, schema_has_param +from ._model_parameters import is_capture_model_enabled +from .logger import log + +_COMPLEX_SCHEMA_KEYS = ("$ref", "oneOf", "allOf", "anyOf") + + +def analytics_owned_parameters( + options: Any, + input_schema: Any, +) -> FrozenSet[str]: + """Return the enabled arguments that PostHog can add to this schema.""" + enabled = set() + if is_context_enabled(options.context): + enabled.add("context") + if options.enable_conversation_id: + enabled.add("conversation_id") + if is_capture_model_enabled(options.capture_model): + enabled.add("llm_model") + + if isinstance(input_schema, dict) and any( + input_schema.get(key) for key in _COMPLEX_SCHEMA_KEYS + ): + return frozenset() + return frozenset( + name for name in enabled if not schema_has_param(input_schema, name) + ) + + +def cache_listed_tool_ownership( + data: Any, tool: Any, *, schema_attribute: str +) -> FrozenSet[str]: + """Cache ownership from the host schema before PostHog changes it.""" + name = getattr(tool, "name", None) + if not isinstance(name, str): + return frozenset() + schema = getattr(tool, schema_attribute, None) + ownership = analytics_owned_parameters(data.options, schema) + data.tool_analytics_parameter_ownership[name] = ownership + if isinstance(schema, dict): + data.tool_input_schemas[name] = schema + else: + data.tool_input_schemas.pop(name, None) + return ownership + + +async def resolve_lowlevel_tool_ownership( + data: Any, name: str +) -> Tuple[Optional[FrozenSet[str]], Optional[Dict[str, Any]]]: + """Resolve one raw tool. A served listing on this instance has priority.""" + if name in data.tool_analytics_parameter_ownership: + return ( + data.tool_analytics_parameter_ownership[name], + data.tool_input_schemas.get(name), + ) + + resolver = data.options.resolve_original_tool + if resolver is None: + return None, None + + try: + descriptor = resolver(name) + if inspect.isawaitable(descriptor): + descriptor = await descriptor + if descriptor is None: + return None, None + schema = _descriptor_input_schema(descriptor) + if not isinstance(schema, dict): + log( + f"Warning: resolve_original_tool failed for tool {name!r}: " + "resolver returned no usable input schema" + ) + return None, None + return analytics_owned_parameters(data.options, schema), schema + except Exception as error: # noqa: BLE001 - analytics must not break dispatch + log(f"Warning: resolve_original_tool failed for tool {name!r}: {error}") + return None, None + + +def _descriptor_input_schema(descriptor: Any) -> Any: + """Read MCP 1.x, MCP 2.x, and dictionary tool descriptors.""" + if isinstance(descriptor, Mapping): + if "inputSchema" in descriptor: + return descriptor["inputSchema"] + return descriptor.get("input_schema") + + schema = getattr(descriptor, "input_schema", None) + if schema is not None: + return schema + return getattr(descriptor, "inputSchema", None) diff --git a/posthog/mcp/_context_parameters.py b/posthog/mcp/_context_parameters.py index 015663d6b..943d13764 100644 --- a/posthog/mcp/_context_parameters.py +++ b/posthog/mcp/_context_parameters.py @@ -48,10 +48,11 @@ def add_context_parameter_to_schema( """Return a new JSON Schema dict with a ``context`` string property added. Returns the input unchanged (logging a warning) for schemas that already - define ``context`` or use ``oneOf``/``allOf``/``anyOf``. ``required`` controls - whether ``context`` is added to the schema's ``required`` list — pass ``False`` - where the advertised schema is also used to validate inbound calls (the - low-level server), so a call omitting ``context`` is not rejected.""" + define ``context`` or use ``$ref``/``oneOf``/``allOf``/``anyOf``. ``required`` + controls whether ``context`` is added to the schema's ``required`` list — + pass ``False`` where the advertised schema is also used to validate inbound + calls (the low-level server), so a call omitting ``context`` is not rejected. + """ schema = input_schema if ( @@ -64,9 +65,10 @@ def add_context_parameter_to_schema( ) return schema - if schema and (schema.get("oneOf") or schema.get("allOf") or schema.get("anyOf")): + if schema and any(schema.get(key) for key in ("$ref", "oneOf", "allOf", "anyOf")): log( - f'WARN: Tool "{tool_name}" has complex schema (oneOf/allOf/anyOf). Skipping context injection.' + f'WARN: Tool "{tool_name}" has complex schema ' + "($ref/oneOf/allOf/anyOf). Skipping context injection." ) return schema diff --git a/posthog/mcp/_conversation_id.py b/posthog/mcp/_conversation_id.py index 506f878ce..d3c774516 100644 --- a/posthog/mcp/_conversation_id.py +++ b/posthog/mcp/_conversation_id.py @@ -35,7 +35,9 @@ def add_conversation_id_to_schema( input_schema: Optional[Dict[str, Any]], tool_name: str = "unknown" ) -> Optional[Dict[str, Any]]: """Return a new JSON Schema with an optional ``conversation_id`` string property. - Skips schemas that already define it or use ``oneOf``/``allOf``/``anyOf``.""" + Skips schemas that already define it or use + ``$ref``/``oneOf``/``allOf``/``anyOf``. + """ schema = input_schema if ( schema @@ -48,7 +50,7 @@ def add_conversation_id_to_schema( f"WARN: Tool \"{tool_name}\" already has '{CONVERSATION_ID_PARAM_NAME}'. Skipping injection." ) return schema - if schema and (schema.get("oneOf") or schema.get("allOf") or schema.get("anyOf")): + if schema and any(schema.get(key) for key in ("$ref", "oneOf", "allOf", "anyOf")): log( f'WARN: Tool "{tool_name}" has complex schema. Skipping conversation_id injection.' ) diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index f8e476283..00bd4ef4d 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -22,6 +22,10 @@ import mcp.types as mcp_types +from ._argument_ownership import ( + cache_listed_tool_ownership, + resolve_lowlevel_tool_ownership, +) from ._context_parameters import is_context_enabled, schema_has_param from ._conversation_id import build_prompt_back from ._event_types import MCPAnalyticsEventType @@ -29,6 +33,7 @@ advertised_tool_names, apply_virtual_tool_injection, collect_listed_tools, + copy_tools_list_result, extract_tools, is_first_listing_page, mutate_tool_schema, @@ -54,9 +59,11 @@ def instrument_low_level(server: Any, data: MCPAnalyticsData) -> None: - """Instrument a raw ``mcp.server.Server``. ``context`` is injected as an - optional schema property and NOT stripped — that schema is also the call's - validation schema, and a typical ``(name, arguments)`` handler ignores extra keys.""" + """Instrument a raw ``mcp.server.Server``. + + The adapter removes arguments that a listing or resolver proves PostHog + owns. Unknown arguments pass through unchanged. + """ data.server_name = getattr(server, "name", None) data.server_version = getattr(server, "version", None) _wrap_call_tool(server, data, strip_injected=False) @@ -186,11 +193,21 @@ def _wrap_call_tool( async def handler(req: Any) -> Any: name = req.params.name arguments = dict(req.params.arguments or {}) - strip, model_ours, input_schema = ( - await _standalone_ownership(data, high_level, name, req.params.meta) - if strip_injected - else (set(), data.tool_model_parameter_injected.get(name), None) - ) + parameter_ownership = None + if strip_injected: + strip, model_ours, input_schema = await _standalone_ownership( + data, high_level, name, req.params.meta + ) + else: + parameter_ownership, input_schema = await resolve_lowlevel_tool_ownership( + data, name + ) + strip = set(parameter_ownership or ()) + model_ours = ( + "llm_model" in parameter_ownership + if parameter_ownership is not None + else data.tool_model_parameter_injected.get(name) + ) client_name, client_version = _client_info(server) protocol_version = _protocol_version(server) mcp_session_id = _mcp_session_id(server) @@ -213,6 +230,7 @@ async def handler(req: Any) -> Any: protocol_version=protocol_version, extra={"session_id": mcp_session_id, "ctx": _request_context(server)}, input_schema=input_schema, + analytics_owned_parameters=parameter_ownership, ) if lifecycle.is_missing_capability and ( @@ -327,6 +345,7 @@ async def handler(req: Any) -> Any: def _inject_tool_schemas( + server: Any, data: MCPAnalyticsData, tools: list, *, @@ -345,6 +364,7 @@ def _inject_tool_schemas( verdicts: Dict[str, bool] = {} for tool in tools: schema = getattr(tool, "inputSchema", None) + cache_listed_tool_ownership(data, tool, schema_attribute="inputSchema") mutate_tool_schema( data, tool, @@ -363,6 +383,11 @@ def _inject_tool_schemas( # Which one dispatches is unknown, so the strip fails closed. data.tool_model_parameter_injected[tool.name] = False + cache = getattr(server, "_tool_cache", None) + if isinstance(cache, dict): + for tool in tools: + cache[tool.name] = tool + def _wrap_list_tools( server: Any, @@ -404,16 +429,18 @@ async def probe_raw_tool_names(_ctx: Any = None) -> Optional[Set[str]]: "registering your handlers." ) return None - result = await original(mcp_types.ListToolsRequest(method="tools/list")) + result = copy_tools_list_result( + await original(mcp_types.ListToolsRequest(method="tools/list")) + ) tools = extract_tools(result) - # `original` is usually the SDK's own list_tools decorator, which rebuilds - # `Server._tool_cache` from these un-injected schemas every time it runs. - # That cache is what the SDK validates real tool arguments against, so - # without re-injecting here the next real call is rejected for sending the - # `context` we advertised. Same reason the `req is None` branch below - # injects. + # Resolve ownership from the host listing. Apply injection only to the + # copy so a shared host descriptor remains original. _inject_tool_schemas( - data, tools, context_required=context_required, high_level=high_level + server, + data, + tools, + context_required=context_required, + high_level=high_level, ) return advertised_tool_names(tools) @@ -421,17 +448,17 @@ async def probe_raw_tool_names(_ctx: Any = None) -> Optional[Set[str]]: async def handler(req: Any) -> Any: # The server calls the handler with None to populate its tool cache. - # Skip analytics there — but still inject, because that cache is the - # schema the SDK validates calls against. This adapter advertises - # `context`/`conversation_id` without stripping them, so a cache built - # from un-injected schemas rejects the very arguments we told the agent - # to send ("Additional properties are not allowed") on any tool with - # `additionalProperties: false`. + # Skip analytics there, but return the same injected schema as a client + # listing. if req is None: - result = await original(req) + result = copy_tools_list_result(await original(req)) tools = extract_tools(result) _inject_tool_schemas( - data, tools, context_required=context_required, high_level=high_level + server, + data, + tools, + context_required=context_required, + high_level=high_level, ) return result @@ -464,7 +491,7 @@ async def handler(req: Any) -> Any: start = time.monotonic() try: - result = await original(req) + result = copy_tools_list_result(await original(req)) except Exception as error: await lifecycle.record_error(error, (time.monotonic() - start) * 1000) raise @@ -481,7 +508,11 @@ async def handler(req: Any) -> Any: ) _inject_tool_schemas( - data, tools, context_required=context_required, high_level=high_level + server, + data, + tools, + context_required=context_required, + high_level=high_level, ) result = apply_virtual_tool_injection( diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index 8191eedc8..843f2d032 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -37,6 +37,10 @@ import mcp.types as mcp_types +from ._argument_ownership import ( + cache_listed_tool_ownership, + resolve_lowlevel_tool_ownership, +) from ._context_parameters import is_context_enabled, schema_has_param from ._conversation_id import build_prompt_back from ._event_types import MCPAnalyticsEventType @@ -44,6 +48,7 @@ advertised_tool_names, apply_virtual_tool_injection, collect_listed_tools, + copy_tools_list_result, is_first_listing_page, mutate_tool_schema, params_to_request_dict, @@ -503,21 +508,30 @@ async def handler(ctx: Any, params: Any) -> Any: # reads the self-reported model anyway; only a listing that proved the # application owns `llm_model` stops it (posthog-js ADR-0011). analytics_owns_model = data.tool_model_parameter_injected.get(name) is not False - input_schema = None standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None + parameter_ownership = None + input_schema = None if standalone is not None: version = _requested_tool_version(ctx) injected, input_schema = await _standalone_injected_parameters( standalone, data, name, version ) if injected is not None: + parameter_ownership = injected analytics_owns_model = "llm_model" in injected - call_arguments = { - key: value - for key, value in arguments.items() - if key not in injected - } - params = params.model_copy(update={"arguments": call_arguments}) + else: + parameter_ownership, input_schema = await resolve_lowlevel_tool_ownership( + data, name + ) + if parameter_ownership is not None: + analytics_owns_model = "llm_model" in parameter_ownership + if parameter_ownership is not None: + call_arguments = { + key: value + for key, value in arguments.items() + if key not in parameter_ownership + } + params = params.model_copy(update={"arguments": call_arguments}) token, client_name, client_version, protocol_version, mcp_session_id = ( _resolve_ctx(ctx) ) @@ -534,6 +548,7 @@ async def handler(ctx: Any, params: Any) -> Any: protocol_version=protocol_version, extra={"session_id": mcp_session_id, "ctx": ctx}, input_schema=input_schema, + analytics_owned_parameters=parameter_ownership, ) # No tool registry on a raw low-level server, so ownership is settled @@ -700,7 +715,7 @@ async def handler(ctx: Any, params: Any) -> Any: start = time.monotonic() try: - result = await original(ctx, params) + result = copy_tools_list_result(await original(ctx, params)) except Exception as error: await lifecycle.record_error(error, (time.monotonic() - start) * 1000) raise @@ -715,6 +730,7 @@ async def handler(ctx: Any, params: Any) -> Any: for tool in tools: schema = getattr(tool, "input_schema", None) + cache_listed_tool_ownership(data, tool, schema_attribute="input_schema") owns_context = ( _tool_owns_param_v2(high_level, tool.name, "context") if high_level is not None @@ -728,7 +744,6 @@ async def handler(ctx: Any, params: Any) -> Any: context_required=context_required, is_sdk_virtual_tool=False, ) - result = apply_virtual_tool_injection( result, injection, names, data, schema_field="input_schema" ) diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 5a82fa035..167969069 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -10,11 +10,12 @@ import asyncio import concurrent.futures +import copy import os import threading from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Dict, List, Literal, Optional, Set +from typing import Any, Dict, FrozenSet, List, Literal, Optional, Set from ._capture import capture_event from ._context_parameters import ( @@ -434,6 +435,7 @@ class ToolCallLifecycle: arguments: Optional[Dict[str, Any]] request_meta: Optional[Dict[str, Any]] allow_self_reported_model: bool + analytics_owned_parameters: Optional[FrozenSet[str]] request: Dict[str, Any] extra: Dict[str, Any] mcp_session_id: Optional[str] @@ -541,6 +543,7 @@ async def record_error(self, error: Any, duration_ms: float) -> None: arguments=self.arguments, request_meta=self.request_meta, allow_self_reported_model=self.allow_self_reported_model, + analytics_owned_parameters=self.analytics_owned_parameters, error=error, duration_ms=duration_ms, client_name=self.client_name, @@ -563,6 +566,7 @@ async def record_result( arguments=self.arguments, request_meta=self.request_meta, allow_self_reported_model=self.allow_self_reported_model, + analytics_owned_parameters=self.analytics_owned_parameters, result=result, duration_ms=duration_ms, client_name=self.client_name, @@ -588,6 +592,7 @@ def start_tool_call_lifecycle( protocol_version: Optional[str], extra: Dict[str, Any], input_schema: Any = None, + analytics_owned_parameters: Optional[FrozenSet[str]] = None, ) -> ToolCallLifecycle: """Resolve adapter-independent policy for a tool call without dispatching it.""" enabled = enabled_virtual_tool_names(data) @@ -597,7 +602,12 @@ def start_tool_call_lifecycle( # running the host's `on_feedback` handler read the configured options. feedback_options = resolve_collect_feedback_options(data.options.collect_feedback) conversation_id, minted = resolve_conversation_id( - data.options.enable_conversation_id, arguments + data.options.enable_conversation_id + and ( + analytics_owned_parameters is None + or "conversation_id" in analytics_owned_parameters + ), + arguments, ) # A carried session stays stable until the agent supplies its own handle. has_carried_session = token is not None or bool(mcp_session_id) @@ -609,6 +619,7 @@ def start_tool_call_lifecycle( arguments=arguments, request_meta=request_meta, allow_self_reported_model=allow_self_reported_model, + analytics_owned_parameters=analytics_owned_parameters, request=build_tool_call_request(name, arguments), extra=extra, mcp_session_id=mcp_session_id, @@ -642,6 +653,7 @@ async def record_tool_call( conversation_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, input_schema: Any = None, + analytics_owned_parameters: Optional[FrozenSet[str]] = None, ) -> None: # Analytics must never change what the tool returns or raises: any failure # building/publishing the event is logged and swallowed here. @@ -654,7 +666,13 @@ async def record_tool_call( "tool_description": data.tool_descriptions.get(name), "tool_category": data.tool_categories.get(name), "parameters": build_captured_mcp_parameters( - request, strip_llm_model=allow_self_reported_model + request, + strip_llm_model=allow_self_reported_model, + strip_argument_names=( + set(analytics_owned_parameters) + if analytics_owned_parameters is not None + else None + ), ), "duration": duration_ms, "client_name": client_name, @@ -663,7 +681,18 @@ async def record_tool_call( "conversation_id": conversation_id, "is_error": False, } - set_event_intent(event, await resolve_tool_call_intent(data, request, extra)) + set_event_intent( + event, + await resolve_tool_call_intent( + data, + request, + extra, + allow_context_argument=( + analytics_owned_parameters is None + or "context" in analytics_owned_parameters + ), + ), + ) if is_capture_model_enabled(data.options.capture_model): model, source = resolve_model( request_meta, @@ -714,6 +743,17 @@ def extract_tools(result: Any) -> list: return list(getattr(root, "tools", []) or []) +def copy_tools_list_result(result: Any) -> Any: + """Copy a tool listing before schema injection changes its descriptors.""" + try: + return result.model_copy(deep=True) + except Exception: # noqa: BLE001 - analytics must not break a listing + try: + return copy.deepcopy(result) + except Exception: # noqa: BLE001 + return result + + def tools_list_envelope(result: Any) -> Optional[Dict[str, Any]]: """The ``tools/list`` result minus its tools, or None when nothing else is set. diff --git a/posthog/mcp/_intent.py b/posthog/mcp/_intent.py index 076a3e1d8..e8a3b3165 100644 --- a/posthog/mcp/_intent.py +++ b/posthog/mcp/_intent.py @@ -55,6 +55,8 @@ async def resolve_tool_call_intent( data: MCPAnalyticsData, request: Dict[str, Any], extra: Optional[Dict[str, Any]] = None, + *, + allow_context_argument: bool = True, ) -> Optional[ResolvedIntent]: from ._instrumentation import ( VIRTUAL_TOOL_MISSING_CAPABILITY, @@ -71,6 +73,7 @@ async def resolve_tool_call_intent( missing_name = enabled_virtual_tool_names(data).get(VIRTUAL_TOOL_MISSING_CAPABILITY) if ( is_context_enabled(data.options.context) + and allow_context_argument and (missing_name is None or name != missing_name) and context_argument ): diff --git a/posthog/mcp/_internal.py b/posthog/mcp/_internal.py index e1cdecef0..12bc83d28 100644 --- a/posthog/mcp/_internal.py +++ b/posthog/mcp/_internal.py @@ -17,7 +17,7 @@ from collections import OrderedDict from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Awaitable, Callable, Dict, Optional, Set, Tuple +from typing import Any, Awaitable, Callable, Dict, FrozenSet, Optional, Set, Tuple from .logger import log from ._sink import McpEventSink @@ -91,6 +91,14 @@ class MCPAnalyticsData: # True only when PostHog added llm_model to this tool's advertised schema. # Missing/False fails closed so an application-owned field is never read or stripped. tool_model_parameter_injected: Dict[str, bool] = field(default_factory=dict) + # Ownership learned from tools/list on this instance has priority over the + # low-level resolver callback. + tool_analytics_parameter_ownership: Dict[str, FrozenSet[str]] = field( + default_factory=dict + ) + # Original schemas used to resolve ownership. They also identify safe input + # names without recording argument values. + tool_input_schemas: Dict[str, Dict[str, Any]] = field(default_factory=dict) # Which tools got `_mcp_instructions` declared on their advertised output # schema at tools/list. Only those may be mirrored into on a call — writing # an undeclared key fails the customer's whole result under diff --git a/posthog/mcp/_lib_identity.py b/posthog/mcp/_lib_identity.py deleted file mode 100644 index 02bb11e57..000000000 --- a/posthog/mcp/_lib_identity.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Apply the MCP-specific identity to the underlying PostHog client.""" - -from __future__ import annotations - -from ..client import Client -from ..version import VERSION - -from .constants import POSTHOG_MCP_LIB_NAME - - -def apply_mcp_lib_identity(client: Client) -> None: - """Relabel every event sent by ``client`` as coming from ``posthog.mcp``.""" - set_identity = getattr(client, "_set_library_identity", None) - if set_identity is not None: - set_identity(POSTHOG_MCP_LIB_NAME, VERSION) diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index 90040011a..15e80fa98 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone from typing import Any, Dict, List +from posthog.capture_event import _merge_groups + from .constants import ( POSTHOG_MCP_ANALYTICS_SOURCE, PostHogMCPAnalyticsEvent, @@ -59,13 +61,15 @@ def build_posthog_capture_events( def _build_capture_event(event: Event) -> PostHogCaptureEvent: - properties: Dict[str, Any] = {_P.SOURCE: POSTHOG_MCP_ANALYTICS_SOURCE} + properties: Dict[str, Any] = { + _P.INTERFACE: "mcp", + _P.SOURCE: POSTHOG_MCP_ANALYTICS_SOURCE, + } _add_session_id(event, properties) _add_conversation_id(event, properties) - _add_person_processing(event, properties) - _add_groups(event, properties) _add_common_properties(event, properties) _add_custom_properties(event, properties) + _add_groups(event, properties) _add_server_build(event, properties) event_name = ( @@ -75,6 +79,7 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent: "event": event_name, "distinct_id": _get_distinct_id(event), "properties": properties, + "options": _get_options(event), "timestamp": _get_timestamp(event), } @@ -92,16 +97,19 @@ def _add_conversation_id(event: Event, properties: Dict[str, Any]) -> None: def _add_groups(event: Event, properties: Dict[str, Any]) -> None: + """Merge the typed groups over a custom ``$groups`` property, key by key.""" groups = event.get("groups") if groups: - properties["$groups"] = groups + _merge_groups(properties, groups) -def _add_person_processing(event: Event, properties: Dict[str, Any]) -> None: +def _get_options(event: Event) -> Dict[str, Any]: # Without a resolved identity the distinct id is just the session id, so # processing a person profile would mint one anonymous person per session. + # As an option it beats any custom $process_person_profile property. if not event.get("identify_actor_given_id"): - properties["$process_person_profile"] = False + return {"process_person_profile": False} + return {} def _is_tool_call(event: Event) -> bool: @@ -260,11 +268,9 @@ def _add_server_build(event: Event, properties: Dict[str, Any]) -> None: def _build_exception_event(event: Event) -> PostHogCaptureEvent: - properties: Dict[str, Any] = {} + properties: Dict[str, Any] = {_P.INTERFACE: "mcp"} _add_session_id(event, properties) _add_conversation_id(event, properties) - _add_person_processing(event, properties) - _add_groups(event, properties) error = event.get("error") if error: @@ -292,11 +298,13 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties[_P.PROTOCOL_VERSION] = event["protocol_version"] _add_custom_properties(event, properties) + _add_groups(event, properties) _add_server_build(event, properties) return { "event": PostHogMCPAnalyticsEvent.EXCEPTION, "distinct_id": _get_distinct_id(event), "properties": properties, + "options": _get_options(event), "timestamp": _get_timestamp(event), } diff --git a/posthog/mcp/_sanitization.py b/posthog/mcp/_sanitization.py index 0600ab69a..b14f4796d 100644 --- a/posthog/mcp/_sanitization.py +++ b/posthog/mcp/_sanitization.py @@ -11,7 +11,7 @@ from __future__ import annotations import re -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import SplitResult, parse_qsl, urlencode, urlsplit, urlunsplit # SDK-injected arguments stripped from captured $mcp_parameters (they surface as @@ -740,7 +740,10 @@ def _sanitize_resource_block(block: Dict[str, Any]) -> Any: def build_captured_mcp_parameters( - request: Any, *, strip_llm_model: bool = False + request: Any, + *, + strip_llm_model: bool = False, + strip_argument_names: Optional[Set[str]] = None, ) -> Dict[str, Any]: """Build the sanitized ``$mcp_parameters`` payload from a request, stripping the injected ``context`` argument before logging.""" @@ -752,35 +755,45 @@ def build_captured_mcp_parameters( if key in request: captured_request[key] = sanitize_captured_value(request[key]) + names_to_strip = strip_argument_names + if names_to_strip is None: + names_to_strip = set(_INJECTED_ARGUMENT_NAMES) + if strip_llm_model: + names_to_strip.add("llm_model") + if "params" in request: captured_request["params"] = _build_captured_mcp_params( - request["params"], strip_llm_model=strip_llm_model + request["params"], strip_argument_names=names_to_strip ) return {"request": captured_request} -def _build_captured_mcp_params(params: Any, *, strip_llm_model: bool) -> Any: +def _build_captured_mcp_params(params: Any, *, strip_argument_names: Set[str]) -> Any: if not _is_record(params): return sanitize_captured_value(params) captured: Dict[str, Any] = {} for key, value in params.items(): captured[key] = ( - _build_captured_mcp_arguments(value, strip_llm_model=strip_llm_model) + _build_captured_mcp_arguments( + value, strip_argument_names=strip_argument_names + ) if key == "arguments" else sanitize_captured_value(value) ) return captured -def _build_captured_mcp_arguments(arguments: Any, *, strip_llm_model: bool) -> Any: +def _build_captured_mcp_arguments( + arguments: Any, *, strip_argument_names: Set[str] +) -> Any: if not _is_record(arguments): return sanitize_captured_value(arguments) captured: Dict[str, Any] = {} for key, value in arguments.items(): - if key in _INJECTED_ARGUMENT_NAMES or (strip_llm_model and key == "llm_model"): + if key in strip_argument_names: continue captured[key] = sanitize_captured_value(value) return captured diff --git a/posthog/mcp/_sink.py b/posthog/mcp/_sink.py index 7d1db967f..15a5e789a 100644 --- a/posthog/mcp/_sink.py +++ b/posthog/mcp/_sink.py @@ -106,6 +106,7 @@ async def capture(self, event: McpEvent, options: McpCaptureOptions) -> None: capture_event["event"], distinct_id=capture_event["distinct_id"], properties=capture_event["properties"], + options=capture_event.get("options"), timestamp=capture_event.get("timestamp"), uuid=_uuid7(), ) diff --git a/posthog/mcp/constants.py b/posthog/mcp/constants.py index ee8cf96ae..525529403 100644 --- a/posthog/mcp/constants.py +++ b/posthog/mcp/constants.py @@ -43,7 +43,6 @@ ) POSTHOG_MCP_ANALYTICS_SOURCE = "posthog_mcp_analytics" -POSTHOG_MCP_LIB_NAME = "posthog-python-mcp" class PostHogMCPAnalyticsEvent: @@ -84,6 +83,7 @@ class PostHogMCPAnalyticsProperty: FEEDBACK_TASK_COMPLETED = "$mcp_feedback_task_completed" FEEDBACK_TOOL = "$mcp_feedback_tool" FEEDBACK_TYPE = "$mcp_feedback_type" + INTERFACE = "$mcp_interface" IS_ERROR = "$mcp_is_error" INTENT = "$mcp_intent" INTENT_SOURCE = "$mcp_intent_source" diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index 49f892621..b91a29eee 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -42,7 +42,6 @@ fire_and_forget, virtual_tool_collision_message, ) -from ._lib_identity import apply_mcp_lib_identity from .logger import log, warn from ._model_parameters import ( add_model_parameter_to_schema, @@ -122,7 +121,6 @@ def __init__( ) -> None: self._server_build = validate_server_build(server_build) super().__init__(api_key, **kwargs) - apply_mcp_lib_identity(self) self._mcp_sink = McpEventSink(self) self._missing_capability_tool_name = ( missing_capability_tool_name or _GET_MORE_TOOLS_NAME diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index f65ae0d26..2e2ff5696 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -78,6 +78,7 @@ class PostHogCaptureEvent(TypedDict, total=False): distinct_id: str event: str properties: Dict[str, Any] + options: Dict[str, Any] timestamp: datetime @@ -242,6 +243,10 @@ class MCPAnalyticsOptions: # Return the alternative names that one tool accepts. The SDK records alias # use but does not change tool arguments. resolve_input_aliases: Optional[ResolveInputAliasesFn] = None + # Return the original tool descriptor for a low-level server. The SDK uses + # its input schema to remove only PostHog-owned arguments on a fresh server + # instance that did not serve tools/list. + resolve_original_tool: Optional[Callable[[str], Any]] = None @dataclass diff --git a/posthog/request.py b/posthog/request.py index 76df1fdc9..f23e40e31 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -3,11 +3,8 @@ import re import socket import time -import zlib from dataclasses import dataclass from datetime import date, datetime, timezone -from gzip import GzipFile -from io import BytesIO from typing import Any, List, Optional, Tuple, Union, cast import requests @@ -216,38 +213,26 @@ def post( api_key: str, host: Optional[str] = None, path: Optional[str] = None, - gzip: bool = False, + *, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs, ) -> requests.Response: """Post the `kwargs` to the API""" log = logging.getLogger("posthog") - user_agent = kwargs.pop("_user_agent", USER_AGENT) body = kwargs body["sent_at"] = datetime.now(tz=timezone.utc).isoformat() trimmed_host = remove_trailing_slash(normalize_host(host)) url = trimmed_host + cast(str, path) body["api_key"] = api_key - data: str | bytes = json.dumps(body, cls=DatetimeSerializer) + data = json.dumps(body, cls=DatetimeSerializer) if log.isEnabledFor(logging.DEBUG): log.debug( "making request: %s to url: %s", json.dumps({**body, "api_key": "[redacted]"}, cls=DatetimeSerializer), url, ) - headers = {"Content-Type": "application/json", "User-Agent": user_agent} - if gzip: - try: - buf = BytesIO() - with GzipFile(fileobj=buf, mode="w") as gz: - # 'data' was produced by json.dumps(), - # whose default encoding is utf-8. - gz.write(cast(str, data).encode("utf-8")) - data = buf.getvalue() - headers["Content-Encoding"] = "gzip" - except (OSError, zlib.error) as exc: - log.warning("failed to gzip request body, sending uncompressed: %s", exc) + headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} res = (session or _get_session()).post( url, data=data, headers=headers, timeout=timeout @@ -314,13 +299,12 @@ def _feature_flags_retry_delay(failed_attempt: int) -> float: def flags( api_key: str, host: Optional[str] = None, - gzip: bool = False, + *, timeout: int = 15, max_retries: int = 1, **kwargs, ) -> Any: """Post the kwargs to the flags API endpoint with bounded transient retries.""" - user_agent = kwargs.pop("_user_agent", USER_AGENT) retries = max(0, max_retries) failed_attempt = 0 @@ -330,10 +314,8 @@ def flags( api_key, host, "/flags/?v=2", - gzip, - timeout, + timeout=timeout, session=_get_flags_session(), - _user_agent=user_agent, **kwargs, ) return _process_response( @@ -360,47 +342,15 @@ def remote_config( timeout: int = 15, ) -> Any: """Get remote config flag value from remote_config API endpoint""" - return _remote_config(personal_api_key, project_api_key, host, key, timeout) - - -def _remote_config( - personal_api_key: str, - project_api_key: str, - host: Optional[str] = None, - key: str = "", - timeout: int = 15, - *, - _user_agent: str = USER_AGENT, -) -> Any: - response = _get( + response = get( personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}", host, timeout, - _user_agent=_user_agent, ) return response.data -EVENTS_ENDPOINT = "/batch/" -AI_EVENTS_ENDPOINT = "/i/v0/ai/batch/" - - -def batch_post( - api_key: str, - host: Optional[str] = None, - gzip: bool = False, - timeout: int = 15, - path: str = EVENTS_ENDPOINT, - **kwargs, -) -> requests.Response: - """Post the `kwargs` to the batch API endpoint for events""" - res = post(api_key, host, path, gzip, timeout, **kwargs) - return _process_response( - res, success_message="data uploaded successfully", return_json=False - ) - - def get( api_key: str, url: str, @@ -415,25 +365,10 @@ def get( - not_modified=True and data=None if server returns 304 - not_modified=False and data=response if server returns 200 """ - return _get(api_key, url, host, timeout, etag) - - -def _get( - api_key: str, - url: str, - host: Optional[str] = None, - timeout: Optional[int] = None, - etag: Optional[str] = None, - *, - _user_agent: str = USER_AGENT, -) -> GetResponse: log = logging.getLogger("posthog") trimmed_host = remove_trailing_slash(normalize_host(host)) full_url = trimmed_host + url - headers = { - "Authorization": "Bearer %s" % api_key, - "User-Agent": _user_agent, - } + headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} if etag: headers["If-None-Match"] = etag diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index 84275e9ad..2774933aa 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -430,9 +430,9 @@ def test_basic_completion(mock_client, mock_anthropic_response): ) assert response == mock_anthropic_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -476,7 +476,7 @@ def test_tokens_source_passthrough(mock_client, mock_anthropic_response): posthog_properties={"$ai_input_tokens": 99999}, ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_tokens_source"] == "passthrough" assert props["$ai_input_tokens"] == 99999 @@ -494,9 +494,9 @@ def test_groups(mock_client, mock_anthropic_response): ) assert response == mock_anthropic_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] assert call_args["groups"] == {"company": "test_company"} @@ -513,9 +513,9 @@ def test_privacy_mode_local(mock_client, mock_anthropic_response): ) assert response == mock_anthropic_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -535,9 +535,9 @@ def test_privacy_mode_global(mock_client, mock_anthropic_response): ) assert response == mock_anthropic_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -561,9 +561,9 @@ def test_basic_integration(mock_client): system="You must always answer with 'Bar'.", ) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" assert call_args["event"] == "$ai_generation" @@ -607,9 +607,9 @@ async def mock_async_create(**kwargs): posthog_properties={"foo": "bar"}, ) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -669,9 +669,9 @@ async def async_create_wrapper(**kwargs): [c async for c in response] # Capture happens in the async finally block before generator completes - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [ @@ -691,9 +691,9 @@ def test_error(mock_client, mock_anthropic_response): messages=[{"role": "user", "content": "Hello"}], ) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_is_error"] is True assert props["$ai_error"] == "Test error" @@ -713,9 +713,9 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens): ) assert response == mock_anthropic_response_with_cached_tokens - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -754,7 +754,7 @@ def test_preserves_cache_creation_ttl_breakdown_non_streaming( ) assert_cache_creation_ttl_breakdown_preserved( - mock_client.capture.call_args.kwargs["properties"] + mock_client.capture_ai.call_args.kwargs["properties"] ) @@ -776,7 +776,7 @@ async def mock_async_create(**kwargs): ) assert_cache_creation_ttl_breakdown_preserved( - mock_client.capture.call_args.kwargs["properties"] + mock_client.capture_ai.call_args.kwargs["properties"] ) @@ -794,7 +794,7 @@ def test_preserves_cache_creation_ttl_breakdown_streaming(mock_client): list(response) assert_cache_creation_ttl_breakdown_preserved( - mock_client.capture.call_args.kwargs["properties"] + mock_client.capture_ai.call_args.kwargs["properties"] ) @@ -820,7 +820,7 @@ async def mock_async_create(**kwargs): [event async for event in response] assert_cache_creation_ttl_breakdown_preserved( - mock_client.capture.call_args.kwargs["properties"] + mock_client.capture_ai.call_args.kwargs["properties"] ) @@ -843,7 +843,7 @@ def test_anthropic_bedrock_preserves_cache_creation_ttl_breakdown( ) assert_cache_creation_ttl_breakdown_preserved( - mock_client.capture.call_args.kwargs["properties"] + mock_client.capture_ai.call_args.kwargs["properties"] ) @@ -882,9 +882,9 @@ def test_tool_definition(mock_client, mock_anthropic_response): ) assert response == mock_anthropic_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -936,9 +936,9 @@ def test_tool_calls_in_output_choices( ) assert response == mock_anthropic_response_with_tool_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -999,9 +999,9 @@ def test_tool_calls_only_no_content( ) assert response == mock_anthropic_response_tool_calls_only - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1068,9 +1068,9 @@ async def run_test(): response = asyncio.run(run_test()) assert response == mock_anthropic_response_with_tool_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1136,9 +1136,9 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools list(response) # Capture happens synchronously when generator is exhausted - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1278,7 +1278,7 @@ def stream_generator(): ) list(response) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_output_choices"] == [ { "role": "assistant", @@ -1345,9 +1345,9 @@ async def run_test(): asyncio.run(run_test()) # Capture completes before asyncio.run() returns - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1427,9 +1427,9 @@ def test_streaming_with_thinking(mock_client, mock_anthropic_stream_with_thinkin # Consume the stream - this triggers the finally block synchronously list(response) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] output_choices = props["$ai_output_choices"] @@ -1479,9 +1479,9 @@ async def run_test(): asyncio.run(run_test()) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] output_choices = props["$ai_output_choices"] @@ -1533,9 +1533,9 @@ def __init__(self): ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured @@ -1615,9 +1615,9 @@ def test_streaming_with_web_search(mock_client, mock_anthropic_stream_with_web_s list(response) # Capture happens synchronously when generator is exhausted - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured @@ -1672,9 +1672,9 @@ async def run_test(): response = asyncio.run(run_test()) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured @@ -1719,9 +1719,9 @@ async def run_test(): asyncio.run(run_test()) # Capture completes before asyncio.run() returns - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured @@ -1735,25 +1735,52 @@ async def run_test(): # ======================= -def test_no_distinct_id_uses_trace_id_and_personless( - mock_client, mock_anthropic_response +@pytest.mark.parametrize("stream", [False, True], ids=["response", "stream"]) +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +async def test_no_distinct_id_uses_trace_id_and_personless( + mock_client, + mock_anthropic_response, + mock_anthropic_stream_with_tools, + is_async, + stream, ): """When no distinct_id is provided and no outer context, trace_id is used and event is personless.""" - with patch( - "anthropic.resources.Messages.create", return_value=mock_anthropic_response - ): - client = Anthropic(api_key="test-key", posthog_client=mock_client) - client.messages.create( - model="claude-3-opus-20240229", - messages=[{"role": "user", "content": "Hello"}], - posthog_trace_id="trace-123", + request = { + "model": "claude-3-opus-20240229", + "messages": [{"role": "user", "content": "Hello"}], + "posthog_trace_id": "trace-123", + "posthog_properties": {"$process_person_profile": True}, + **({"stream": True} if stream else {}), + } + if is_async: + + async def stream_events(): + for event in mock_anthropic_stream_with_tools: + yield event + + async def create(**kwargs): + return stream_events() if stream else mock_anthropic_response + + with patch("anthropic.resources.AsyncMessages.create", side_effect=create): + client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client) + response = await client.messages.create(**request) + if stream: + [event async for event in response] + else: + provider_response = ( + mock_anthropic_stream_with_tools if stream else mock_anthropic_response ) + with patch( + "anthropic.resources.Messages.create", return_value=provider_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + response = client.messages.create(**request) + if stream: + list(response) - call_args = mock_client.capture.call_args[1] - props = call_args["properties"] - - assert call_args["distinct_id"] == "trace-123" - assert props["$process_person_profile"] is False + call_args = mock_client.capture_ai.call_args[1] + assert call_args["distinct_id"] == "trace-123" + assert call_args["options"] == {"process_person_profile": False} def test_explicit_distinct_id_creates_person_profile( @@ -1771,14 +1798,12 @@ def test_explicit_distinct_id_creates_person_profile( posthog_trace_id="trace-123", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "user-123" - assert ( - "$process_person_profile" not in props - or props["$process_person_profile"] is not False - ) + assert "options" not in call_args + assert "$process_person_profile" not in props def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response): @@ -1795,14 +1820,12 @@ def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response) posthog_trace_id="trace-123", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "outer-user-456" - assert ( - "$process_person_profile" not in props - or props["$process_person_profile"] is not False - ) + assert "options" not in call_args + assert "$process_person_profile" not in props def test_explicit_distinct_id_overrides_outer_context( @@ -1822,7 +1845,7 @@ def test_explicit_distinct_id_overrides_outer_context( posthog_trace_id="trace-123", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] assert call_args["distinct_id"] == "explicit-user-789" @@ -1839,7 +1862,7 @@ def test_integration_stop_reason(mock_client): posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] in ("end_turn", "max_tokens") assert props["$ai_provider"] == "anthropic" assert props["$ai_input_tokens"] > 0 @@ -1940,9 +1963,9 @@ async def test_streaming_sync_async_accumulation_parity(mock_client): 0, ) list(sync_response) - sync_properties = mock_client.capture.call_args.kwargs["properties"] + sync_properties = mock_client.capture_ai.call_args.kwargs["properties"] - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client) async_response = async_client.messages._track_streaming_response( @@ -1956,7 +1979,7 @@ async def test_streaming_sync_async_accumulation_parity(mock_client): 0, ) [event async for event in async_response] - async_properties = mock_client.capture.call_args.kwargs["properties"] + async_properties = mock_client.capture_ai.call_args.kwargs["properties"] parity_keys = ( "$ai_input", @@ -1996,9 +2019,9 @@ async def test_streaming_sync_async_exceptions_capture_and_close(mock_client): list(stream) assert sync_source.closed is True - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() async_source = FailingRecordingAsyncStream([]) async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client) @@ -2017,7 +2040,7 @@ async def test_streaming_sync_async_exceptions_capture_and_close(mock_client): [event async for event in stream] assert async_source.closed is True - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 def test_messages_stream_preserves_native_manager_helpers_close_and_tracking( @@ -2041,8 +2064,8 @@ def test_messages_stream_preserves_native_manager_helpers_close_and_tracking( assert text == ["Hi"] assert source.closed is True - assert mock_client.capture.call_count == 1 - assert mock_client.capture.call_args.kwargs["distinct_id"] == "test-user" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args.kwargs["distinct_id"] == "test-user" assert "posthog_distinct_id" not in client.post.call_args.kwargs @@ -2083,8 +2106,8 @@ async def test_async_messages_stream_preserves_provider_contract_and_manager( assert text == ["Hi"] assert source.closed is True - assert mock_client.capture.call_count == 1 - assert mock_client.capture.call_args.kwargs["distinct_id"] == "test-user" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args.kwargs["distinct_id"] == "test-user" assert "posthog_distinct_id" not in client.post.call_args.kwargs @@ -2130,7 +2153,7 @@ async def mock_async_create(**kwargs): events = [event async for event in stream] assert len(events) == 3 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 def test_messages_streaming_early_exit_closes_provider_stream(mock_client): @@ -2153,7 +2176,7 @@ def test_messages_streaming_early_exit_closes_provider_stream(mock_client): break assert source.closed is True - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 @pytest.mark.asyncio @@ -2182,24 +2205,7 @@ async def mock_async_create(**kwargs): break assert source.closed is True - assert mock_client.capture.call_count == 1 - - -def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_anthropic_response): - mock_client.enable_full_ai_capture = True - with patch( - "anthropic.resources.Messages.create", return_value=mock_anthropic_response - ): - client = Anthropic(api_key="test-key", posthog_client=mock_client) - client.messages.create( - model="claude-3-opus-20240229", - messages=[{"role": "user", "content": "Hello"}], - posthog_distinct_id="test-id", - ) - - mock_client.capture.assert_not_called() assert mock_client.capture_ai.call_count == 1 - assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" def test_multimodal_client_skips_media_redaction(mock_client, mock_anthropic_response): diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 3dc776002..3fae9b683 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -173,7 +173,7 @@ def test_initialization(self, mock_client): assert proc._properties == {"env": "test"} def test_initialization_defaults(self): - with patch("posthog.ai.claude_agent_sdk.processor.setup") as mock_setup: + with patch("posthog.ai.utils.setup") as mock_setup: mock_setup.return_value = MagicMock() proc = PostHogClaudeAgentProcessor() assert proc._distinct_id is None @@ -202,7 +202,7 @@ async def test_emits_generation_from_stream_events(self, processor, mock_client) collected.append(msg) # Should have captured $ai_generation + $ai_trace - calls = mock_client.capture.call_args_list + calls = mock_client.capture_ai.call_args_list events = [c.kwargs.get("event") or c[1].get("event") for c in calls] assert "$ai_generation" in events assert "$ai_trace" in events @@ -247,7 +247,7 @@ async def test_emits_cache_creation_ttl_breakdown_from_stream_events( generation_call = next( call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if (call.kwargs.get("event") or call[1].get("event")) == "$ai_generation" ) props = generation_call.kwargs.get("properties") or generation_call[1].get( @@ -286,7 +286,7 @@ async def test_emits_multiple_generations_for_multi_turn( gen_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" ] assert len(gen_calls) == 2 @@ -318,7 +318,7 @@ async def test_fallback_generation_from_result_when_no_stream_events( gen_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" ] assert len(gen_calls) == 1 @@ -349,7 +349,7 @@ async def test_fallback_copies_result_usage_before_capture( generation_call = next( call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if (call.kwargs.get("event") or call[1].get("event")) == "$ai_generation" ) props = generation_call.kwargs.get("properties") or generation_call[1].get( @@ -391,7 +391,7 @@ async def test_emits_span_for_tool_use(self, processor, mock_client): span_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" ] assert len(span_calls) == 1 @@ -422,7 +422,7 @@ async def test_emits_trace_on_result(self, processor, mock_client): trace_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 @@ -449,7 +449,7 @@ async def test_trace_emits_error_status(self, processor, mock_client): trace_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 @@ -473,7 +473,7 @@ async def test_client_privacy_mode_redacts_generation_input(self, mock_client): async for _ in proc.query(prompt="secret", options=ClaudeAgentOptions()): pass - properties = mock_client.capture.call_args.kwargs["properties"] + properties = mock_client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] is None @pytest.mark.asyncio @@ -502,12 +502,12 @@ async def test_per_call_privacy_mode_cannot_disable_processor_mode( ): pass - properties = mock_client.capture.call_args.kwargs["properties"] + properties = mock_client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] is None @pytest.mark.asyncio async def test_client_without_privacy_mode_captures_content(self): - client = SimpleNamespace(capture=MagicMock()) + client = SimpleNamespace(capture_ai=MagicMock()) proc = PostHogClaudeAgentProcessor(client=client, distinct_id="user") messages = [_make_message_start(), _make_message_stop()] @@ -518,7 +518,7 @@ async def test_client_without_privacy_mode_captures_content(self): async for _ in proc.query(prompt="visible", options=ClaudeAgentOptions()): pass - properties = client.capture.call_args.kwargs["properties"] + properties = client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] == [{"role": "user", "content": "visible"}] @pytest.mark.asyncio @@ -559,7 +559,7 @@ async def test_privacy_mode_redacts_tool_input(self, mock_client): span_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" ] assert len(span_calls) == 1 @@ -570,7 +570,7 @@ async def test_privacy_mode_redacts_tool_input(self, mock_client): generation_call = next( call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if (call.kwargs.get("event") or call[1].get("event")) == "$ai_generation" ) generation_props = generation_call.kwargs.get("properties") or generation_call[ @@ -619,7 +619,7 @@ async def test_data_url_prompt_and_tool_use_base64_are_redacted( gen_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" ] assert len(gen_calls) == 2 @@ -644,7 +644,7 @@ async def test_data_url_prompt_and_tool_use_base64_are_redacted( class TestPersonlessMode: @pytest.mark.asyncio - async def test_no_distinct_id_sets_process_person_profile_false(self, mock_client): + async def test_no_distinct_id_sets_personless_option(self, mock_client): proc = PostHogClaudeAgentProcessor( client=mock_client, distinct_id=None, @@ -667,9 +667,8 @@ async def test_no_distinct_id_sets_process_person_profile_false(self, mock_clien ): pass - for call in mock_client.capture.call_args_list: - props = call.kwargs.get("properties") or call[1].get("properties") - assert props.get("$process_person_profile") is False + for call in mock_client.capture_ai.call_args_list: + assert call.kwargs["options"] == {"process_person_profile": False} assert call.kwargs["distinct_id"] == "trace-fallback" @@ -682,7 +681,7 @@ def test_explicit_groups_override_preserves_empty_groups(self, mock_client): proc._capture_event("$ai_trace", {}, groups={}) - assert mock_client.capture.call_args.kwargs["groups"] == {} + assert mock_client.capture_ai.call_args.kwargs["groups"] == {} def test_default_properties_keep_existing_precedence(self, mock_client): proc = PostHogClaudeAgentProcessor( @@ -695,20 +694,22 @@ def test_default_properties_keep_existing_precedence(self, mock_client): {"environment": "event", "$ai_trace_id": "trace-id"}, ) - assert mock_client.capture.call_args.kwargs["properties"] == { + assert mock_client.capture_ai.call_args.kwargs["properties"] == { "$ai_lib": "posthog-ai", "$ai_lib_version": VERSION, "environment": "processor", "$ai_trace_id": "trace-id", } - def test_client_without_capture_capability_is_ignored(self): - proc = PostHogClaudeAgentProcessor(client=object()) + def test_client_without_capture_ai_disables_capture_at_construction(self, caplog): + with caplog.at_level(logging.ERROR, logger="posthog"): + proc = PostHogClaudeAgentProcessor(client=object()) + assert "capture_ai" in caplog.text proc._capture_event("$ai_trace", {}) def test_capture_errors_are_logged_and_suppressed(self, mock_client, caplog): - mock_client.capture.side_effect = RuntimeError("capture failed") + mock_client.capture_ai.side_effect = RuntimeError("capture failed") proc = PostHogClaudeAgentProcessor(client=mock_client) with caplog.at_level(logging.DEBUG, logger="posthog"): @@ -739,7 +740,7 @@ async def test_instance_properties_merged(self, mock_client): async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): pass - for call in mock_client.capture.call_args_list: + for call in mock_client.capture_ai.call_args_list: props = call.kwargs.get("properties") or call[1].get("properties") assert props.get("app") == "stamphog" assert props.get("version") == "1.0" @@ -764,7 +765,7 @@ async def test_per_call_properties_merged(self, processor, mock_client): ): pass - for call in mock_client.capture.call_args_list: + for call in mock_client.capture_ai.call_args_list: props = call.kwargs.get("properties") or call[1].get("properties") assert props.get("pr_number") == 42 @@ -792,7 +793,7 @@ def resolver(result): trace_calls = [ c - for c in mock_client.capture.call_args_list + for c in mock_client.capture_ai.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 @@ -895,13 +896,3 @@ async def test_non_config_errors_propagate(self): prompt="Hello", options=ClaudeAgentOptions() ): pass - - -def test_ai_lane_client_routes_through_capture_ai(mock_client): - mock_client.enable_full_ai_capture = True - processor = PostHogClaudeAgentProcessor(client=mock_client, distinct_id="test-user") - processor._capture_event(event="$ai_trace", properties={}, distinct_id="d") - - mock_client.capture.assert_not_called() - mock_client.capture_ai.assert_called_once() - assert mock_client.capture_ai.call_args[1]["event"] == "$ai_trace" diff --git a/posthog/test/ai/gemini/test_gemini.py b/posthog/test/ai/gemini/test_gemini.py index f2a801e68..48d5cbd08 100644 --- a/posthog/test/ai/gemini/test_gemini.py +++ b/posthog/test/ai/gemini/test_gemini.py @@ -188,9 +188,9 @@ def test_new_client_basic_generation( ) assert response == mock_gemini_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -261,8 +261,8 @@ def mock_streaming_response(): assert chunks[1].text == "world!" # Check that the streaming event was captured - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -343,8 +343,8 @@ def mock_streaming_response(): assert chunks[1].text == "the weather" # Check that the streaming event was captured with tools - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -373,7 +373,7 @@ def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_re posthog_groups={"company": "company_123"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] assert call_args["groups"] == {"company": "company_123"} @@ -392,7 +392,7 @@ def test_new_client_privacy_mode_local( posthog_privacy_mode=True, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -414,7 +414,7 @@ def test_new_client_privacy_mode_global( posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -432,7 +432,7 @@ def test_new_client_different_input_formats( client.models.generate_content( model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id" ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [{"role": "user", "content": "Hello"}] @@ -443,7 +443,7 @@ def test_new_client_different_input_formats( contents=[{"role": "user", "parts": [{"text": "hey"}]}], posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [ {"role": "user", "content": [{"type": "text", "text": "hey"}]} @@ -456,7 +456,7 @@ def test_new_client_different_input_formats( contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}], posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [ { @@ -469,11 +469,11 @@ def test_new_client_different_input_formats( ] # Test list input with string - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() client.models.generate_content( model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id" ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [{"role": "user", "content": "List item"}] @@ -494,7 +494,7 @@ def test_new_client_model_parameters( max_tokens=100, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_model_parameters"]["temperature"] == 0.7 assert props["$ai_model_parameters"]["max_tokens"] == 100 @@ -518,7 +518,7 @@ def test_new_client_default_settings( # Call without overriding defaults client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"]) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "default_user" @@ -551,7 +551,7 @@ def test_new_client_override_defaults( posthog_groups={"organization": "special_org"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check overrides @@ -676,9 +676,9 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re ) assert response == mock_gemini_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -718,9 +718,9 @@ def test_function_calls_in_output_choices( ) assert response == mock_gemini_response_with_function_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -767,9 +767,9 @@ def test_function_calls_only_no_content( ) assert response == mock_gemini_response_function_calls_only - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -826,9 +826,9 @@ def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client): ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check that all token types are present @@ -890,9 +890,9 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli assert len(result) == 2 # Check PostHog capture was called - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check that all token types are present (should use final chunk's usage) @@ -963,9 +963,9 @@ def test_web_search_grounding(mock_client, mock_google_genai_client): ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is detected (binary for grounding) @@ -1034,9 +1034,9 @@ def mock_streaming_response(): chunks = list(response) assert len(chunks) == 2 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is detected (binary for grounding) @@ -1098,9 +1098,9 @@ def test_empty_grounding_metadata_no_web_search(mock_client, mock_google_genai_c ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is 0 (not present in properties when 0) @@ -1161,9 +1161,9 @@ def test_empty_array_grounding_metadata_no_web_search( ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is 0 (not present in properties when 0) @@ -1203,9 +1203,9 @@ def test_falsy_candidates_no_web_search( ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Should not crash and web search count should not be present @@ -1257,9 +1257,9 @@ def test_embed_content_basic( ) assert response == mock_embed_content_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1293,7 +1293,7 @@ def test_embed_content_with_token_counts( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_input_tokens"] == 13 # 5 + 8 @@ -1311,7 +1311,7 @@ def test_embed_content_without_token_counts( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] # No embedding carried a token count, so the property is omitted, not 0. assert "$ai_input_tokens" not in props @@ -1334,7 +1334,7 @@ def test_streaming_without_usage_omits_token_counts( ) list(response) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert "$ai_input_tokens" not in props assert "$ai_output_tokens" not in props @@ -1354,7 +1354,7 @@ def test_embed_content_privacy_mode( posthog_privacy_mode=True, ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_input"] is None @@ -1371,12 +1371,13 @@ def test_embed_content_no_distinct_id( contents="Hello", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Should fall back to trace_id as distinct_id assert call_args["distinct_id"] == props["$ai_trace_id"] - assert props["$process_person_profile"] is False + assert call_args["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props def test_embed_content_default_params( @@ -1399,7 +1400,7 @@ def test_embed_content_default_params( posthog_properties={"extra": "prop"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "default-id" @@ -1421,8 +1422,8 @@ def test_embed_content_error_handling(mock_client, mock_google_genai_client): ) # Event should still be captured - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_is_error"] is True assert props["$ai_error"] == "API error" assert props["$ai_http_status"] == 0 @@ -1466,8 +1467,8 @@ def test_embed_content_integration(mock_client): assert all(isinstance(v, float) for v in response.embeddings[0].values) # Verify event captured - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["event"] == "$ai_embedding" assert props["$ai_provider"] == "gemini" @@ -1530,7 +1531,7 @@ def test_stop_reason_captured( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] == finish_reason_name @@ -1579,8 +1580,8 @@ def mock_streaming_response(): list(response) - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] == "STOP" @@ -1596,26 +1597,8 @@ def test_integration_stop_reason(mock_client): posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] is not None assert isinstance(props["$ai_stop_reason"], str) assert props["$ai_provider"] == "gemini" assert props["$ai_input_tokens"] > 0 - - -def test_ai_lane_client_routes_through_capture_ai( - mock_client, mock_google_genai_client, mock_gemini_response -): - mock_google_genai_client.models.generate_content.return_value = mock_gemini_response - - mock_client.enable_full_ai_capture = True - client = Client(api_key="test-key", posthog_client=mock_client) - client.models.generate_content( - model="gemini-2.0-flash", - contents=["Tell me a fun fact about hedgehogs"], - posthog_distinct_id="test-id", - ) - - mock_client.capture.assert_not_called() - assert mock_client.capture_ai.call_count == 1 - assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" diff --git a/posthog/test/ai/gemini/test_gemini_async.py b/posthog/test/ai/gemini/test_gemini_async.py index 8dc51ca34..701b7ebdc 100644 --- a/posthog/test/ai/gemini/test_gemini_async.py +++ b/posthog/test/ai/gemini/test_gemini_async.py @@ -134,9 +134,9 @@ async def test_async_client_basic_generation( ) assert response == mock_gemini_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -199,8 +199,8 @@ async def mock_streaming_response(): assert chunks[1].text == "world!" # Check that the streaming event was captured - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -283,8 +283,8 @@ async def mock_streaming_response(): assert chunks[1].text == "the weather" # Check that the streaming event was captured with tools - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -317,7 +317,7 @@ async def test_async_client_groups( posthog_groups={"company": "company_123"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] assert call_args["groups"] == {"company": "company_123"} @@ -338,7 +338,7 @@ async def test_async_client_privacy_mode_local( posthog_privacy_mode=True, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -362,7 +362,7 @@ async def test_async_client_privacy_mode_global( posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -382,7 +382,7 @@ async def test_async_client_different_input_formats( await client.models.generate_content( model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id" ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [{"role": "user", "content": "Hello"}] @@ -393,7 +393,7 @@ async def test_async_client_different_input_formats( contents=[{"role": "user", "parts": [{"text": "hey"}]}], posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [ {"role": "user", "content": [{"type": "text", "text": "hey"}]} @@ -406,7 +406,7 @@ async def test_async_client_different_input_formats( contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}], posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [ { @@ -419,11 +419,11 @@ async def test_async_client_different_input_formats( ] # Test list input with string - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() await client.models.generate_content( model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id" ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] == [{"role": "user", "content": "List item"}] @@ -446,7 +446,7 @@ async def test_async_client_model_parameters( max_tokens=100, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_model_parameters"]["temperature"] == 0.7 assert props["$ai_model_parameters"]["max_tokens"] == 100 @@ -472,7 +472,7 @@ async def test_async_client_default_settings( # Call without overriding defaults await client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"]) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "default_user" @@ -507,7 +507,7 @@ async def test_async_client_override_defaults( posthog_groups={"organization": "special_org"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check overrides @@ -586,9 +586,9 @@ async def test_async_function_calls_in_output_choices( ) assert response == mock_gemini_response_with_function_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -649,9 +649,9 @@ async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_c ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check that all token types are present @@ -708,9 +708,9 @@ async def mock_streaming_response(): assert len(result) == 2 # Check PostHog capture was called - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Check that all token types are present (should use final chunk's usage) @@ -772,9 +772,9 @@ async def test_async_web_search_grounding(mock_client, mock_google_genai_client) ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is detected (binary for grounding) @@ -845,9 +845,9 @@ async def mock_streaming_response(): chunks.append(chunk) assert len(chunks) == 2 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is detected (binary for grounding) @@ -899,9 +899,9 @@ async def test_async_embed_content_basic( ) assert response == mock_embed_content_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -935,7 +935,7 @@ async def test_async_embed_content_with_token_counts( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_input_tokens"] == 13 # 5 + 8 @@ -953,7 +953,7 @@ async def test_async_embed_content_without_token_counts( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] # No embedding carried a token count, so the property is omitted, not 0. assert "$ai_input_tokens" not in props @@ -973,7 +973,7 @@ async def test_async_embed_content_privacy_mode( posthog_privacy_mode=True, ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_input"] is None @@ -990,12 +990,13 @@ async def test_async_embed_content_no_distinct_id( contents="Hello", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Should fall back to trace_id as distinct_id assert call_args["distinct_id"] == props["$ai_trace_id"] - assert props["$process_person_profile"] is False + assert call_args["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props async def test_async_embed_content_default_params( @@ -1018,7 +1019,7 @@ async def test_async_embed_content_default_params( posthog_properties={"extra": "prop"}, ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "default-id" @@ -1044,8 +1045,8 @@ async def test_async_embed_content_error_handling( ) # Event should still be captured - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_is_error"] is True assert props["$ai_error"] == "API error" assert props["$ai_http_status"] == 0 @@ -1089,8 +1090,8 @@ async def test_async_embed_content_integration(mock_client): assert all(isinstance(v, float) for v in response.embeddings[0].values) # Verify event captured - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["event"] == "$ai_embedding" assert props["$ai_provider"] == "gemini" @@ -1147,7 +1148,7 @@ async def mock_streaming_response(): chunks.append(chunk) assert len(chunks) == 1 - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] assert call_args["event"] == "$ai_generation" assert call_args["properties"]["$ai_provider"] == "gemini" diff --git a/posthog/test/ai/gemini/test_gemini_parity.py b/posthog/test/ai/gemini/test_gemini_parity.py index de9f9b82f..a31299032 100644 --- a/posthog/test/ai/gemini/test_gemini_parity.py +++ b/posthog/test/ai/gemini/test_gemini_parity.py @@ -178,8 +178,8 @@ async def test_sync_client_aio_models_tracks_generations( assert response is gemini_response provider_client.aio.models.generate_content.assert_awaited_once() - assert posthog_client.capture.call_count == 1 - call_args = posthog_client.capture.call_args[1] + assert posthog_client.capture_ai.call_count == 1 + call_args = posthog_client.capture_ai.call_args[1] assert call_args["distinct_id"] == "test-id" assert call_args["event"] == "$ai_generation" assert call_args["properties"]["$ai_model"] == "gemini-2.0-flash" diff --git a/posthog/test/ai/langchain/test_callbacks.py b/posthog/test/ai/langchain/test_callbacks.py index 2556294ae..32c9f1e3d 100644 --- a/posthog/test/ai/langchain/test_callbacks.py +++ b/posthog/test/ai/langchain/test_callbacks.py @@ -185,15 +185,15 @@ def test_basic_chat_chain(mock_client, stream): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "The Los Angeles Dodgers won the World Series in 2020." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - span_args = mock_client.capture.call_args_list[0][1] + span_args = mock_client.capture_ai.call_args_list[0][1] span_props = span_args["properties"] - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] # Span is first @@ -262,13 +262,13 @@ async def test_async_basic_chat_chain(mock_client, stream): else: result = await chain.ainvoke({}, config={"callbacks": callbacks}) assert result.content == "The Los Angeles Dodgers won the World Series in 2020." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - span_args = mock_client.capture.call_args_list[0][1] + span_args = mock_client.capture_ai.call_args_list[0][1] span_props = span_args["properties"] - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] # Span is first @@ -349,7 +349,7 @@ def _capture_anthropic_usage( ) cb._pop_run_and_capture_generation(run_id, None, response) - return mock_client.capture.call_args.kwargs["properties"] + return mock_client.capture_ai.call_args.kwargs["properties"] @pytest.mark.parametrize( @@ -380,8 +380,8 @@ def test_basic_llm_chain(mock_client, Model, stream): ) assert result == "The Los Angeles Dodgers won the World Series in 2020." - assert mock_client.capture.call_count == 1 - args = mock_client.capture.call_args_list[0][1] + assert mock_client.capture_ai.call_count == 1 + args = mock_client.capture_ai.call_args_list[0][1] props = args["properties"] assert args["event"] == "$ai_generation" @@ -425,8 +425,8 @@ async def test_async_basic_llm_chain(mock_client, Model, stream): ) assert result == "The Los Angeles Dodgers won the World Series in 2020." - assert mock_client.capture.call_count == 1 - args = mock_client.capture.call_args_list[0][1] + assert mock_client.capture_ai.call_count == 1 + args = mock_client.capture_ai.call_args_list[0][1] props = args["properties"] assert args["event"] == "$ai_generation" @@ -455,21 +455,21 @@ def test_trace_id_and_inputs_for_multiple_chains(mock_client): assert result.content == "Bar" # span, generation, span, generation, trace - assert mock_client.capture.call_count == 5 + assert mock_client.capture_ai.call_count == 5 - first_span_args = mock_client.capture.call_args_list[0][1] + first_span_args = mock_client.capture_ai.call_args_list[0][1] first_span_props = first_span_args["properties"] - first_generation_args = mock_client.capture.call_args_list[1][1] + first_generation_args = mock_client.capture_ai.call_args_list[1][1] first_generation_props = first_generation_args["properties"] - second_span_args = mock_client.capture.call_args_list[2][1] + second_span_args = mock_client.capture_ai.call_args_list[2][1] second_span_props = second_span_args["properties"] - second_generation_args = mock_client.capture.call_args_list[3][1] + second_generation_args = mock_client.capture_ai.call_args_list[3][1] second_generation_props = second_generation_args["properties"] - trace_args = mock_client.capture.call_args_list[4][1] + trace_args = mock_client.capture_ai.call_args_list[4][1] trace_props = trace_args["properties"] # Prompt span @@ -536,38 +536,38 @@ def test_personless_mode(mock_client): prompt = ChatPromptTemplate.from_messages([("user", "Foo")]) chain = prompt | FakeMessagesListChatModel(responses=[AIMessage(content="Bar")]) chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client)]}) - assert mock_client.capture.call_count == 3 - span_args = mock_client.capture.call_args_list[0][1] - generation_args = mock_client.capture.call_args_list[1][1] - trace_args = mock_client.capture.call_args_list[2][1] + assert mock_client.capture_ai.call_count == 3 + span_args = mock_client.capture_ai.call_args_list[0][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] # span assert span_args["event"] == "$ai_span" - assert span_args["properties"]["$process_person_profile"] is False + assert span_args["options"] == {"process_person_profile": False} # generation assert generation_args["event"] == "$ai_generation" - assert generation_args["properties"]["$process_person_profile"] is False + assert generation_args["options"] == {"process_person_profile": False} # trace assert trace_args["event"] == "$ai_trace" - assert trace_args["properties"]["$process_person_profile"] is False + assert trace_args["options"] == {"process_person_profile": False} id = uuid.uuid4() chain.invoke( {}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]} ) - assert mock_client.capture.call_count == 6 - span_args = mock_client.capture.call_args_list[3][1] - generation_args = mock_client.capture.call_args_list[4][1] - trace_args = mock_client.capture.call_args_list[5][1] + assert mock_client.capture_ai.call_count == 6 + span_args = mock_client.capture_ai.call_args_list[3][1] + generation_args = mock_client.capture_ai.call_args_list[4][1] + trace_args = mock_client.capture_ai.call_args_list[5][1] # span - assert "$process_person_profile" not in span_args["properties"] + assert "options" not in span_args assert span_args["distinct_id"] == id # generation - assert "$process_person_profile" not in generation_args["properties"] + assert "options" not in generation_args assert generation_args["distinct_id"] == id # trace - assert "$process_person_profile" not in trace_args["properties"] + assert "options" not in trace_args assert trace_args["distinct_id"] == id @@ -595,44 +595,44 @@ def test_personless_mode_exception(mock_client, unauthorized_http_client): callbacks = CallbackHandler(mock_client) with pytest.raises(AuthenticationError): chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - span_args = mock_client.capture.call_args_list[0][1] - generation_args = mock_client.capture.call_args_list[1][1] - trace_args = mock_client.capture.call_args_list[2][1] + assert mock_client.capture_ai.call_count == 3 + span_args = mock_client.capture_ai.call_args_list[0][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] # span assert span_args["event"] == "$ai_span" - assert span_args["properties"]["$process_person_profile"] is False + assert span_args["options"] == {"process_person_profile": False} # generation assert generation_args["event"] == "$ai_generation" - assert generation_args["properties"]["$process_person_profile"] is False + assert generation_args["options"] == {"process_person_profile": False} # trace assert trace_args["event"] == "$ai_trace" - assert trace_args["properties"]["$process_person_profile"] is False + assert trace_args["options"] == {"process_person_profile": False} id = uuid.uuid4() with pytest.raises(AuthenticationError): chain.invoke( {}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]} ) - assert mock_client.capture.call_count == 6 - span_args = mock_client.capture.call_args_list[3][1] - generation_args = mock_client.capture.call_args_list[4][1] - trace_args = mock_client.capture.call_args_list[5][1] + assert mock_client.capture_ai.call_count == 6 + span_args = mock_client.capture_ai.call_args_list[3][1] + generation_args = mock_client.capture_ai.call_args_list[4][1] + trace_args = mock_client.capture_ai.call_args_list[5][1] # span assert span_args["event"] == "$ai_span" - assert "$process_person_profile" not in span_args["properties"] + assert "options" not in span_args assert span_args["distinct_id"] == id # generation assert generation_args["event"] == "$ai_generation" - assert "$process_person_profile" not in generation_args["properties"] + assert "options" not in generation_args assert generation_args["distinct_id"] == id # trace assert trace_args["event"] == "$ai_trace" - assert "$process_person_profile" not in trace_args["properties"] + assert "options" not in trace_args assert trace_args["distinct_id"] == id @@ -655,9 +655,9 @@ def test_metadata(mock_client): result = chain.invoke({"plan": None}, config={"callbacks": callbacks}) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - span_call_args = mock_client.capture.call_args_list[0][1] + span_call_args = mock_client.capture_ai.call_args_list[0][1] span_call_props = span_call_args["properties"] assert span_call_args["distinct_id"] == "test_id" assert span_call_args["event"] == "$ai_span" @@ -666,7 +666,7 @@ def test_metadata(mock_client): assert "$ai_parent_id" in span_call_props assert "$ai_span_id" in span_call_props - generation_call_args = mock_client.capture.call_args_list[1][1] + generation_call_args = mock_client.capture_ai.call_args_list[1][1] generation_call_props = generation_call_args["properties"] assert generation_call_args["distinct_id"] == "test_id" assert generation_call_args["event"] == "$ai_generation" @@ -679,7 +679,7 @@ def test_metadata(mock_client): assert generation_call_props["$ai_http_status"] == 200 assert isinstance(generation_call_props["$ai_latency"], float) - trace_call_args = mock_client.capture.call_args_list[2][1] + trace_call_args = mock_client.capture_ai.call_args_list[2][1] trace_call_props = trace_call_args["properties"] assert trace_call_args["distinct_id"] == "test_id" assert trace_call_args["event"] == "$ai_trace" @@ -742,8 +742,8 @@ def test_graph_state(mock_client): assert isinstance(result["messages"][2], AIMessage) assert result["messages"][2].content == "It's a type of greeble." - assert mock_client.capture.call_count == 6 - calls = [call[1] for call in mock_client.capture.call_args_list] + assert mock_client.capture_ai.call_count == 6 + calls = [call[1] for call in mock_client.capture_ai.call_args_list] # The trace event is captured at the end trace_args = calls[-1] @@ -867,8 +867,8 @@ def runnable(_): assert callbacks._runs == {} assert callbacks._parent_tree == {} - assert mock_client.capture.call_count == 1 - trace_call_args = mock_client.capture.call_args_list[0][1] + assert mock_client.capture_ai.call_count == 1 + trace_call_args = mock_client.capture_ai.call_args_list[0][1] assert trace_call_args["event"] == "$ai_trace" assert trace_call_args["properties"]["$ai_span_name"] == "runnable" @@ -895,8 +895,8 @@ def unauthorized(request): assert callbacks._runs == {} assert callbacks._parent_tree == {} - assert mock_client.capture.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_args = mock_client.capture_ai.call_args_list[1][1] props = generation_args["properties"] assert props["$ai_http_status"] == 401 assert props["$ai_input"] == [{"role": "user", "content": "Foo"}] @@ -928,9 +928,9 @@ def test_openai_chain(mock_client): approximate_latency = math.floor(time.time() - start_time) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] assert gen_args["event"] == "$ai_generation" assert gen_props["$ai_trace_id"] == "test-trace-id" @@ -990,11 +990,11 @@ def test_openai_captures_multiple_generations(mock_client): result = chain.invoke({}, config={"callbacks": [callbacks]}) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] assert gen_args["event"] == "$ai_generation" @@ -1053,11 +1053,11 @@ def test_openai_streaming(mock_client): result = sum(result[1:], result[0]) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] assert gen_args["event"] == "$ai_generation" @@ -1097,11 +1097,11 @@ async def test_async_openai_streaming(mock_client): result = sum(result[1:], result[0]) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] assert gen_args["event"] == "$ai_generation" @@ -1135,8 +1135,8 @@ def test_base_url_retrieval(mock_client, unauthorized_http_client): with pytest.raises(AuthenticationError): chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - generation_call = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_call = mock_client.capture_ai.call_args_list[1][1] assert generation_call["properties"]["$ai_base_url"] == "https://test.posthog.com" @@ -1152,8 +1152,8 @@ def test_groups(mock_client): callbacks = CallbackHandler(mock_client, groups={"company": "test_company"}) chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - generation_call = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_call = mock_client.capture_ai.call_args_list[1][1] assert generation_call["groups"] == {"company": "test_company"} @@ -1169,8 +1169,8 @@ def test_privacy_mode_local(mock_client): callbacks = CallbackHandler(mock_client, privacy_mode=True) chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - generation_call = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_call = mock_client.capture_ai.call_args_list[1][1] assert generation_call["properties"]["$ai_input"] is None assert generation_call["properties"]["$ai_output_choices"] is None @@ -1188,8 +1188,8 @@ def test_privacy_mode_global(mock_client): callbacks = CallbackHandler(mock_client) chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - generation_call = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_call = mock_client.capture_ai.call_args_list[1][1] assert generation_call["properties"]["$ai_input"] is None assert generation_call["properties"]["$ai_output_choices"] is None @@ -1219,11 +1219,11 @@ def test_anthropic_chain(mock_client): approximate_latency = math.floor(time.time() - start_time) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] assert gen_args["event"] == "$ai_generation" @@ -1279,11 +1279,11 @@ async def test_async_anthropic_streaming(mock_client): result = sum(result[1:], result[0]) assert result.content == "Bar" - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - gen_args = mock_client.capture.call_args_list[1][1] + gen_args = mock_client.capture_ai.call_args_list[1][1] gen_props = gen_args["properties"] - trace_args = mock_client.capture.call_args_list[2][1] + trace_args = mock_client.capture_ai.call_args_list[2][1] trace_props = trace_args["properties"] assert gen_args["event"] == "$ai_generation" @@ -1385,8 +1385,8 @@ def test_tool_calls(mock_client): callbacks = CallbackHandler(mock_client) chain.invoke({}, config={"callbacks": [callbacks]}) - assert mock_client.capture.call_count == 3 - generation_call = mock_client.capture.call_args_list[1][1] + assert mock_client.capture_ai.call_count == 3 + generation_call = mock_client.capture_ai.call_args_list[1][1] assert generation_call["properties"]["$ai_output_choices"][0]["tool_calls"] == [ { "type": "function", @@ -1420,10 +1420,10 @@ async def sleep(x): # -> Any: chain2.ainvoke({}, config={"callbacks": [cb]}), ) approximate_latency = math.floor(time.time() - start_time) - assert mock_client.capture.call_count == 4 + assert mock_client.capture_ai.call_count == 4 first_call, second_call, third_call, fourth_call = ( - mock_client.capture.call_args_list + mock_client.capture_ai.call_args_list ) assert first_call[1]["event"] == "$ai_span" assert second_call[1]["event"] == "$ai_generation" @@ -1460,7 +1460,7 @@ def get_weather(city: Literal["nyc", "sf"]): mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id" ) graph.invoke(inputs, config={"callbacks": [cb]}) - calls = [call[1] for call in mock_client.capture.call_args_list] + calls = [call[1] for call in mock_client.capture_ai.call_args_list] assert len(calls) == 15 for call in calls: assert call["properties"]["$ai_trace_id"] == "test-trace-id" @@ -1486,15 +1486,15 @@ def test_span_set_parent_ids(mock_client, trace_id): chain = prompt | model chain.invoke({}, config={"callbacks": callbacks}) - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - span_props = mock_client.capture.call_args_list[0][1] + span_props = mock_client.capture_ai.call_args_list[0][1] assert ( span_props["properties"]["$ai_trace_id"] == span_props["properties"]["$ai_parent_id"] ) - generation_props = mock_client.capture.call_args_list[1][1] + generation_props = mock_client.capture_ai.call_args_list[1][1] assert ( generation_props["properties"]["$ai_trace_id"] == generation_props["properties"]["$ai_parent_id"] @@ -1516,9 +1516,9 @@ def span_3(_): chain = RunnableLambda(span_1) chain.invoke({}, config={"callbacks": callbacks}) - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - calls = mock_client.capture.call_args_list + calls = mock_client.capture_ai.call_args_list span_props_2 = calls[0][1]["properties"] span_props_1 = calls[1][1]["properties"] trace_props = calls[2][1]["properties"] @@ -1538,12 +1538,12 @@ def span(_): except ValueError: pass - assert mock_client.capture.call_count == 2 + assert mock_client.capture_ai.call_count == 2 assert ( - mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"] + mock_client.capture_ai.call_args_list[1][1]["properties"]["$ai_error"] == "ValueError: test" ) - assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"] + assert mock_client.capture_ai.call_args_list[1][1]["properties"]["$ai_is_error"] def test_captures_error_without_details_in_span(mock_client): @@ -1557,12 +1557,12 @@ def span(_): except ValueError: pass - assert mock_client.capture.call_count == 2 + assert mock_client.capture_ai.call_count == 2 assert ( - mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"] + mock_client.capture_ai.call_args_list[1][1]["properties"]["$ai_error"] == "ValueError" ) - assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"] + assert mock_client.capture_ai.call_args_list[1][1]["properties"]["$ai_is_error"] def test_openai_reasoning_tokens(mock_client): @@ -1591,9 +1591,9 @@ def test_openai_reasoning_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Let me think through this step by step..." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -1626,9 +1626,9 @@ def test_anthropic_cache_write_and_read_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "I've analyzed the document and cached the context." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -1661,9 +1661,9 @@ def test_anthropic_cache_write_and_read_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Using cached analysis to provide quick response." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -1714,7 +1714,7 @@ def test_anthropic_provider_subtracts_cache_tokens(mock_client): cb._pop_run_and_capture_generation(run_id, None, response) - generation_args = mock_client.capture.call_args_list[0][1] + generation_args = mock_client.capture_ai.call_args_list[0][1] assert generation_args["properties"]["$ai_input_tokens"] == 400 # 1200 - 800 assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800 @@ -1757,7 +1757,7 @@ def test_anthropic_provider_subtracts_cache_write_tokens(mock_client): cb._pop_run_and_capture_generation(run_id, None, response) - generation_args = mock_client.capture.call_args_list[0][1] + generation_args = mock_client.capture_ai.call_args_list[0][1] assert generation_args["properties"]["$ai_input_tokens"] == 200 # 1000 - 800 assert generation_args["properties"]["$ai_cache_creation_input_tokens"] == 800 @@ -1930,7 +1930,7 @@ def test_anthropic_provider_subtracts_both_cache_read_and_write_tokens(mock_clie cb._pop_run_and_capture_generation(run_id, None, response) - generation_args = mock_client.capture.call_args_list[0][1] + generation_args = mock_client.capture_ai.call_args_list[0][1] # 2000 - 800 (read) - 500 (write) = 700 assert generation_args["properties"]["$ai_input_tokens"] == 700 assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800 @@ -1966,9 +1966,9 @@ def test_openai_cache_read_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Response using cached prompt context." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2010,9 +2010,9 @@ def test_openai_cache_creation_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Created cache for the prompt context." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2052,9 +2052,9 @@ def test_combined_reasoning_and_cache_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Let me reason through this using cached context..." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2074,7 +2074,7 @@ def test_openai_reasoning_tokens_o4_mini(mock_client): mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id" ) model.invoke("what is the weather in sf", config={"callbacks": [cb]}) - call = mock_client.capture.call_args_list[0][1] + call = mock_client.capture_ai.call_args_list[0][1] assert call["properties"]["$ai_reasoning_tokens"] is not None assert call["properties"]["$ai_input_tokens"] is not None assert call["properties"]["$ai_output_tokens"] is not None @@ -2082,7 +2082,7 @@ def test_openai_reasoning_tokens_o4_mini(mock_client): def test_callback_handler_without_client(): """Test that CallbackHandler works properly when no PostHog client is passed.""" - with patch("posthog.ai.langchain.callbacks.setup") as mock_setup: + with patch("posthog.ai.utils.setup") as mock_setup: mock_client = mock_setup.return_value callbacks = CallbackHandler() @@ -2103,7 +2103,7 @@ def test_callback_handler_without_client(): assert result.content == "Bar" # Verify that the mock client was used for capturing events - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 def test_convert_message_to_dict_tool_calls(): @@ -2210,8 +2210,8 @@ def test_tool_definition(mock_client): parent_run_id=None, ) - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == run_id @@ -2261,9 +2261,9 @@ def test_cache_read_tokens_subtraction_from_input_tokens(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Response using cached prompt context." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2303,9 +2303,9 @@ def test_cache_read_tokens_subtraction_prevents_negative(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Response with edge case token counts." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2344,9 +2344,9 @@ def test_no_cache_read_tokens_no_subtraction(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Response without cache." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2383,9 +2383,9 @@ def test_zero_input_tokens_with_cache_read(mock_client): result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Response." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2424,9 +2424,9 @@ def test_non_anthropic_cache_write_tokens_not_subtracted_from_input(mock_client) result = chain.invoke({}, config={"callbacks": callbacks}) assert result.content == "Creating cache." - assert mock_client.capture.call_count == 3 + assert mock_client.capture_ai.call_count == 3 - generation_args = mock_client.capture.call_args_list[1][1] + generation_args = mock_client.capture_ai.call_args_list[1][1] generation_props = generation_args["properties"] assert generation_args["event"] == "$ai_generation" @@ -2475,8 +2475,8 @@ def test_agent_action_and_finish_imports(): callbacks.on_agent_finish(finish, run_id=run_id, parent_run_id=parent_run_id) # Verify capture was called - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] assert call_args["event"] == "$ai_span" @@ -2583,8 +2583,8 @@ def test_billable_property_in_generation_event(mock_client): parent_run_id=None, ) - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["event"] == "$ai_generation" @@ -2603,7 +2603,7 @@ def test_billable_defaults_to_false_in_event(mock_client): chain.invoke({}, config={"callbacks": callbacks}) generation_call = None - for call in mock_client.capture.call_args_list: + for call in mock_client.capture_ai.call_args_list: if call[1]["event"] == "$ai_generation": generation_call = call break @@ -2647,8 +2647,8 @@ def test_billable_with_real_chain(mock_client): parent_run_id=None, ) - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["event"] == "$ai_generation" @@ -2706,7 +2706,7 @@ def failing_span(_): # Find the span event (should have $ai_is_error=True) span_calls = [ call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if call[1].get("properties", {}).get("$ai_is_error") is True ] assert len(span_calls) >= 1 @@ -2739,7 +2739,7 @@ def failing_span(_): # But the span event should still have error info span_calls = [ call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if call[1].get("properties", {}).get("$ai_is_error") is True ] assert len(span_calls) >= 1 @@ -2777,7 +2777,7 @@ def test_exception_autocapture_on_llm_generation_error(mock_client): # Verify the generation event has $exception_event_id generation_calls = [ call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if call[1].get("event") == "$ai_generation" ] assert len(generation_calls) == 1 @@ -2851,7 +2851,7 @@ def failing_span(_): # Span event should NOT have $exception_event_id span_calls = [ call - for call in mock_client.capture.call_args_list + for call in mock_client.capture_ai.call_args_list if call[1].get("properties", {}).get("$ai_is_error") is True ] assert len(span_calls) >= 1 @@ -2860,20 +2860,6 @@ def failing_span(_): assert "$exception_event_id" not in span_props -def test_ai_lane_client_routes_through_capture_ai(mock_client): - prompt = ChatPromptTemplate.from_messages([("user", "Who won the world series?")]) - model = FakeMessagesListChatModel(responses=[AIMessage(content="The Dodgers.")]) - mock_client.enable_full_ai_capture = True - callbacks = [CallbackHandler(mock_client)] - - (prompt | model).invoke({}, config={"callbacks": callbacks}) - - mock_client.capture.assert_not_called() - events = [c[1]["event"] for c in mock_client.capture_ai.call_args_list] - assert "$ai_generation" in events - assert "$ai_trace" in events - - def test_served_service_tier_merges_into_model_parameters(mock_client): from langchain_core.messages import AIMessage from langchain_core.outputs import ChatGeneration, LLMResult @@ -2894,7 +2880,7 @@ def test_served_service_tier_merges_into_model_parameters(mock_client): cb._pop_run_and_capture_generation(run_id, None, response) - props = mock_client.capture.call_args.kwargs["properties"] + props = mock_client.capture_ai.call_args.kwargs["properties"] assert props["$ai_model_parameters"]["service_tier"] == "flex" assert props["$ai_model_parameters"]["temperature"] == 0.5 assert props["$ai_service_tier"] == "flex" @@ -2951,5 +2937,5 @@ def test_stop_reason_resolution( cb._pop_run_and_capture_generation(run_id, None, response) - props = mock_client.capture.call_args.kwargs["properties"] + props = mock_client.capture_ai.call_args.kwargs["properties"] assert props.get("$ai_stop_reason") == expected diff --git a/posthog/test/ai/langchain/test_middleware.py b/posthog/test/ai/langchain/test_middleware.py index ed68bca85..441cdc229 100644 --- a/posthog/test/ai/langchain/test_middleware.py +++ b/posthog/test/ai/langchain/test_middleware.py @@ -121,7 +121,7 @@ def _client() -> MagicMock: def _events(client: MagicMock) -> list[dict[str, Any]]: - return [call.kwargs for call in client.capture.call_args_list] + return [call.kwargs for call in client.capture_ai.call_args_list] def _event(client: MagicMock, event_name: str) -> dict[str, Any]: @@ -353,7 +353,7 @@ async def test_async_hooks_do_not_capture_on_the_event_loop_thread() -> None: client.sync_mode = True event_loop_thread = threading.get_ident() capture_threads: list[int] = [] - client.capture.side_effect = lambda **_: capture_threads.append( + client.capture_ai.side_effect = lambda **_: capture_threads.append( threading.get_ident() ) middleware = PostHogMiddleware(client) @@ -841,7 +841,7 @@ def test_privacy_mode_redacts_agent_and_model_content() -> None: def test_capture_failure_never_changes_agent_result() -> None: client = _client() - client.capture.side_effect = RuntimeError("telemetry unavailable") + client.capture_ai.side_effect = RuntimeError("telemetry unavailable") middleware = PostHogMiddleware(client) state = _middleware_state(middleware) request = _model_request(StubAgentModel(responses=[]), state) diff --git a/posthog/test/ai/openai/test_async_parity.py b/posthog/test/ai/openai/test_async_parity.py index 850b2a885..8a96bfec8 100644 --- a/posthog/test/ai/openai/test_async_parity.py +++ b/posthog/test/ai/openai/test_async_parity.py @@ -46,7 +46,7 @@ def _sync_props(mock_client, chunks): posthog_distinct_id="test-id", ) ) - return mock_client.capture.call_args[1]["properties"] + return mock_client.capture_ai.call_args[1]["properties"] async def _async_props(mock_client, chunks): @@ -68,7 +68,7 @@ async def it(): ) async for _ in stream: pass - return mock_client.capture.call_args[1]["properties"] + return mock_client.capture_ai.call_args[1]["properties"] @pytest.mark.asyncio @@ -76,7 +76,7 @@ async def test_async_streaming_emits_the_same_properties_as_sync( mock_client, streaming_tool_call_chunks ): sync_props = _sync_props(mock_client, streaming_tool_call_chunks) - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() async_props = await _async_props(mock_client, streaming_tool_call_chunks) # Guard: if the sync side stopped emitting these, the comparison below is vacuous. @@ -120,7 +120,7 @@ async def test_responses_streaming_properties_have_sync_async_parity(mock_client ): client = OpenAI(api_key="test-key", posthog_client=mock_client) list(client.responses.create(**request)) - sync_props = mock_client.capture.call_args.kwargs["properties"] + sync_props = mock_client.capture_ai.call_args.kwargs["properties"] async def create(self, **kwargs): async def chunks(): @@ -128,13 +128,13 @@ async def chunks(): return chunks() - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() with patch("openai.resources.responses.AsyncResponses.create", new=create): client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) stream = await client.responses.create(**request) async for _ in stream: pass - async_props = mock_client.capture.call_args.kwargs["properties"] + async_props = mock_client.capture_ai.call_args.kwargs["properties"] sync_without_latency = {k: v for k, v in sync_props.items() if k != "$ai_latency"} async_without_latency = {k: v for k, v in async_props.items() if k != "$ai_latency"} @@ -167,15 +167,15 @@ async def test_embedding_telemetry_has_sync_async_parity(mock_client): client = OpenAI(api_key="test-key", posthog_client=mock_client) assert client.embeddings.create(**request) is response sync_create.assert_called_once_with(**provider_request) - sync_capture = mock_client.capture.call_args + sync_capture = mock_client.capture_ai.call_args async_create = AsyncMock(return_value=response) - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() with patch("openai.resources.embeddings.AsyncEmbeddings.create", new=async_create): client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client) assert await client.embeddings.create(**request) is response async_create.assert_awaited_once_with(**provider_request) - async_capture = mock_client.capture.call_args + async_capture = mock_client.capture_ai.call_args sync_props = sync_capture.kwargs["properties"] async_props = async_capture.kwargs["properties"] @@ -186,7 +186,8 @@ async def test_embedding_telemetry_has_sync_async_parity(mock_client): assert async_props["$ai_input"] is None assert async_props["$ai_input_tokens"] == 12 assert async_props["$ai_provider"] == "azure" - assert async_props["$process_person_profile"] is False + assert async_capture.kwargs["options"] == sync_capture.kwargs["options"] + assert async_capture.kwargs["options"] == {"process_person_profile": False} assert async_capture.kwargs["distinct_id"] == "shared-trace" assert async_capture.kwargs["groups"] == {"company": "test-company"} @@ -208,5 +209,5 @@ def test_sync_stream_close_after_early_exit_captures_partial_state( assert next(stream) == streaming_tool_call_chunks[0] stream.close() - assert mock_client.capture.call_count == 1 - assert mock_client.capture.call_args.kwargs["properties"]["$ai_model"] == "gpt-4" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args.kwargs["properties"]["$ai_model"] == "gpt-4" diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index 0d22f393a..21f40bf6e 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -2,7 +2,7 @@ import logging import os import time -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -393,9 +393,9 @@ def test_basic_completion(mock_client, mock_openai_response): ) assert response == mock_openai_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -440,9 +440,9 @@ def test_embeddings(mock_client, mock_embedding_response): ) assert response == mock_embedding_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -470,9 +470,9 @@ def test_groups(mock_client, mock_openai_response): ) assert response == mock_openai_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] assert call_args["groups"] == {"company": "test_company"} @@ -491,9 +491,9 @@ def test_privacy_mode_local(mock_client, mock_openai_response): ) assert response == mock_openai_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -514,9 +514,9 @@ def test_privacy_mode_global(mock_client, mock_openai_response): ) assert response == mock_openai_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"] is None assert props["$ai_output_choices"] is None @@ -533,9 +533,9 @@ def test_error(mock_client, mock_openai_response): model="gpt-4", messages=[{"role": "user", "content": "Hello"}] ) - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_is_error"] is True assert props["$ai_error"] == "Test error" @@ -555,9 +555,9 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens): ) assert response == mock_openai_response_with_cached_tokens - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -594,9 +594,9 @@ def test_null_token_details_do_not_crash( ) assert response == mock_openai_response_with_null_token_details - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input_tokens"] == 20 @@ -630,9 +630,9 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls): ) assert response == mock_openai_response_with_tool_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -701,9 +701,9 @@ def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls ) assert response == mock_openai_response_tool_calls_only - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -756,9 +756,9 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call ) assert response == mock_responses_api_with_tool_calls - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -789,6 +789,25 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call assert props["$ai_http_status"] == 200 +def test_client_without_capture_ai_still_returns_the_provider_response( + mock_openai_response, caplog +): + ph_client = MagicMock(spec=["capture", "privacy_mode"]) + with patch( + "openai.resources.chat.completions.Completions.create", + return_value=mock_openai_response, + ): + with caplog.at_level(logging.ERROR, logger="posthog"): + client = OpenAI(api_key="test-key", posthog_client=ph_client) + response = client.chat.completions.create( + model="gpt-4", messages=[{"role": "user", "content": "Hello"}] + ) + + assert response == mock_openai_response + assert "capture_ai" in caplog.text + ph_client.capture.assert_not_called() + + def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks): # Mock the create method to return our chunks with patch("openai.resources.chat.completions.Completions.create") as mock_create: @@ -825,9 +844,9 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks): assert chunks == streaming_tool_call_chunks # Verify the capture was called with the right arguments - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -901,9 +920,9 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api): posthog_properties={"foo": "bar"}, ) assert response == mock_openai_response_with_responses_api - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -966,9 +985,9 @@ def test_responses_parse(mock_client, mock_parsed_response): ) assert response == mock_parsed_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1018,9 +1037,9 @@ def test_chat_completions_parse(mock_client, mock_openai_response): assert response == mock_openai_response assert mock_parse.call_count == 1 assert "posthog_distinct_id" not in mock_parse.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1059,9 +1078,9 @@ async def test_async_chat_completions_parse(mock_client, mock_openai_response): assert response == mock_openai_response mock_parse.assert_awaited_once() assert "posthog_distinct_id" not in mock_parse.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1184,9 +1203,9 @@ def mock_streaming_response(**kwargs): assert "stream_options" not in captured_kwargs # Verify capture was called - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify tokens are captured correctly from response.usage (not 0) @@ -1249,8 +1268,8 @@ async def chunk_iterable(): assert len(chunks) == len(streaming_tool_call_chunks) assert chunks == streaming_tool_call_chunks - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1317,8 +1336,8 @@ async def chunk_iterable(): kwargs = captured_kwargs["kwargs"] assert "stream_options" not in kwargs - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1347,9 +1366,9 @@ async def test_async_embeddings_create(mock_client, mock_embedding_response): assert response == mock_embedding_response assert mock_create.await_count == 1 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1398,9 +1417,9 @@ def test_tool_definition(mock_client, mock_openai_response): ) assert response == mock_openai_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["distinct_id"] == "test-id" @@ -1462,9 +1481,9 @@ def __init__(self): ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is detected (binary detection) @@ -1521,9 +1540,9 @@ def mock_create_call(**kwargs): ) assert result == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify exact web search count @@ -1608,9 +1627,9 @@ def test_streaming_with_web_search(mock_client, streaming_web_search_chunks): # Verify the chunks were returned correctly assert len(chunks) == 3 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured (binary detection = 1) @@ -1644,9 +1663,9 @@ def test_streaming_with_web_search_on_non_usage_chunk( # Verify the chunks were returned correctly assert len(chunks) == 3 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured even though citations were on first chunk @@ -1700,9 +1719,9 @@ async def mock_create(self, **kwargs): ) assert response == mock_response - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured (binary detection = 1) @@ -1749,9 +1768,9 @@ async def chunk_iterable(): # Verify the chunks were returned correctly assert len(chunks) == 3 - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Verify web search count is captured (binary detection = 1) @@ -1816,8 +1835,8 @@ def test_streaming_chat_extracts_model_from_chunk_when_not_in_kwargs(mock_client # Consume the generator list(response_generator) - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Model should be extracted from chunk, not kwargs @@ -1861,7 +1880,7 @@ def test_streaming_chat_prefers_kwargs_model_over_chunk_model(mock_client): list(response_generator) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # kwargs model should take precedence @@ -1909,7 +1928,7 @@ def test_streaming_responses_api_extracts_model_from_response_object(mock_client list(response_generator) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Model should be extracted from chunk.response.model @@ -1983,7 +2002,7 @@ def test_streaming_responses_api_captures_usage_and_output_when_incomplete( ) list(response_generator) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] == "max_output_tokens" assert props["$ai_input_tokens"] == 20 assert props["$ai_output_tokens"] == 16 @@ -2033,7 +2052,7 @@ def test_non_streaming_extracts_model_from_response(mock_client): ) assert response == mock_response - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Model should be extracted from response.model @@ -2093,7 +2112,7 @@ def test_non_streaming_responses_api_extracts_model_from_response(mock_client): ) assert response == mock_response - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Model should be extracted from response.model @@ -2139,7 +2158,7 @@ def test_non_streaming_returns_none_when_no_model(mock_client): posthog_distinct_id="test-id", ) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Should be None, NOT "unknown" (to avoid incorrect cost matching) @@ -2178,7 +2197,7 @@ def test_streaming_falls_back_to_unknown_when_no_model(mock_client): list(response_generator) - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] # Should fall back to "unknown" @@ -2231,7 +2250,7 @@ async def chunk_iterable(): async for _ in response_stream: pass - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_model"] == "gpt-4o-async-stored" @@ -2281,7 +2300,7 @@ async def chunk_iterable(): async for _ in response_stream: pass - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_model"] == "gpt-4o-mini-async-stored" @@ -2300,7 +2319,7 @@ def test_integration_stop_reason(mock_client): posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_stop_reason"] in ("stop", "length") assert props["$ai_provider"] == "openai" assert props["$ai_input_tokens"] > 0 @@ -2334,8 +2353,8 @@ async def mock_create(self, **kwargs): chunks.append(chunk) assert chunks == streaming_tool_call_chunks - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert call_args["event"] == "$ai_generation" assert props["$ai_provider"] == "openai" @@ -2369,7 +2388,7 @@ async def mock_create(self, **kwargs): received = [c async for c in stream] assert received == [chunk] - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 @pytest.mark.asyncio @@ -2400,25 +2419,7 @@ async def mock_create(self, **kwargs): break assert source.closed is True - assert mock_client.capture.call_count == 1 - - -def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_openai_response): - mock_client.enable_full_ai_capture = True - with patch( - "openai.resources.chat.completions.Completions.create", - return_value=mock_openai_response, - ): - client = OpenAI(api_key="test-key", posthog_client=mock_client) - client.chat.completions.create( - model="gpt-4", - messages=[{"role": "user", "content": "Hello"}], - posthog_distinct_id="test-id", - ) - - mock_client.capture.assert_not_called() assert mock_client.capture_ai.call_count == 1 - assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" def test_multimodal_client_skips_media_redaction(mock_client, mock_openai_response): @@ -2481,8 +2482,8 @@ def test_provider_override_chat_completions(mock_client, mock_openai_response): # The override must never reach the underlying OpenAI request. assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 - call_args = mock_client.capture.call_args[1] + assert mock_client.capture_ai.call_count == 1 + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_provider"] == "deepseek" @@ -2507,8 +2508,8 @@ def test_provider_override_omitted_defaults_to_openai( posthog_distinct_id="test-id", ) - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "openai" @@ -2549,8 +2550,8 @@ def test_provider_override_streaming_chat_completions(mock_client): list(response_generator) assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "groq" assert props["$ai_model"] == "gpt-4" @@ -2572,9 +2573,9 @@ def test_provider_override_responses_api( assert response == mock_openai_response_with_responses_api assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_provider"] == "xai" assert props["$ai_model"] == "gpt-4o-mini" @@ -2595,9 +2596,9 @@ def test_provider_override_embeddings(mock_client, mock_embedding_response): assert response == mock_embedding_response assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_provider"] == "mistral" assert props["$ai_model"] == "text-embedding-3-small" @@ -2620,9 +2621,9 @@ def test_provider_override_chat_completions_parse(mock_client, mock_openai_respo assert response == mock_openai_response assert mock_parse.call_count == 1 assert "posthog_provider_override" not in mock_parse.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client.capture.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_provider"] == "cerebras" assert props["$ai_model"] == "gpt-4" @@ -2649,9 +2650,9 @@ async def test_async_provider_override_chat_completions( assert response == mock_openai_response mock_create.assert_awaited_once() assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "together" assert props["$ai_model"] == "gpt-4" @@ -2675,8 +2676,8 @@ async def test_async_provider_override_omitted_defaults_to_openai( posthog_distinct_id="test-id", ) - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "openai" @@ -2715,8 +2716,8 @@ async def chunk_iterable(): assert "posthog_provider_override" not in captured_kwargs["kwargs"] assert len(chunks) == len(streaming_tool_call_chunks) - assert mock_client.capture.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "fireworks" assert props["$ai_model"] == "gpt-4" @@ -2738,9 +2739,9 @@ async def test_async_provider_override_embeddings(mock_client, mock_embedding_re assert response == mock_embedding_response assert mock_create.await_count == 1 assert "posthog_provider_override" not in mock_create.call_args.kwargs - assert mock_client.capture.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_provider"] == "perplexity" assert props["$ai_model"] == "text-embedding-3-small" @@ -2760,7 +2761,7 @@ def test_served_service_tier_lands_in_model_parameters( posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_model_parameters"]["service_tier"] == "flex" assert props["$ai_service_tier"] == "flex" @@ -2777,7 +2778,7 @@ def test_response_without_service_tier_omits_it(mock_client, mock_openai_respons posthog_distinct_id="test-id", ) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] assert "service_tier" not in props["$ai_model_parameters"] assert "$ai_service_tier" not in props diff --git a/posthog/test/ai/openai_agents/test_processor.py b/posthog/test/ai/openai_agents/test_processor.py index 3cbc6d276..74f2336ac 100644 --- a/posthog/test/ai/openai_agents/test_processor.py +++ b/posthog/test/ai/openai_agents/test_processor.py @@ -109,7 +109,7 @@ def test_on_trace_start_stores_metadata(self, processor, mock_client, mock_trace """Test that on_trace_start stores metadata but does not capture an event.""" processor.on_trace_start(mock_trace) - mock_client.capture.assert_not_called() + mock_client.capture_ai.assert_not_called() assert mock_trace.trace_id in processor._trace_metadata def test_on_trace_end_captures_ai_trace(self, processor, mock_client, mock_trace): @@ -117,8 +117,8 @@ def test_on_trace_end_captures_ai_trace(self, processor, mock_client, mock_trace processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) - mock_client.capture.assert_called_once() - call_kwargs = mock_client.capture.call_args[1] + mock_client.capture_ai.assert_called_once() + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_trace" assert call_kwargs["distinct_id"] == "test-user" @@ -137,10 +137,10 @@ def test_group_id_becomes_session_id( mock_span.span_data = GenerationSpanData(model="gpt-4o") processor.on_span_start(mock_span) processor.on_span_end(mock_span) - span_kwargs = mock_client.capture.call_args[1] + span_kwargs = mock_client.capture_ai.call_args[1] processor.on_trace_end(mock_trace) - trace_kwargs = mock_client.capture.call_args[1] + trace_kwargs = mock_client.capture_ai.call_args[1] assert span_kwargs["properties"]["$ai_session_id"] == "group_123" assert span_kwargs["properties"]["$ai_group_id"] == "group_123" @@ -156,8 +156,8 @@ def test_personless_mode_when_no_distinct_id(self, mock_client, mock_trace): processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) - call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + call_kwargs = mock_client.capture_ai.call_args[1] + assert call_kwargs["options"] == {"process_person_profile": False} # Should fallback to trace_id as the distinct_id assert call_kwargs["distinct_id"] == mock_trace.trace_id @@ -170,7 +170,7 @@ def test_personless_mode_for_spans_when_no_distinct_id( ) processor.on_trace_start(mock_trace) - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() span_data = GenerationSpanData(model="gpt-4o") mock_span.span_data = span_data @@ -178,8 +178,8 @@ def test_personless_mode_for_spans_when_no_distinct_id( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + call_kwargs = mock_client.capture_ai.call_args[1] + assert call_kwargs["options"] == {"process_person_profile": False} assert call_kwargs["distinct_id"] == mock_span.trace_id def test_personless_mode_when_callable_returns_none( @@ -196,7 +196,7 @@ def resolver(trace): ) processor.on_trace_start(mock_trace) - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() span_data = GenerationSpanData(model="gpt-4o") mock_span.span_data = span_data @@ -204,8 +204,8 @@ def resolver(trace): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + call_kwargs = mock_client.capture_ai.call_args[1] + assert call_kwargs["options"] == {"process_person_profile": False} assert call_kwargs["distinct_id"] == mock_span.trace_id def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace): @@ -218,8 +218,8 @@ def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace) processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) - call_kwargs = mock_client.capture.call_args[1] - assert "$process_person_profile" not in call_kwargs["properties"] + call_kwargs = mock_client.capture_ai.call_args[1] + assert "options" not in call_kwargs def test_on_trace_end_clears_metadata(self, processor, mock_client, mock_trace): """Test that on_trace_end clears stored trace metadata.""" @@ -229,7 +229,7 @@ def test_on_trace_end_clears_metadata(self, processor, mock_client, mock_trace): processor.on_trace_end(mock_trace) assert mock_trace.trace_id not in processor._trace_metadata # Also verify it captured the event - mock_client.capture.assert_called_once() + mock_client.capture_ai.assert_called_once() def test_on_span_start_tracks_time(self, processor, mock_span): """Test that on_span_start records start time.""" @@ -250,8 +250,8 @@ def test_generation_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - mock_client.capture.assert_called_once() - call_kwargs = mock_client.capture.call_args[1] + mock_client.capture_ai.assert_called_once() + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_generation" assert call_kwargs["properties"]["$ai_trace_id"] == "trace_123456789" @@ -285,7 +285,7 @@ def test_generation_span_with_reasoning_tokens( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_reasoning_tokens"] == 400 def test_function_span_mapping(self, processor, mock_client, mock_span): @@ -300,7 +300,7 @@ def test_function_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_name"] == "get_weather" @@ -323,7 +323,7 @@ def test_agent_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_name"] == "CustomerServiceAgent" @@ -345,7 +345,7 @@ def test_handoff_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_type"] == "handoff" @@ -367,7 +367,7 @@ def test_guardrail_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_name"] == "ContentFilter" @@ -385,7 +385,7 @@ def test_custom_span_mapping(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_name"] == "database_query" @@ -414,7 +414,7 @@ def test_privacy_mode_redacts_content(self, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] # Content should be redacted assert call_kwargs["properties"]["$ai_input"] is None @@ -435,12 +435,12 @@ def test_client_privacy_mode_redacts_content(self, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - properties = mock_client.capture.call_args.kwargs["properties"] + properties = mock_client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] is None assert properties["$ai_output_choices"] is None def test_client_without_privacy_mode_captures_content(self, mock_span): - client = SimpleNamespace(capture=MagicMock()) + client = SimpleNamespace(capture_ai=MagicMock()) processor = PostHogTracingProcessor(client=client) mock_span.span_data = GenerationSpanData( input=[{"role": "user", "content": "Visible message"}], @@ -450,7 +450,7 @@ def test_client_without_privacy_mode_captures_content(self, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - properties = client.capture.call_args.kwargs["properties"] + properties = client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] == [ {"role": "user", "content": "Visible message"} ] @@ -480,7 +480,7 @@ def test_generation_span_image_input_is_redacted( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] captured_input = call_kwargs["properties"]["$ai_input"] assert captured_input[0]["content"][0]["image_url"] == "[base64 image redacted]" @@ -534,7 +534,7 @@ def test_response_span_image_output_is_redacted( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] captured_output = call_kwargs["properties"]["$ai_output_choices"] assert ( captured_output[0]["content"][0]["image_url"] == "[base64 image redacted]" @@ -549,7 +549,7 @@ def test_error_handling_in_span(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_is_error"] is True assert call_kwargs["properties"]["$ai_error"] == "Rate limit exceeded" @@ -567,7 +567,7 @@ def test_generation_span_includes_total_tokens( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_total_tokens"] == 150 def test_error_type_categorization_model_behavior( @@ -584,7 +584,7 @@ def test_error_type_categorization_model_behavior( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error" def test_error_type_categorization_user_error( @@ -598,7 +598,7 @@ def test_error_type_categorization_user_error( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_error_type"] == "user_error" def test_error_type_categorization_input_guardrail( @@ -614,7 +614,7 @@ def test_error_type_categorization_input_guardrail( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert ( call_kwargs["properties"]["$ai_error_type"] == "input_guardrail_triggered" ) @@ -632,7 +632,7 @@ def test_error_type_categorization_output_guardrail( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert ( call_kwargs["properties"]["$ai_error_type"] == "output_guardrail_triggered" ) @@ -648,7 +648,7 @@ def test_error_type_categorization_max_turns( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_error_type"] == "max_turns_exceeded" def test_error_type_categorization_unknown(self, processor, mock_client, mock_span): @@ -660,7 +660,7 @@ def test_error_type_categorization_unknown(self, processor, mock_client, mock_sp processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_error_type"] == "unknown" def test_response_span_with_output_and_total_tokens( @@ -686,7 +686,7 @@ def test_response_span_with_output_and_total_tokens( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_generation" assert call_kwargs["properties"]["$ai_total_tokens"] == 35 @@ -715,7 +715,7 @@ def test_response_span_includes_total_cost( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_total_cost_usd"] == total_cost_usd def test_speech_span_with_pass_through_properties( @@ -735,7 +735,7 @@ def test_speech_span_with_pass_through_properties( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_type"] == "speech" @@ -768,7 +768,7 @@ def test_transcription_span_with_pass_through_properties( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["event"] == "$ai_span" assert call_kwargs["properties"]["$ai_span_type"] == "transcription" @@ -799,7 +799,7 @@ def test_transcription_span_audio_input_redacted( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_input"] == "[base64 audio redacted]" def test_transcription_span_audio_input_passthrough( @@ -834,7 +834,7 @@ def test_latency_calculation(self, processor, mock_client, mock_span): mock_time.return_value = 1001.5 # 1.5 seconds later processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_latency"] == pytest.approx(1.5, rel=0.01) def test_groups_included_in_events(self, mock_client, mock_trace, mock_span): @@ -848,7 +848,7 @@ def test_groups_included_in_events(self, mock_client, mock_trace, mock_span): processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["groups"] == {"company": "acme", "team": "engineering"} def test_additional_properties_included(self, mock_client, mock_trace): @@ -862,7 +862,7 @@ def test_additional_properties_included(self, mock_client, mock_trace): processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["environment"] == "production" assert call_kwargs["properties"]["version"] == "1.0" @@ -889,7 +889,7 @@ def test_generation_span_with_no_usage(self, processor, mock_client, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert "$ai_input_tokens" not in call_kwargs["properties"] assert "$ai_output_tokens" not in call_kwargs["properties"] assert "$ai_total_tokens" not in call_kwargs["properties"] @@ -907,7 +907,7 @@ def test_generation_span_with_partial_usage( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_input_tokens"] == 42 # The output side was never reported, so it is omitted rather than 0; # the total is the sum of the reported sides. @@ -928,7 +928,7 @@ def test_error_type_categorization_by_type_field_only( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error" def test_distinct_id_resolved_from_trace_for_spans( @@ -946,7 +946,7 @@ def resolver(trace): # Start trace - this resolves and stores distinct_id processor.on_trace_start(mock_trace) - mock_client.capture.reset_mock() + mock_client.capture_ai.reset_mock() # End a span - should use the stored distinct_id from trace span_data = GenerationSpanData(model="gpt-4o") @@ -955,7 +955,7 @@ def resolver(trace): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client.capture.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["distinct_id"] == "user-Test Workflow" def test_eviction_of_stale_entries(self, mock_client): @@ -1044,24 +1044,13 @@ def test_processor_groups_are_forwarded(self, mock_client): processor._capture_event("$ai_trace", {}) - assert mock_client.capture.call_args.kwargs["groups"] == {"company": "acme"} + assert mock_client.capture_ai.call_args.kwargs["groups"] == {"company": "acme"} def test_capture_errors_are_logged_and_suppressed(self, mock_client, caplog): - mock_client.capture.side_effect = RuntimeError("capture failed") + mock_client.capture_ai.side_effect = RuntimeError("capture failed") processor = PostHogTracingProcessor(client=mock_client) with caplog.at_level(logging.DEBUG, logger="posthog"): processor._capture_event("$ai_trace", {}) assert "Failed to capture PostHog event: capture failed" in caplog.text - - -def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_trace): - mock_client.enable_full_ai_capture = True - processor = PostHogTracingProcessor(client=mock_client, distinct_id="test-user") - processor.on_trace_start(mock_trace) - processor.on_trace_end(mock_trace) - - mock_client.capture.assert_not_called() - mock_client.capture_ai.assert_called_once() - assert mock_client.capture_ai.call_args[1]["event"] == "$ai_trace" diff --git a/posthog/test/ai/test_capture_contract.py b/posthog/test/ai/test_capture_contract.py index 5b4951c76..987e39bec 100644 --- a/posthog/test/ai/test_capture_contract.py +++ b/posthog/test/ai/test_capture_contract.py @@ -246,7 +246,7 @@ class FakePH: def __init__(self): self.events = [] - def capture(self, *args, **kwargs): + def capture_ai(self, *args, **kwargs): self.events.append(kwargs) def flush(self): diff --git a/posthog/test/ai/test_capture_pipeline.py b/posthog/test/ai/test_capture_pipeline.py index 916ad8416..cacbac8d3 100644 --- a/posthog/test/ai/test_capture_pipeline.py +++ b/posthog/test/ai/test_capture_pipeline.py @@ -12,7 +12,7 @@ class FakePH: def __init__(self): self.events = [] - def capture(self, *args, **kwargs): + def capture_ai(self, *args, **kwargs): self.events.append(kwargs) def flush(self): diff --git a/posthog/test/ai/test_token_reporting.py b/posthog/test/ai/test_token_reporting.py index 790a3a6db..e06c2f075 100644 --- a/posthog/test/ai/test_token_reporting.py +++ b/posthog/test/ai/test_token_reporting.py @@ -52,7 +52,7 @@ def test_token_counts_trace_back_to_a_provider_report( ): capture_streaming_event(mock_client, _event_data(usage_stats)) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] for key in ("$ai_input_tokens", "$ai_output_tokens"): if key in expected: assert props[key] == expected[key] @@ -85,7 +85,7 @@ def test_aux_token_fields_trace_back_to_a_provider_report( ): capture_streaming_event(mock_client, _event_data(usage_stats, provider=provider)) - props = mock_client.capture.call_args[1]["properties"] + props = mock_client.capture_ai.call_args[1]["properties"] for key in ( "$ai_cache_read_input_tokens", "$ai_cache_creation_input_tokens", diff --git a/posthog/test/capture_helpers.py b/posthog/test/capture_helpers.py new file mode 100644 index 000000000..d32f4741f --- /dev/null +++ b/posthog/test/capture_helpers.py @@ -0,0 +1,72 @@ +"""Intercept capture uploads at the batch submitter for client-level tests. + +Patching the submitter (not the HTTP layer) lets tests assert on the event +dicts the SDK built, before the wire encoding in ``capture_event``. Wire shape is +covered by ``test_capture_event``. +""" + +import json +from unittest import mock + +from requests import Response + +_SUBMITTER = "_send_v1_batch" + + +def offline_v1_post(url: str, data=None, **kwargs) -> Response: + """Stand-in for ``requests.Session.post`` that accepts every v1 event. + + For subprocess tests with no server. Prints the uncompressed request body, + because the SDK never logs payloads, and answers ``ok`` for each event. + """ + print(f"capture request body: {data}", flush=True) # noqa: T201 + events = json.loads(data)["batch"] + response = Response() + response.status_code = 200 + response._content = json.dumps( + {"results": {event["uuid"]: {"result": "ok"} for event in events}} + ).encode() + return response + + +def patch_capture_send(site: str = "client", **kwargs) -> "mock._patch": + """Patch the submitter where ``posthog.`` imported it. + + ``site="client"`` sees ``sync_mode`` uploads; ``site="consumer"`` sees + background consumer uploads. + """ + return mock.patch(f"posthog.{site}.{_SUBMITTER}", **kwargs) + + +def record_sync_capture_sends(test_case) -> list[dict]: + """Record every event a ``sync_mode`` client uploads until ``test_case`` ends.""" + events: list[dict] = [] + + def record(*args, **kwargs): + events.extend(args[2] if len(args) > 2 else kwargs["batch"]) + return mock.DEFAULT + + patcher = patch_capture_send("client", side_effect=record) + patcher.start() + test_case.addCleanup(patcher.stop) + return events + + +def patch_async_capture_send(**kwargs) -> "mock._patch": + """Patch the submitter the ``AsyncPosthog`` consumer awaits.""" + return mock.patch("posthog._async_consumer.async_send_v1_batch", **kwargs) + + +def sent_batch(send_mock: mock.Mock, call_index: int = -1) -> list[dict]: + """Return the event batch from one recorded upload (default: the last).""" + call = send_mock.call_args_list[call_index] + return call.args[2] if len(call.args) > 2 else call.kwargs["batch"] + + +def sent_events(send_mock: mock.Mock) -> list[dict]: + """Return every event uploaded through ``send_mock``, in send order.""" + return [ + event + for index in range(len(send_mock.call_args_list)) + for event in sent_batch(send_mock, index) + ] diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py new file mode 100644 index 000000000..63ddf938e --- /dev/null +++ b/posthog/test/integrations/test_asgi_integration.py @@ -0,0 +1,429 @@ +from unittest.mock import Mock, patch + +import pytest + +import posthog +from posthog import contexts +from posthog.client import Client +from posthog.integrations.asgi import PosthogASGIMiddleware + + +def http_scope(**overrides): + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "scheme": "https", + "method": "GET", + "path": "/api/items", + "raw_path": b"/api/items", + "query_string": b"token=secret", + "headers": [ + (b"host", b"api.example.com"), + (b"user-agent", b"test-agent/1.0"), + (b"x-forwarded-for", b"203.0.113.5, 10.0.0.1"), + (b"x-posthog-session-id", b"session-123"), + (b"x-posthog-distinct-id", b"user-456"), + ], + "client": ("198.51.100.8", 1234), + "server": ("api.example.com", 443), + } + scope.update(overrides) + return scope + + +async def noop_receive(): + return {"type": "http.disconnect"} + + +async def noop_send(message): + return None + + +@pytest.mark.asyncio +async def test_adds_request_properties_and_tracing_context_then_restores_parent(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["properties"] = contexts.get_tags() + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + sent = [] + + async def send(message): + sent.append(message) + + with contexts.new_context(fresh=True): + contexts.identify_context("parent-user") + contexts.tag("parent-property", "kept") + middleware = PosthogASGIMiddleware(app, trust_tracing_headers=True) + await middleware(http_scope(method="POST"), noop_receive, send) + + assert contexts.get_context_distinct_id() == "parent-user" + assert contexts.get_context_session_id() is None + assert contexts.get_tags() == {"parent-property": "kept"} + + assert observed["session_id"] == "session-123" + assert observed["distinct_id"] == "user-456" + assert observed["properties"] == { + "parent-property": "kept", + "$request_method": "POST", + "$request_path": "/api/items", + "$user_agent": "test-agent/1.0", + "$raw_user_agent": "test-agent/1.0", + "$ip": "203.0.113.5", + "$current_url": "https://api.example.com/api/items", + } + assert sent == [ + {"type": "http.response.start", "status": 204, "headers": []}, + {"type": "http.response.body", "body": b""}, + ] + assert "secret" not in observed["properties"]["$current_url"] + + +@pytest.mark.asyncio +async def test_ignores_client_controlled_tracing_headers_by_default(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["distinct_id"] = contexts.get_context_distinct_id() + + await PosthogASGIMiddleware(app)(http_scope(), noop_receive, noop_send) + + assert observed == {"session_id": None, "distinct_id": None} + + +@pytest.mark.asyncio +async def test_sanitizes_tracing_headers_and_uses_socket_ip_fallback(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["properties"] = contexts.get_tags() + + scope = http_scope( + headers=[ + (b"host", b"example.com"), + (b"x-posthog-session-id", b" session\n-123 "), + (b"x-posthog-distinct-id", b" user\t-456 "), + ] + ) + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) + + assert observed["session_id"] == "session-123" + assert observed["distinct_id"] == "user-456" + assert observed["properties"]["$ip"] == "198.51.100.8" + + +@pytest.mark.asyncio +async def test_malformed_and_duplicate_headers_are_handled_safely(): + observed = {} + + async def app(scope, receive, send): + observed["distinct_id"] = contexts.get_context_distinct_id() + + scope = http_scope( + headers=[ + (b"x-posthog-distinct-id", b"first"), + (b"X-POSTHOG-DISTINCT-ID", b"second"), + ("not-bytes", "ignored"), + (b"incomplete",), + ] + ) + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) + + assert observed["distinct_id"] == "first" + + +@pytest.mark.asyncio +async def test_captures_exception_with_client_and_preserves_propagation(): + client = Mock() + error = RuntimeError("application failed") + + async def app(scope, receive, send): + assert contexts.get_tags()["$request_path"] == "/api/items" + raise error + + middleware = PosthogASGIMiddleware(app, client=client, capture_exceptions=True) + + with pytest.raises(RuntimeError, match="application failed") as raised: + await middleware(http_scope(), noop_receive, noop_send) + + assert raised.value is error + client.capture_exception.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) + + +@pytest.mark.asyncio +async def test_captured_event_uses_canonical_framework_boundary_metadata(): + error = RuntimeError("application failed") + + async def app(scope, receive, send): + raise error + + client = Client("test-api-key", sync_mode=True) + try: + with patch.object(client, "capture", return_value="event-id") as capture: + with pytest.raises(RuntimeError, match="application failed"): + await PosthogASGIMiddleware( + app, client=client, capture_exceptions=True + )(http_scope(), noop_receive, noop_send) + + properties = capture.call_args.kwargs["properties"] + outermost = properties["$exception_list"][0] + assert properties["$exception_level"] == "error" + assert properties["$exception_source"] == "asgi.middleware" + assert outermost["mechanism"] == { + "type": "middleware", + "handled": False, + "exception_id": 0, + "synthetic": False, + } + finally: + client.shutdown() + + +@pytest.mark.asyncio +async def test_captures_exception_with_global_client(): + error = ValueError("bad request handler") + + async def app(scope, receive, send): + raise error + + with patch("posthog.capture_exception") as capture_exception: + with pytest.raises(ValueError, match="bad request handler"): + await PosthogASGIMiddleware(app, capture_exceptions=True)( + http_scope(), noop_receive, noop_send + ) + + capture_exception.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) + + +@pytest.mark.asyncio +async def test_can_disable_exception_capture(): + client = Mock() + + async def app(scope, receive, send): + raise LookupError("not captured") + + with pytest.raises(LookupError, match="not captured"): + await PosthogASGIMiddleware(app, client=client, capture_exceptions=False)( + http_scope(), noop_receive, noop_send + ) + + client.capture_exception.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +async def test_exception_capture_defaults_to_custom_client_setting(enabled): + client = Mock(enable_exception_autocapture=enabled) + + async def app(scope, receive, send): + raise RuntimeError("custom default") + + with pytest.raises(RuntimeError, match="custom default"): + await PosthogASGIMiddleware(app, client=client)( + http_scope(), noop_receive, noop_send + ) + + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("initialized", [False, True]) +async def test_exception_capture_defaults_to_global_client_setting( + enabled, initialized +): + default_client = Mock(enable_exception_autocapture=enabled) if initialized else None + + async def app(scope, receive, send): + raise RuntimeError("global default") + + # Middleware may be registered before the global client is configured. + middleware = PosthogASGIMiddleware(app) + with ( + patch.object(posthog, "default_client", default_client), + patch.object( + posthog, + "enable_exception_autocapture", + not enabled if initialized else enabled, + ), + patch("posthog.capture_exception") as capture_exception, + pytest.raises(RuntimeError, match="global default"), + ): + await middleware(http_scope(), noop_receive, noop_send) + + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + + +@pytest.mark.asyncio +async def test_context_enriches_events_without_redirecting_capture_calls(): + events = [] + + def record_event(event): + events.append(event) + return event + + event_client = Client( + "event-api-key", send=False, sync_mode=True, before_send=record_event + ) + exception_client = Mock(enable_exception_autocapture=False) + + async def app(scope, receive, send): + event_client.capture( + "request event", distinct_id="event-user", properties={"source": "app"} + ) + + try: + await PosthogASGIMiddleware( + app, + client=exception_client, + extra_properties=lambda scope: {"tenant": "acme"}, + )(http_scope(), noop_receive, noop_send) + finally: + event_client.shutdown() + + assert len(events) == 1 + assert events[0]["distinct_id"] == "event-user" + assert events[0]["properties"]["source"] == "app" + assert events[0]["properties"]["tenant"] == "acme" + assert events[0]["properties"]["$request_path"] == "/api/items" + assert {"tenant", "$request_path"} <= set(events[0]["properties"]["$context_tags"]) + exception_client.capture.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_filter", [False, True]) +async def test_request_filter_bypasses_all_instrumentation(async_filter): + observed = {} + client = Mock() + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["properties"] = contexts.get_tags() + raise RuntimeError("filtered") + + if async_filter: + + async def request_filter(scope): + return False + + else: + + def request_filter(scope): + return False + + with contexts.new_context(fresh=True): + contexts.tag("existing", True) + with pytest.raises(RuntimeError, match="filtered"): + await PosthogASGIMiddleware( + app, + client=client, + capture_exceptions=True, + request_filter=request_filter, + )(http_scope(), noop_receive, noop_send) + + assert observed == {"session_id": None, "properties": {"existing": True}} + client.capture_exception.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_properties", [False, True]) +async def test_extra_properties_supports_sync_and_async_callbacks(async_properties): + observed = {} + + async def app(scope, receive, send): + observed.update(contexts.get_tags()) + + if async_properties: + + async def extra_properties(scope): + return {"framework": "fastapi"} + + else: + + def extra_properties(scope): + return {"framework": "starlette"} + + await PosthogASGIMiddleware(app, extra_properties=extra_properties)( + http_scope(), noop_receive, noop_send + ) + + assert observed["framework"] == ("fastapi" if async_properties else "starlette") + + +@pytest.mark.asyncio +async def test_websocket_scope_is_instrumented(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["path"] = contexts.get_tags()["$request_path"] + + scope = http_scope(type="websocket", scheme="wss", method=None, path="/socket") + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) + + assert observed == {"session_id": "session-123", "path": "/socket"} + + +@pytest.mark.asyncio +async def test_lifespan_scope_passes_through_without_context(): + scope = {"type": "lifespan"} + observed = {} + + async def app(received_scope, receive, send): + observed["scope"] = received_scope + observed["properties"] = contexts.get_tags() + + with contexts.new_context(fresh=True): + contexts.tag("existing", "value") + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed == {"scope": scope, "properties": {"existing": "value"}} + + +@pytest.mark.asyncio +async def test_builds_url_from_server_when_host_header_is_absent(): + observed = {} + + async def app(scope, receive, send): + observed.update(contexts.get_tags()) + + scope = http_scope( + headers=[], scheme="http", server=("2001:db8::1", 8080), path="/health" + ) + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed["$current_url"] == "http://[2001:db8::1]:8080/health" diff --git a/posthog/test/integrations/test_celery_integration.py b/posthog/test/integrations/test_celery_integration.py index 565800cac..0e06314eb 100644 --- a/posthog/test/integrations/test_celery_integration.py +++ b/posthog/test/integrations/test_celery_integration.py @@ -424,7 +424,14 @@ def test_task_failure_captures_exception_and_failure_event(self): exception=exception, ) - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) event_names = [call.args[0] for call in mock_client.capture.call_args_list] self.assertIn("celery task failure", event_names) @@ -588,7 +595,14 @@ def test_task_failure_captures_exception_when_lifecycle_events_disabled(self): ) mock_client.capture.assert_not_called() - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) def test_after_task_publish_captures_published_event(self): mock_client = Mock() @@ -656,7 +670,14 @@ def test_capture_exception_falls_back_to_global_capture_exception(self): with patch("posthog.capture_exception") as mock_capture_exception: integration._capture_exception(exception) - mock_capture_exception.assert_called_once_with(exception) + mock_capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) def test_extract_headers_supports_request_dict_shape(self): integration = PosthogCeleryIntegration() diff --git a/posthog/test/integrations/test_drf_integration.py b/posthog/test/integrations/test_drf_integration.py new file mode 100644 index 000000000..154d81517 --- /dev/null +++ b/posthog/test/integrations/test_drf_integration.py @@ -0,0 +1,388 @@ +import builtins +import unittest +from unittest.mock import Mock, patch + +import django +import posthog +from django.conf import settings +from django.test import override_settings + +if not settings.configured: + settings.configure( + DEBUG=True, + SECRET_KEY="test-secret-key", + INSTALLED_APPS=[], + MIDDLEWARE=[], + ) + django.setup() + +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.response import Response + +from posthog.client import Client +from posthog.contexts import ( + get_tags as get_context_properties, + new_context, + tag as set_context_property, +) +from posthog.integrations.drf import create_exception_handler, exception_handler + + +class ServiceUnavailable(APIException): + status_code = 503 + default_detail = "Service unavailable" + + +class TestDjangoRestFrameworkIntegration(unittest.TestCase): + def test_default_handler_captures_5xx_with_canonical_metadata(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = ServiceUnavailable() + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": { + "type": "middleware", + "handled": True, + }, + }, + ) + + def test_default_handler_does_not_capture_expected_4xx(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = ValidationError({"name": ["This field is required."]}) + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 400) + client.capture_exception.assert_not_called() + + def test_capture_4xx_is_opt_in(self): + client = Mock() + handler = create_exception_handler(client=client, capture_4xx=True) + exception = ValidationError("Invalid input") + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 400) + client.capture_exception.assert_called_once() + + def test_unhandled_exception_is_left_for_django_middleware(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = RuntimeError("unhandled") + + response = handler(exception, {}) + + self.assertIsNone(response) + client.capture_exception.assert_not_called() + + def test_custom_handler_response_and_context_are_preserved(self): + client = Mock() + response = Response({"detail": "custom"}, status=502) + delegate = Mock(return_value=response) + handler = create_exception_handler(delegate, client=client) + exception = RuntimeError("upstream failed") + context = {"view": object(), "request": object()} + + returned_response = handler(exception, context) + + self.assertIs(returned_response, response) + delegate.assert_called_once_with(exception, context) + client.capture_exception.assert_called_once() + + def test_custom_handler_exception_is_preserved(self): + client = Mock() + handler_error = LookupError("handler failed") + delegate = Mock(side_effect=handler_error) + handler = create_exception_handler(delegate, client=client) + + with self.assertRaisesRegex(LookupError, "handler failed"): + handler(RuntimeError("view failed"), {}) + + client.capture_exception.assert_not_called() + + def test_explicit_capture_opt_out_preserves_response_without_running_filters(self): + client = Mock() + response = Response({"detail": "unavailable"}, status=503) + delegate = Mock(return_value=response) + exception_filter = Mock(return_value=True) + request_filter = Mock(return_value=True) + handler = create_exception_handler( + delegate, + client=client, + capture_exceptions=False, + exception_filter=exception_filter, + ) + exception = RuntimeError("upstream failed") + context = {"request": object()} + + with override_settings(POSTHOG_MW_REQUEST_FILTER=request_filter): + returned_response = handler(exception, context) + + self.assertIs(returned_response, response) + delegate.assert_called_once_with(exception, context) + request_filter.assert_not_called() + exception_filter.assert_not_called() + client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False) + def test_module_handler_inherits_django_capture_opt_out(self): + exception = ServiceUnavailable() + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(exception, {}) + + self.assertEqual(response.status_code, 503) + capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False) + def test_explicit_capture_setting_overrides_django_opt_out(self): + client = Mock() + handler = create_exception_handler(client=client, capture_exceptions=True) + exception = ServiceUnavailable() + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_explicit_client_default(self): + for enabled in (False, True): + with self.subTest(enable_exception_autocapture=enabled): + client = Mock(spec=Client) + client.enable_exception_autocapture = enabled + handler = create_exception_handler(client=client) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_configured_client_default(self): + drf_client = Mock(spec=Client) + drf_client.enable_exception_autocapture = False + middleware_client = Mock(spec=Client) + middleware_client.enable_exception_autocapture = True + + with override_settings( + POSTHOG_DRF_CLIENT=drf_client, + POSTHOG_MW_CLIENT=middleware_client, + ): + response = exception_handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + drf_client.capture_exception.assert_not_called() + middleware_client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_global_client_default(self): + original_default_client = posthog.default_client + + try: + for enabled in (False, True): + with self.subTest(enable_exception_autocapture=enabled): + global_client = Mock(spec=Client) + global_client.enable_exception_autocapture = enabled + posthog.default_client = global_client + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + finally: + posthog.default_client = original_default_client + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_explicit_capture_setting_overrides_client_default(self): + client = Mock(spec=Client) + client.enable_exception_autocapture = False + handler = create_exception_handler(client=client, capture_exceptions=True) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + def test_omitted_django_capture_setting_preserves_legacy_default(self): + self.assertFalse(hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS")) + original_default_client = posthog.default_client + global_client = Mock(spec=Client) + global_client.enable_exception_autocapture = False + posthog.default_client = global_client + + try: + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(ServiceUnavailable(), {}) + finally: + posthog.default_client = original_default_client + + self.assertEqual(response.status_code, 503) + capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS="invalid") + def test_malformed_django_capture_setting_preserves_legacy_default(self): + client = Mock(spec=Client) + client.enable_exception_autocapture = False + handler = create_exception_handler(client=client) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_REQUEST_FILTER=lambda request: False) + def test_django_middleware_request_filter_suppresses_capture(self): + client = Mock() + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + django_request = object() + drf_request = Mock(_request=django_request) + + response = handler(RuntimeError("filtered"), {"request": drf_request}) + + self.assertEqual(response.status_code, 500) + client.capture_exception.assert_not_called() + + def test_django_middleware_request_filter_receives_underlying_request(self): + client = Mock() + request_filter = Mock(return_value=True) + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + django_request = object() + drf_request = Mock(_request=django_request) + + with override_settings(POSTHOG_MW_REQUEST_FILTER=request_filter): + handler(RuntimeError("tracked"), {"request": drf_request}) + + request_filter.assert_called_once_with(django_request) + client.capture_exception.assert_called_once() + + def test_exception_filter_can_suppress_capture(self): + client = Mock() + exception_filter = Mock(return_value=False) + handler = create_exception_handler( + lambda exc, context: Response(status=500), + client=client, + exception_filter=exception_filter, + ) + exception = RuntimeError("filtered") + context = {"request": object()} + + handler(exception, context) + + exception_filter.assert_called_once() + client.capture_exception.assert_not_called() + + def test_already_captured_exception_is_not_captured_twice(self): + client = Mock() + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + exception = RuntimeError("already captured") + setattr(exception, "__posthog_exception_captured", True) + + handler(exception, {}) + + client.capture_exception.assert_not_called() + + def test_capture_exclusion_preserves_existing_django_context(self): + observed_properties = [] + client = Mock() + + def delegate(exc, context): + observed_properties.append(get_context_properties()) + return Response(status=503) + + handler = create_exception_handler( + delegate, client=client, capture_exceptions=False + ) + + with new_context(): + set_context_property("$request_path", "/api/widgets") + response = handler(RuntimeError("excluded"), {}) + + self.assertEqual(response.status_code, 503) + self.assertEqual(observed_properties, [{"$request_path": "/api/widgets"}]) + client.capture_exception.assert_not_called() + + def test_capture_includes_existing_django_request_properties(self): + observed_properties = [] + client = Mock() + + def capture_exception(*args, **kwargs): + observed_properties.append(get_context_properties()) + + client.capture_exception.side_effect = capture_exception + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + + with new_context(): + set_context_property("$request_path", "/api/widgets") + handler(RuntimeError("failed"), {}) + + self.assertEqual(observed_properties, [{"$request_path": "/api/widgets"}]) + + def test_capture_failure_does_not_change_response(self): + client = Mock() + client.capture_exception.side_effect = RuntimeError("capture failed") + response = Response(status=500) + handler = create_exception_handler(lambda exc, context: response, client=client) + + with self.assertLogs("posthog", level="ERROR"): + returned_response = handler(RuntimeError("view failed"), {}) + + self.assertIs(returned_response, response) + + def test_module_handler_uses_global_client(self): + exception = ServiceUnavailable() + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(exception, {}) + + self.assertEqual(response.status_code, 503) + capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": { + "type": "middleware", + "handled": True, + }, + }, + ) + + def test_importing_module_does_not_import_drf(self): + real_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name == "rest_framework" or name.startswith("rest_framework."): + raise AssertionError("DRF imported eagerly") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=guarded_import): + # Reloading executes all module-level imports and factory setup. + import importlib + import posthog.integrations.drf as drf_integration + + importlib.reload(drf_integration) diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py new file mode 100644 index 000000000..9ce95dd3f --- /dev/null +++ b/posthog/test/integrations/test_flask_integration.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from flask import Flask, abort, jsonify + +import posthog +from posthog import contexts +from posthog.client import Client +from posthog.integrations.flask import ( + PosthogFlaskIntegration, + _sanitize_tracing_header_value, +) + + +def _app() -> Flask: + app = Flask(__name__) + app.config.update(TESTING=True) + return app + + +def test_adds_request_context_and_restores_parent_context() -> None: + app = _app() + PosthogFlaskIntegration( + app, + extra_properties=lambda request: {"tenant": "acme"}, + trust_tracing_headers=True, + ) + + @app.get("/users/") + def view(user_id: str): + scope = contexts._get_current_context() + assert scope is not None + return jsonify( + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + properties=scope.collect_tags(), + ) + + with contexts.new_context(): + contexts.tag("outer", "must-not-leak-into-request") + response = app.test_client().get( + "/users/123?access_token=secret", + headers={ + "X-PostHog-Distinct-Id": " person-1 ", + "X-PostHog-Session-Id": " session-1 ", + "User-Agent": "integration-test/1.0", + }, + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + + payload = response.get_json() + assert payload["distinct_id"] == "person-1" + assert payload["session_id"] == "session-1" + assert payload["properties"] == { + "$current_url": "http://localhost/users/123", + "$ip": "203.0.113.10", + "$raw_user_agent": "integration-test/1.0", + "$request_method": "GET", + "$request_path": "/users/123", + "$request_route": "/users/", + "$user_agent": "integration-test/1.0", + "tenant": "acme", + } + assert "secret" not in payload["properties"]["$current_url"] + + parent = contexts._get_current_context() + assert parent is not None + assert parent.collect_tags() == {"outer": "must-not-leak-into-request"} + + +def test_fresh_request_preserves_enclosing_exception_privacy_settings() -> None: + app = _app() + client = Mock() + observed = {} + + def capture(exception, **kwargs): + scope = contexts._get_current_context() + assert scope is not None + observed.update( + capture_code_variables=contexts.get_capture_exception_code_variables_context(), + mask_patterns=contexts.get_code_variables_mask_patterns_context(), + ignore_patterns=contexts.get_code_variables_ignore_patterns_context(), + mask_url_credentials=contexts.get_code_variables_mask_url_credentials_context(), + detect_secrets=contexts.get_code_variables_detect_secrets_context(), + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + properties=scope.collect_tags(), + ) + return "event-id" + + client.capture_exception.side_effect = capture + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) + + @app.get("/privacy") + def privacy_failure(): + raise ValueError("privacy settings") + + with contexts.new_context(): + contexts.set_capture_exception_code_variables_context(False) + contexts.set_code_variables_mask_patterns_context(["secret"]) + contexts.set_code_variables_ignore_patterns_context(["ignored"]) + contexts.set_code_variables_mask_url_credentials_context(True) + contexts.set_code_variables_detect_secrets_context(True) + contexts.identify_context("outer-person") + contexts.set_context_session("outer-session") + contexts.tag("outer-property", "must-not-leak") + + with pytest.raises(ValueError, match="privacy settings"): + app.test_client().get("/privacy") + + # Teardown restores the enclosing context unchanged. + assert contexts.get_capture_exception_code_variables_context() is False + assert contexts.get_code_variables_mask_patterns_context() == ["secret"] + assert contexts.get_code_variables_ignore_patterns_context() == ["ignored"] + assert contexts.get_code_variables_mask_url_credentials_context() is True + assert contexts.get_code_variables_detect_secrets_context() is True + + properties = observed.pop("properties") + assert properties["$request_path"] == "/privacy" + assert "outer-property" not in properties + assert observed == { + "capture_code_variables": False, + "mask_patterns": ["secret"], + "ignore_patterns": ["ignored"], + "mask_url_credentials": True, + "detect_secrets": True, + # Identity remains isolated by the fresh scope. + "distinct_id": None, + "session_id": None, + } + + +def test_captures_unhandled_exception_once_with_request_properties() -> None: + app = _app() + client = Mock() + captures = [] + + def capture(exception, **kwargs): + scope = contexts._get_current_context() + assert scope is not None + captures.append((exception, kwargs, scope.collect_tags())) + return "event-id" + + client.capture_exception.side_effect = capture + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) + error = ValueError("view failed") + + @app.get("/failure") + def failure(): + raise error + + with pytest.raises(ValueError, match="view failed"): + app.test_client().get("/failure") + + assert len(captures) == 1 + exception, kwargs, properties = captures[0] + assert exception is error + assert kwargs == { + "_capture_metadata": { + "level": "error", + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, + } + } + assert properties["$request_path"] == "/failure" + assert contexts._get_current_context() is None + + +def test_uses_global_client_when_custom_client_is_not_provided() -> None: + app = _app() + PosthogFlaskIntegration(app, capture_exceptions=True) + error = RuntimeError("boom") + + @app.get("/failure") + def failure(): + raise error + + with patch("posthog.capture_exception", return_value="event-id") as capture: + with pytest.raises(RuntimeError, match="boom"): + app.test_client().get("/failure") + + capture.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) + + +def test_does_not_capture_handled_exception_or_expected_http_error() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) + + @app.errorhandler(ValueError) + def handle_value_error(error): + return {"error": str(error)}, 422 + + @app.get("/handled") + def handled(): + raise ValueError("expected") + + @app.get("/missing") + def missing(): + abort(404) + + assert app.test_client().get("/handled").status_code == 422 + assert app.test_client().get("/missing").status_code == 404 + client.capture_exception.assert_not_called() + + +def test_capture_exceptions_can_be_disabled_without_changing_propagation() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration(app, client=client, capture_exceptions=False) + + @app.get("/failure") + def failure(): + raise LookupError("disabled") + + with pytest.raises(LookupError, match="disabled"): + app.test_client().get("/failure") + + client.capture_exception.assert_not_called() + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_exception_capture_defaults_to_custom_client_setting(enabled: bool) -> None: + app = _app() + client = Mock(enable_exception_autocapture=enabled) + PosthogFlaskIntegration(app, client=client) + + @app.get("/failure") + def failure(): + raise RuntimeError("custom default") + + with pytest.raises(RuntimeError, match="custom default"): + app.test_client().get("/failure") + + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("initialized", [False, True]) +def test_exception_capture_defaults_to_global_client_setting( + enabled: bool, initialized: bool +) -> None: + app = _app() + default_client = Mock(enable_exception_autocapture=enabled) if initialized else None + + # App-factory setup may run before the global client is configured. + PosthogFlaskIntegration(app) + + @app.get("/failure") + def failure(): + raise RuntimeError("global default") + + with ( + patch.object(posthog, "default_client", default_client), + patch.object( + posthog, + "enable_exception_autocapture", + not enabled if initialized else enabled, + ), + patch("posthog.capture_exception") as capture_exception, + pytest.raises(RuntimeError, match="global default"), + ): + app.test_client().get("/failure") + + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + + +def test_context_enriches_events_without_redirecting_capture_calls() -> None: + app = _app() + events = [] + + def record_event(event): + events.append(event) + return event + + event_client = Client( + "event-api-key", send=False, sync_mode=True, before_send=record_event + ) + exception_client = Mock(enable_exception_autocapture=False) + PosthogFlaskIntegration( + app, + client=exception_client, + extra_properties=lambda request: {"tenant": "acme"}, + ) + + @app.get("/capture") + def capture_event(): + event_client.capture( + "request event", distinct_id="event-user", properties={"source": "app"} + ) + return "ok" + + try: + assert app.test_client().get("/capture").status_code == 200 + finally: + event_client.shutdown() + + assert len(events) == 1 + assert events[0]["distinct_id"] == "event-user" + assert events[0]["properties"]["source"] == "app" + assert events[0]["properties"]["tenant"] == "acme" + assert events[0]["properties"]["$request_path"] == "/capture" + assert {"tenant", "$request_path"} <= set(events[0]["properties"]["$context_tags"]) + exception_client.capture.assert_not_called() + + +def test_tracing_headers_are_ignored_by_default_and_can_be_trusted() -> None: + def request_identity(trust_tracing_headers: bool) -> dict: + app = _app() + PosthogFlaskIntegration(app, trust_tracing_headers=trust_tracing_headers) + + @app.get("/identity") + def identity(): + return jsonify( + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + ) + + response = app.test_client().get( + "/identity", + headers={ + "X-PostHog-Distinct-Id": "person-1", + "X-PostHog-Session-Id": "session-1", + }, + ) + return response.get_json() + + assert request_identity(False) == {"distinct_id": None, "session_id": None} + assert request_identity(True) == { + "distinct_id": "person-1", + "session_id": "session-1", + } + + +def test_request_filter_skips_context_and_exception_capture() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration( + app, + client=client, + capture_exceptions=True, + request_filter=lambda request: request.path != "/ignored", + ) + + @app.get("/ignored") + def ignored(): + assert contexts._get_current_context() is None + raise RuntimeError("ignored") + + with pytest.raises(RuntimeError, match="ignored"): + app.test_client().get("/ignored") + + client.capture_exception.assert_not_called() + + +def test_supports_application_factory_pattern_and_rejects_duplicate_setup() -> None: + app = _app() + integration = PosthogFlaskIntegration() + integration.init_app(app) + + assert app.extensions["posthog"] is integration + + with pytest.raises(RuntimeError, match="already initialized"): + PosthogFlaskIntegration(app) + + +def test_sanitizes_and_bounds_tracing_headers() -> None: + assert _sanitize_tracing_header_value(" person\n-\t1\x85 ") == "person-1" + assert _sanitize_tracing_header_value("\r\n") is None + assert _sanitize_tracing_header_value(123) is None + assert _sanitize_tracing_header_value("a" * 1001) == "a" * 1000 diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index 1d4e26089..d58065bf3 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -12,6 +12,7 @@ # Configure Django settings before importing middleware import django from django.conf import settings +from django.test import override_settings if not settings.configured: settings.configure( @@ -55,6 +56,7 @@ def create_middleware( tag_map=None, capture_exceptions=True, get_response=None, + trust_tracing_headers=True, ): """Helper to create middleware instance with mock Django settings""" if get_response is None: @@ -67,6 +69,7 @@ def create_middleware( mock_settings.POSTHOG_MW_TAG_MAP = tag_map mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions mock_settings.POSTHOG_MW_CLIENT = None + mock_settings.POSTHOG_MW_TRUST_TRACING_HEADERS = trust_tracing_headers # Make hasattr work correctly def mock_hasattr(obj, name): @@ -309,7 +312,14 @@ def mock_get_response(request): response = middleware(request) self.assertEqual(response.status_code, 500) - mock_client.capture_exception.assert_called_once_with(view_exception) + mock_client.capture_exception.assert_called_once_with( + view_exception, + _capture_metadata={ + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) def test_process_exception_respects_capture_exceptions_false(self): """Verify process_exception respects capture_exceptions=False setting""" @@ -445,7 +455,14 @@ def get_response_simulating_django(request): if hasattr(middleware, "process_exception"): exception = ValueError("View error") middleware.process_exception(request, exception) - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) else: self.fail( "process_exception missing - view exceptions will not be captured!" @@ -455,6 +472,12 @@ def get_response_simulating_django(request): class TestPosthogContextMiddlewareAsync(unittest.TestCase): """Test asynchronous middleware behavior""" + def setUp(self): + # Header propagation tests explicitly opt in to analytics attribution. + configuration = override_settings(POSTHOG_MW_TRUST_TRACING_HEADERS=True) + configuration.enable() + self.addCleanup(configuration.disable) + def test_async_middleware_detection(self): """Test that async get_response is correctly detected""" diff --git a/posthog/test/integrations/test_middleware_api.py b/posthog/test/integrations/test_middleware_api.py new file mode 100644 index 000000000..23bb43ede --- /dev/null +++ b/posthog/test/integrations/test_middleware_api.py @@ -0,0 +1,216 @@ +"""Public configuration contract for Django request enrichment.""" + +import asyncio +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings +from django.test import override_settings + +from posthog import contexts +from posthog.client import Client +from posthog.integrations.django import PosthogContextMiddleware +from posthog.test.integrations.test_middleware import MockRequest + + +@pytest.fixture +def client(): + instance = Client("test-key", send=False, enable_exception_autocapture=False) + yield instance + instance.shutdown() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("configured", [None, False, True]) +def test_capture_configuration_applies_to_context_and_exception_hook( + client, asynchronous, enabled, configured +): + client.enable_exception_autocapture = enabled + error = ValueError("view failed") + observed = [] + + def view(request): + scope = contexts._get_current_context() + observed.append(scope.capture_exceptions) + middleware.process_exception(request, error) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CLIENT=client, + POSTHOG_MW_CAPTURE_EXCEPTIONS=configured, + ), + patch.object(client, "capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + result = middleware(MockRequest()) + assert (asyncio.run(result) if asynchronous else result) == "response" + + expected = enabled if configured is None else configured + assert observed == [expected] + assert capture.call_count == int(expected) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_capture_none_inherits_global_configuration(enabled): + with ( + override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None), + patch("posthog.default_client", None), + patch("posthog.enable_exception_autocapture", enabled), + patch("posthog.capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(Mock()) + middleware.process_exception(MockRequest(), ValueError("view failed")) + assert capture.call_count == int(enabled) + + +def test_capture_none_inherits_initialized_global_client(client): + with ( + override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None), + patch("posthog.default_client", client), + patch("posthog.enable_exception_autocapture", True), + patch("posthog.capture_exception") as capture, + ): + # An initialized global client's setting takes precedence over the module flag. + middleware = PosthogContextMiddleware(Mock()) + middleware.process_exception(MockRequest(), ValueError("view failed")) + capture.assert_not_called() + + +def test_omitted_capture_setting_preserves_legacy_default(): + with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=True): + del settings.POSTHOG_MW_CAPTURE_EXCEPTIONS + middleware = PosthogContextMiddleware(Mock()) + assert middleware.capture_exceptions is True + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("trust", [False, True]) +def test_tracing_headers_require_opt_in_and_user_fallback(asynchronous, trust): + observed = {} + + def view(request): + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["session_id"] = contexts.get_context_session_id() + return "response" + + async def async_view(request): + return view(request) + + request = MockRequest( + headers={ + "X-POSTHOG-DISTINCT-ID": "browser-user", + "X-POSTHOG-SESSION-ID": "browser-session", + } + ) + request.user = Mock(is_authenticated=True, pk=42, email="user@example.com") + + async def auser(): + return request.user + + request.auser = auser + with override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, + POSTHOG_MW_TRUST_TRACING_HEADERS=trust, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + with contexts.new_context(fresh=True, capture_exceptions=False): + result = middleware(request) + assert (asyncio.run(result) if asynchronous else result) == "response" + + assert observed == { + "distinct_id": "browser-user" if trust else "42", + "session_id": "browser-session" if trust else None, + } + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_context_only_enriches_events_without_redirecting_the_client( + client, asynchronous +): + integration_client = Mock(spec=Client) + captured = [] + + def view(request): + client.capture("custom-event", properties={"tenant": "explicit"}) + middleware.process_exception(request, ValueError("view failed")) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, + POSTHOG_MW_EXTRA_PROPERTIES=lambda request: { + "tenant": "context", + "role": "admin", + }, + ), + patch.object( + client, + "before_send", + new=lambda message: captured.append(message), + ), + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + middleware.client = integration_client + result = middleware(MockRequest(path="/checkout")) + assert (asyncio.run(result) if asynchronous else result) == "response" + + assert captured[0]["event"] == "custom-event" + assert captured[0]["properties"]["$request_path"] == "/checkout" + assert captured[0]["properties"]["tenant"] == "explicit" + assert captured[0]["properties"]["role"] == "admin" + integration_client.capture.assert_not_called() + integration_client.capture_exception.assert_not_called() + assert contexts.get_tags() == {} + + +def test_property_settings_take_precedence_over_legacy_aliases(): + with override_settings( + POSTHOG_MW_EXTRA_PROPERTIES=lambda request: {"new": True}, + POSTHOG_MW_EXTRA_TAGS=lambda request: {"old": True}, + POSTHOG_MW_PROPERTIES_MAP=lambda properties: {**properties, "mapped": "new"}, + POSTHOG_MW_TAG_MAP=lambda properties: {**properties, "mapped": "old"}, + ): + middleware = PosthogContextMiddleware(Mock()) + properties = middleware.extract_properties(MockRequest()) + assert properties["new"] is True + assert "old" not in properties + assert properties["mapped"] == "new" + assert middleware.extra_tags is middleware.extra_properties + assert middleware.tag_map is middleware.properties_map + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_request_filter_disables_context_and_capture(client, asynchronous): + properties = Mock() + error = ValueError("view failed") + + def view(request): + assert contexts.get_tags() == {} + middleware.process_exception(request, error) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CLIENT=client, + POSTHOG_MW_CAPTURE_EXCEPTIONS=True, + POSTHOG_MW_REQUEST_FILTER=lambda request: False, + POSTHOG_MW_EXTRA_PROPERTIES=properties, + ), + patch.object(client, "capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + result = middleware(MockRequest()) + assert (asyncio.run(result) if asynchronous else result) == "response" + capture.assert_not_called() + properties.assert_not_called() diff --git a/posthog/test/mcp/_helpers.py b/posthog/test/mcp/_helpers.py index 836ef8ad6..cd4227c8e 100644 --- a/posthog/test/mcp/_helpers.py +++ b/posthog/test/mcp/_helpers.py @@ -29,7 +29,12 @@ def capture( **kwargs, ): self.events.append( - {"event": event, "distinct_id": distinct_id, "properties": properties or {}} + { + "event": event, + "distinct_id": distinct_id, + "properties": properties or {}, + "options": kwargs.get("options") or {}, + } ) return None diff --git a/posthog/test/mcp/test_fastmcp.py b/posthog/test/mcp/test_fastmcp.py index df725009e..f3e904982 100644 --- a/posthog/test/mcp/test_fastmcp.py +++ b/posthog/test/mcp/test_fastmcp.py @@ -255,7 +255,7 @@ async def test_identify_sets_distinct_id_and_groups(): calls = _events(client, "$mcp_tool_call") assert calls[0]["distinct_id"] == "user_42" assert calls[0]["properties"]["$groups"] == {"organization": "org_7"} - assert "$process_person_profile" not in calls[0]["properties"] + assert calls[0]["options"] == {} # an $identify event was emitted assert _events(client, "$identify") diff --git a/posthog/test/mcp/test_fastmcp_v4.py b/posthog/test/mcp/test_fastmcp_v4.py index ac29c3a9f..1d5afe45c 100644 --- a/posthog/test/mcp/test_fastmcp_v4.py +++ b/posthog/test/mcp/test_fastmcp_v4.py @@ -152,7 +152,6 @@ def echo(context: str, conversation_id: str, llm_model: str) -> str: tool = Tool.from_function(echo) if source == "middleware": child.add_middleware(ToolInjectionMiddleware(tools=[tool])) - assert await child.get_tool("echo") is None else: child.add_tool(tool) server = FastMCP("example-app") if source == "mounted" else child @@ -225,7 +224,6 @@ def echo(text: str) -> str: server = FastMCP("example-middleware-tools") server.add_middleware(ToolInjectionMiddleware(tools=[Tool.from_function(echo)])) - assert await server.get_tool("echo") is None sink = FakeClient() instrument( server, diff --git a/posthog/test/mcp/test_features_m4.py b/posthog/test/mcp/test_features_m4.py index f1f7bc37f..561162cad 100644 --- a/posthog/test/mcp/test_features_m4.py +++ b/posthog/test/mcp/test_features_m4.py @@ -193,7 +193,7 @@ async def test_fastmcp_keeps_input_names_after_conversation_anchoring(): assert properties["$mcp_input_keys"] == ["a", "b"] -async def test_lowlevel_does_not_reuse_a_listed_schema_for_input_names(): +async def test_lowlevel_reuses_a_listed_schema_for_input_names(): server = make_lowlevel() client = FakeClient() instrument(server, client) @@ -205,7 +205,7 @@ async def test_lowlevel_does_not_reuse_a_listed_schema_for_input_names(): await _flush() properties = _events(client, "$mcp_tool_call")[0]["properties"] - assert properties["$mcp_input_keys"] == ["[redacted]"] + assert properties["$mcp_input_keys"] == ["msg"] async def test_lowlevel_conversation_id_captured_and_prompt_back(): diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index ac84541c8..b51a5a10a 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -386,3 +386,259 @@ async def test_initialize_emitted_once(): assert len(_events(client, "$mcp_initialize")) == 1 assert len(_events(client, "$mcp_tool_call")) == 2 + + +def _make_strict_ownership_server(input_schema): + server = Server("strict-lowlevel") + seen = [] + + @server.list_tools() + async def list_tools(): + return [ + mcp_types.Tool( + name="search_docs", + inputSchema=input_schema, + ) + ] + + @server.call_tool() + async def call_tool(name, arguments): + seen.append(dict(arguments or {})) + allowed = set(input_schema.get("properties", {})) + unexpected = set(arguments or {}) - allowed + if unexpected: + raise ValueError(f"Unexpected arguments: {sorted(unexpected)}") + return [mcp_types.TextContent(type="text", text="ok")] + + return server, seen + + +async def test_listing_does_not_mutate_tool_used_by_fresh_resolver(): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + tool = mcp_types.Tool(name="search_docs", inputSchema=schema) + + def make_server(): + server = Server("reused-tool") + seen = [] + + @server.list_tools() + async def list_tools(): + return [tool] + + @server.call_tool() + async def call_tool(name, arguments): + seen.append(dict(arguments or {})) + return [mcp_types.TextContent(type="text", text="ok")] + + return server, seen + + first, _ = make_server() + instrument(first, FakeClient(), MCPAnalyticsOptions(enable_conversation_id=False)) + await first.request_handlers[mcp_types.ListToolsRequest]( + mcp_types.ListToolsRequest(method="tools/list") + ) + assert tool.inputSchema == schema + + second, seen = make_server() + instrument( + second, + FakeClient(), + MCPAnalyticsOptions( + enable_conversation_id=False, + resolve_original_tool=lambda _name: tool, + ), + ) + await second.request_handlers[mcp_types.CallToolRequest]( + _call_request( + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + ) + + assert seen == [{"query": "flags"}] + + +async def test_lowlevel_keeps_top_level_reference_schema_unchanged(): + schema = { + "$ref": "#/$defs/Input", + "$defs": { + "Input": { + "type": "object", + "properties": { + "context": {"type": "string"}, + "conversation_id": {"type": "string"}, + "llm_model": {"type": "string"}, + }, + } + }, + } + server, _ = _make_strict_ownership_server(schema) + instrument(server, FakeClient()) + + result = await server.request_handlers[mcp_types.ListToolsRequest]( + mcp_types.ListToolsRequest(method="tools/list") + ) + + assert result.root.tools[0].inputSchema == schema + + +async def test_fresh_lowlevel_resolver_strips_posthog_arguments(): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server(schema) + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions( + enable_conversation_id=False, + resolve_original_tool=lambda _name: {"inputSchema": schema}, + ), + ) + + result = await server.request_handlers[mcp_types.CallToolRequest]( + _call_request( + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + ) + await _flush() + + assert result.root.isError is False + assert seen == [{"query": "flags"}] + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert props["$mcp_intent"] == "find docs" + assert props["$mcp_llm_model"] == "model-a" + assert props["$mcp_input_keys"] == ["query"] + + +async def test_fresh_lowlevel_resolver_preserves_tool_owned_context(): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "context": {"type": "string"}, + }, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server(schema) + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions( + capture_model=False, + enable_conversation_id=False, + resolve_original_tool=lambda _name: {"input_schema": schema}, + ), + ) + + await server.request_handlers[mcp_types.CallToolRequest]( + _call_request("search_docs", {"query": "flags", "context": "tool context"}) + ) + await _flush() + + assert seen == [{"query": "flags", "context": "tool context"}] + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert "$mcp_intent" not in props + captured = props["$mcp_parameters"]["request"]["params"]["arguments"] + assert captured["context"] == "tool context" + + +@pytest.mark.parametrize( + "failure", ["none", "raise", "missing_schema", "schema_property_raises"] +) +async def test_fresh_lowlevel_resolver_failure_keeps_arguments(failure): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server(schema) + client = FakeClient() + messages = [] + + class BrokenDescriptor: + @property + def inputSchema(self): + raise RuntimeError("schema unavailable") + + def resolver(_name): + if failure == "raise": + raise RuntimeError("registry unavailable") + if failure == "missing_schema": + return {"name": "search_docs"} + if failure == "schema_property_raises": + return BrokenDescriptor() + return None + + instrument( + server, + client, + MCPAnalyticsOptions( + enable_conversation_id=False, + logger=messages.append, + resolve_original_tool=resolver, + ), + ) + + await server.request_handlers[mcp_types.CallToolRequest]( + _call_request( + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + ) + + assert seen == [{"query": "flags", "context": "find docs", "llm_model": "model-a"}] + warnings = [ + message for message in messages if "resolve_original_tool failed" in message + ] + assert len(warnings) == int(failure != "none") + + +async def test_lowlevel_served_listing_has_priority_over_resolver(): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "context": {"type": "string"}, + }, + } + resolver_calls = [] + server, seen = _make_strict_ownership_server(schema) + + def resolver(name): + resolver_calls.append(name) + return { + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + } + } + + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions( + capture_model=False, + enable_conversation_id=False, + resolve_original_tool=resolver, + ), + ) + await server.request_handlers[mcp_types.ListToolsRequest]( + mcp_types.ListToolsRequest(method="tools/list") + ) + await server.request_handlers[mcp_types.CallToolRequest]( + _call_request("search_docs", {"query": "flags", "context": "tool context"}) + ) + + assert resolver_calls == [] + assert seen == [{"query": "flags", "context": "tool context"}] diff --git a/posthog/test/mcp/test_no_crash.py b/posthog/test/mcp/test_no_crash.py index ffe0f4a3d..72713c841 100644 --- a/posthog/test/mcp/test_no_crash.py +++ b/posthog/test/mcp/test_no_crash.py @@ -61,7 +61,7 @@ def test_low_level_server_detected_on_installed_major(): assert compat.is_low_level_server(object()) is False -def test_instrument_relabels_the_host_client(): +def test_instrument_keeps_the_host_client_identity(): from mcp.server.lowlevel import Server captured = [] @@ -74,7 +74,7 @@ def before_send(event): instrument(Server("probe-lib-identity"), client) client.capture("after instrumentation") - assert captured[0]["properties"]["$lib"] == "posthog-python-mcp" + assert captured[0]["properties"]["$lib"] == "posthog-python" assert captured[0]["properties"]["$lib_version"] == VERSION diff --git a/posthog/test/mcp/test_pipeline.py b/posthog/test/mcp/test_pipeline.py index 81b6db644..7ec183ef9 100644 --- a/posthog/test/mcp/test_pipeline.py +++ b/posthog/test/mcp/test_pipeline.py @@ -19,7 +19,10 @@ sanitize_captured_value, sanitize_event, ) -from posthog.mcp._sink import McpCaptureOptions, process_mcp_event +from posthog.capture_event import _to_v1_event +from posthog.client import Client +from posthog.mcp._sink import McpCaptureOptions, McpEventSink, process_mcp_event +from posthog.test.capture_helpers import patch_capture_send, sent_events from posthog.mcp._truncation import MAX_EVENT_BYTES, normalize, truncate_event # --- ids --------------------------------------------------------------------- @@ -885,6 +888,7 @@ def test_build_tool_call_event_properties(): props = capture["properties"] assert capture["event"] == PostHogMCPAnalyticsEvent.TOOL_CALL assert capture["distinct_id"] == "ses_abc" + assert props[PostHogMCPAnalyticsProperty.INTERFACE] == "mcp" assert props[PostHogMCPAnalyticsProperty.SOURCE] == POSTHOG_MCP_ANALYTICS_SOURCE assert props[PostHogMCPAnalyticsProperty.TOOL_NAME] == "search_events" assert props[PostHogMCPAnalyticsProperty.TOOL_CATEGORY] == "Logs" @@ -895,7 +899,8 @@ def test_build_tool_call_event_properties(): assert props[PostHogMCPAnalyticsProperty.LLM_MODEL_SOURCE] == "client_metadata" assert props[PostHogMCPAnalyticsProperty.SESSION_ID] == "ses_abc" # anonymous (no identity) => person processing disabled - assert props["$process_person_profile"] is False + assert capture["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props def test_protocol_version_on_primary_and_exception_events(): @@ -912,6 +917,7 @@ def test_protocol_version_on_primary_and_exception_events(): # Both the primary $mcp_tool_call and the $exception sibling carry it. assert len(captures) == 2 for capture in captures: + assert capture["properties"][PostHogMCPAnalyticsProperty.INTERFACE] == "mcp" assert ( capture["properties"][PostHogMCPAnalyticsProperty.PROTOCOL_VERSION] == "2025-06-18" @@ -925,14 +931,37 @@ def test_identity_enables_person_processing_and_set(): "identify_actor_given_id": "user_1", "identify_actor_data": {"email": "a@b.com"}, "groups": {"organization": "org_1"}, + "properties": {"$groups": {"organization": "custom", "project": "p1"}}, "timestamp": datetime.now(timezone.utc), } [capture] = build_posthog_capture_events(event) props = capture["properties"] assert capture["distinct_id"] == "user_1" - assert "$process_person_profile" not in props + assert capture["options"] == {} assert props["$set"] == {"email": "a@b.com"} - assert props["$groups"] == {"organization": "org_1"} + assert props["$groups"] == {"organization": "org_1", "project": "p1"} + + +def test_typed_groups_win_on_primary_and_exception_events(): + event = { + "event_type": MCPAnalyticsEventType.MCP_TOOLS_CALL, + "session_id": "ses_abc", + "is_error": True, + "error": {"$exception_list": [{"type": "ValueError", "value": "boom"}]}, + "groups": {"organization": "org_1"}, + "properties": {"$groups": {"organization": "custom", "project": "p1"}}, + "timestamp": datetime.now(timezone.utc), + } + captures = build_posthog_capture_events(event) + assert [c["event"] for c in captures] == [ + PostHogMCPAnalyticsEvent.TOOL_CALL, + PostHogMCPAnalyticsEvent.EXCEPTION, + ] + for capture in captures: + assert capture["properties"]["$groups"] == { + "organization": "org_1", + "project": "p1", + } def test_listed_tool_names_only_on_tools_list(): @@ -1108,3 +1137,32 @@ async def before_send(e): result = await process_mcp_event(event, McpCaptureOptions(before_send=before_send)) _, captures = result assert captures[0]["properties"]["added"] is True + + +def _opt_in(e): + return {**e, "options": {"process_person_profile": True}} + + +@pytest.mark.parametrize( + "before_send, expected", + [(None, False), (_opt_in, True)], + ids=["sdk_option_wins", "before_send_can_change_it"], +) +async def test_anonymous_event_sends_personless_option(before_send, expected): + event = { + "event_type": MCPAnalyticsEventType.MCP_TOOLS_CALL, + "session_id": "ses_abc", + "properties": {"$process_person_profile": True}, + "timestamp": datetime.now(timezone.utc), + } + with patch_capture_send("client") as send: + client = Client( + "test-key", + sync_mode=True, + super_options={"process_person_profile": True}, + ) + await McpEventSink(client).capture( + event, McpCaptureOptions(before_send=before_send) + ) + [wire] = [_to_v1_event(msg) for msg in sent_events(send)] + assert wire["options"] == {"process_person_profile": expected} diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 6ed662e81..8f1df2d48 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -9,7 +9,6 @@ import pytest from mcp.types import CallToolResult, ServerResult, TextContent, Tool -from posthog.capture_mode import CaptureMode from posthog.mcp import ( PostHogMCP, PreparedToolCall, @@ -110,98 +109,12 @@ def before_send(event): assert captured[2]["properties"]["$mcp_server_build"] == "default-build" -async def test_mcp_events_use_mcp_library_identity(): - captured = [] - - def before_send(event): - captured.append(event) - return event - - client = PostHogMCP( - "phc_test", - host="https://us.i.posthog.com", - send=False, - before_send=before_send, - ) - client.capture_tool_call("broken", is_error=True, error=RuntimeError("kaboom")) - await _flush() - - assert {event["event"] for event in captured} == {"$mcp_tool_call", "$exception"} - assert all( - event["properties"]["$lib"] == "posthog-python-mcp" - and event["properties"]["$lib_version"] == VERSION - for event in captured - ) - - -def test_mcp_library_identity_reaches_capture_v0_header(): - response = mock.Mock(status_code=200) +def test_mcp_client_keeps_the_sdk_identity(): client = PostHogMCP("phc_test", sync_mode=True) - - with mock.patch("posthog.request._session.post", return_value=response) as post: - client.capture("$mcp_custom") - - assert post.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - - -def test_mcp_library_identity_reaches_capture_v1_header(): - client = PostHogMCP("phc_test", sync_mode=True, capture_mode=CaptureMode.V1) with mock.patch("posthog.client._send_v1_batch") as send: client.capture("$mcp_custom") - assert send.call_args.kwargs["sdk_info"] == f"posthog-python-mcp/{VERSION}" - event = send.call_args.args[2][0] - assert event["properties"]["$lib"] == "posthog-python-mcp" - assert event["properties"]["$lib_version"] == VERSION - - -def test_mcp_library_identity_reaches_feature_flag_requests(): - response = mock.Mock(status_code=200) - response.json.return_value = {"flags": {}} - client = PostHogMCP("phc_test", send=False) - - with mock.patch( - "posthog.request._flags_session.post", return_value=response - ) as post: - client.evaluate_flags("user_1") - - assert post.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - - -def test_mcp_library_identity_reaches_feature_flag_definition_requests(): - response = mock.Mock(status_code=200, headers={}) - response.json.return_value = {"flags": [], "group_type_mapping": {}, "cohorts": {}} - client = PostHogMCP( - "phc_test", - secret_key="phs_test", - send=False, - enable_local_evaluation=False, - ) - - with mock.patch("posthog.request._session.get", return_value=response) as get: - client.load_feature_flags() - - assert get.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - client.shutdown() - - -def test_mcp_library_identity_reaches_remote_config_requests(): - response = mock.Mock(status_code=200, headers={}) - response.json.return_value = "payload" - client = PostHogMCP("phc_test", secret_key="phs_test", send=False) - - with mock.patch("posthog.request._session.get", return_value=response) as get: - assert client.get_remote_config_payload("flag-key") == "payload" - - assert get.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) + assert send.call_args.kwargs["sdk_info"] == f"posthog-python/{VERSION}" async def test_capture_initialize_and_tools_list(): diff --git a/posthog/test/mcp/test_types.py b/posthog/test/mcp/test_types.py index e8a3f5424..98e437aac 100644 --- a/posthog/test/mcp/test_types.py +++ b/posthog/test/mcp/test_types.py @@ -1,8 +1,23 @@ from unittest.mock import Mock +from types import SimpleNamespace import pytest from posthog.mcp.types import MCPAnalyticsOptions, PreparedToolCall, UserIdentity +from posthog.mcp._argument_ownership import _descriptor_input_schema + + +@pytest.mark.parametrize( + "descriptor", + [ + {"inputSchema": {"type": "object"}}, + {"input_schema": {"type": "object"}}, + SimpleNamespace(inputSchema={"type": "object"}), + SimpleNamespace(input_schema={"type": "object"}), + ], +) +def test_original_tool_descriptor_schema_shapes(descriptor): + assert _descriptor_input_schema(descriptor) == {"type": "object"} @pytest.mark.parametrize("capture_model", [False, True]) diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index d923be04d..905292b1d 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -761,3 +761,269 @@ async def on_list_tools(ctx, params): assert result.content[0].text == get_more_tools_result_text() assert _events(client, "$mcp_missing_capability") assert not [m for m in messages if "Cannot inject PostHog's" in m] + + +def _make_strict_ownership_server_v2(input_schema): + seen = [] + + async def on_call_tool(ctx, params): + arguments = dict(params.arguments or {}) + seen.append(arguments) + allowed = set(input_schema.get("properties", {})) + unexpected = set(arguments) - allowed + if unexpected: + raise ValueError(f"Unexpected arguments: {sorted(unexpected)}") + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text="ok")] + ) + + async def on_list_tools(ctx, params): + return mcp_types.ListToolsResult( + tools=[ + mcp_types.Tool( + name="search_docs", + input_schema=input_schema, + ) + ] + ) + + return ( + Server( + "strict-lowlevel-v2", + on_call_tool=on_call_tool, + on_list_tools=on_list_tools, + ), + seen, + ) + + +async def test_v2_fresh_lowlevel_resolver_strips_posthog_arguments(): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server_v2(schema) + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions( + enable_conversation_id=False, + resolve_original_tool=lambda _name: {"input_schema": schema}, + ), + ) + + result = await _call_tool( + server, + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + await _flush() + + assert result.is_error is False + assert seen == [{"query": "flags"}] + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert props["$mcp_intent"] == "find docs" + assert props["$mcp_llm_model"] == "model-a" + assert props["$mcp_input_keys"] == ["query"] + + +async def test_v2_listing_does_not_mutate_tool_used_by_fresh_resolver(): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + tool = mcp_types.Tool(name="search_docs", input_schema=schema) + + def make_server(): + seen = [] + + async def on_call_tool(ctx, params): + seen.append(dict(params.arguments or {})) + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text="ok")] + ) + + async def on_list_tools(ctx, params): + return mcp_types.ListToolsResult(tools=[tool]) + + return ( + Server( + "reused-tool-v2", + on_call_tool=on_call_tool, + on_list_tools=on_list_tools, + ), + seen, + ) + + first, _ = make_server() + instrument(first, FakeClient(), MCPAnalyticsOptions(enable_conversation_id=False)) + await _list_tools(first) + assert tool.input_schema == schema + + second, seen = make_server() + instrument( + second, + FakeClient(), + MCPAnalyticsOptions( + enable_conversation_id=False, + resolve_original_tool=lambda _name: tool, + ), + ) + await _call_tool( + second, + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + + assert seen == [{"query": "flags"}] + + +async def test_v2_lowlevel_keeps_top_level_reference_schema_unchanged(): + schema = { + "$ref": "#/$defs/Input", + "$defs": { + "Input": { + "type": "object", + "properties": { + "context": {"type": "string"}, + "conversation_id": {"type": "string"}, + "llm_model": {"type": "string"}, + }, + } + }, + } + server, _ = _make_strict_ownership_server_v2(schema) + instrument(server, FakeClient()) + + result = await _list_tools(server) + + assert result.tools[0].input_schema == schema + + +async def test_v2_fresh_lowlevel_resolver_preserves_tool_owned_context(): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "context": {"type": "string"}, + }, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server_v2(schema) + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions( + capture_model=False, + enable_conversation_id=False, + resolve_original_tool=lambda _name: {"inputSchema": schema}, + ), + ) + + await _call_tool( + server, + "search_docs", + {"query": "flags", "context": "tool context"}, + ) + await _flush() + + assert seen == [{"query": "flags", "context": "tool context"}] + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert "$mcp_intent" not in props + captured = props["$mcp_parameters"]["request"]["params"]["arguments"] + assert captured["context"] == "tool context" + + +@pytest.mark.parametrize( + "failure", ["none", "raise", "missing_schema", "schema_property_raises"] +) +async def test_v2_fresh_lowlevel_resolver_failure_keeps_arguments(failure): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "additionalProperties": False, + } + server, seen = _make_strict_ownership_server_v2(schema) + client = FakeClient() + messages = [] + + class BrokenDescriptor: + @property + def inputSchema(self): + raise RuntimeError("schema unavailable") + + def resolver(_name): + if failure == "raise": + raise RuntimeError("registry unavailable") + if failure == "missing_schema": + return {"name": "search_docs"} + if failure == "schema_property_raises": + return BrokenDescriptor() + return None + + instrument( + server, + client, + MCPAnalyticsOptions( + enable_conversation_id=False, + logger=messages.append, + resolve_original_tool=resolver, + ), + ) + + with pytest.raises(ValueError, match="Unexpected arguments"): + await _call_tool( + server, + "search_docs", + {"query": "flags", "context": "find docs", "llm_model": "model-a"}, + ) + + assert seen == [{"query": "flags", "context": "find docs", "llm_model": "model-a"}] + warnings = [ + message for message in messages if "resolve_original_tool failed" in message + ] + assert len(warnings) == int(failure != "none") + + +async def test_v2_lowlevel_served_listing_has_priority_over_resolver(): + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "context": {"type": "string"}, + }, + } + resolver_calls = [] + server, seen = _make_strict_ownership_server_v2(schema) + + def resolver(name): + resolver_calls.append(name) + return { + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + } + } + + instrument( + server, + FakeClient(), + MCPAnalyticsOptions( + capture_model=False, + enable_conversation_id=False, + resolve_original_tool=resolver, + ), + ) + await _list_tools(server) + await _call_tool( + server, + "search_docs", + {"query": "flags", "context": "tool context"}, + ) + + assert resolver_calls == [] + assert seen == [{"query": "flags", "context": "tool context"}] diff --git a/posthog/test/mcp/test_v2_mcpserver.py b/posthog/test/mcp/test_v2_mcpserver.py index c17e6de15..7fbf0919d 100644 --- a/posthog/test/mcp/test_v2_mcpserver.py +++ b/posthog/test/mcp/test_v2_mcpserver.py @@ -544,7 +544,8 @@ async def test_anonymous_events_do_not_create_person_profiles(): await _flush() calls = _events(client, "$mcp_tool_call") - assert calls[0]["properties"]["$process_person_profile"] is False + assert calls[0]["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in calls[0]["properties"] async def test_a_failed_registry_lookup_delegates_instead_of_swallowing(): diff --git a/posthog/test/mcp/test_virtual_tools.py b/posthog/test/mcp/test_virtual_tools.py index 9c5bea29b..c02ecc7bf 100644 --- a/posthog/test/mcp/test_virtual_tools.py +++ b/posthog/test/mcp/test_virtual_tools.py @@ -290,10 +290,9 @@ async def test_renamed_tool_is_intercepted_and_the_default_name_is_not(): assert len(_events(client, "$mcp_missing_capability")) == 1 -async def test_real_tool_named_get_more_tools_keeps_normal_injection(): - # With report_missing off the SDK advertises no such tool, so one by that - # name is an ordinary application tool: it gets `context` injected and its - # value captured as $mcp_intent, like any other tool's. +async def test_real_tool_named_get_more_tools_keeps_tool_owned_context(): + # With report_missing off the SDK advertises no such virtual tool. A real + # tool with this name keeps its declared `context` argument as tool data. server = make_paged_lowlevel([[_REAL_GET_MORE_TOOLS]]) client = FakeClient() instrument(server, client, MCPAnalyticsOptions(report_missing=False, context=True)) @@ -305,7 +304,11 @@ async def test_real_tool_named_get_more_tools_keeps_normal_injection(): assert out.root.content[0].text == "real tool ran" calls = _events(client, "$mcp_tool_call") assert calls - assert calls[0]["properties"]["$mcp_intent"] == "delete a cohort" + properties = calls[0]["properties"] + assert "$mcp_intent" not in properties + assert properties["$mcp_parameters"]["request"]["params"]["arguments"] == { + "context": "delete a cohort" + } # --- a host that reuses one result object -------------------------------------- diff --git a/posthog/test/snapshots/legacy_event_family.json b/posthog/test/snapshots/event_family.json similarity index 77% rename from posthog/test/snapshots/legacy_event_family.json rename to posthog/test/snapshots/event_family.json index 4e33c6bd8..0d72f4cf5 100644 --- a/posthog/test/snapshots/legacy_event_family.json +++ b/posthog/test/snapshots/event_family.json @@ -1,18 +1,16 @@ { "body": { - "api_key": "phc_snapshot_project", "batch": [ { "distinct_id": "user-123", "event": "order completed", + "options": {}, "properties": { "$geoip_disable": true, "$groups": { "company": "company-456" }, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "$os": "", "$os_distro": "", "$os_version": "", @@ -34,17 +32,16 @@ "uuid": "00000000-0000-4000-8000-000000000001" }, { - "$set": { - "email": "person@example.com", - "plan": "pro" - }, "distinct_id": "user-123", "event": "$set", + "options": {}, "properties": { "$geoip_disable": true, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "" + "$set": { + "email": "person@example.com", + "plan": "pro" + } }, "timestamp": "2026-01-02T03:04:05+00:00", "uuid": "00000000-0000-4000-8000-000000000002" @@ -52,11 +49,10 @@ { "distinct_id": "anonymous-789", "event": "$create_alias", + "options": {}, "properties": { "$geoip_disable": true, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "alias": "user-123", "distinct_id": "anonymous-789" }, @@ -66,6 +62,7 @@ { "distinct_id": "user-123", "event": "$groupidentify", + "options": {}, "properties": { "$geoip_disable": true, "$group_key": "company-456", @@ -74,21 +71,23 @@ "name": "Example Corp" }, "$group_type": "company", - "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "" + "$is_server": true }, "timestamp": "2026-01-02T03:04:05+00:00", "uuid": "00000000-0000-4000-8000-000000000004" } ], - "historical_migration": false, - "sent_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05+00:00" }, "headers": { + "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", + "PostHog-Attempt": "1", + "PostHog-Request-Id": "", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, "timeout": 15, - "url": "https://example.posthog.test/batch/" + "url": "https://example.posthog.test/i/v1/analytics/events" } diff --git a/posthog/test/snapshots/exception_event.json b/posthog/test/snapshots/exception_event.json index b971c7afe..f4ed6660b 100644 --- a/posthog/test/snapshots/exception_event.json +++ b/posthog/test/snapshots/exception_event.json @@ -1,15 +1,18 @@ { "body": { - "api_key": "phc_snapshot_project", "batch": [ { "distinct_id": "user-123", "event": "$exception", + "options": {}, "properties": { + "$exception_level": "error", "$exception_list": [ { "mechanism": { + "exception_id": 0, "handled": true, + "synthetic": false, "type": "generic" }, "module": null, @@ -53,7 +56,7 @@ "", "def _exception_request():", " session = mock.MagicMock()", - " session.post.return_value = _successful_response()" + " session.post.side_effect = _capture_ok_response" ], "pre_context": [ "", @@ -71,8 +74,11 @@ }, { "mechanism": { - "handled": true, - "type": "generic" + "exception_id": 1, + "parent_id": 0, + "source": "cause", + "synthetic": false, + "type": "chained" }, "module": null, "stacktrace": { @@ -113,8 +119,6 @@ "company": "company-456" }, "$is_server": true, - "$lib": "posthog-python", - "$lib_version": "", "$os": "", "$os_distro": "", "$os_version": "", @@ -127,13 +131,17 @@ "uuid": "00000000-0000-4000-8000-000000000005" } ], - "historical_migration": false, - "sent_at": "2026-01-02T03:04:05+00:00" + "created_at": "2026-01-02T03:04:05+00:00" }, "headers": { + "Authorization": "Bearer phc_snapshot_project", "Content-Type": "application/json", + "PostHog-Attempt": "1", + "PostHog-Request-Id": "", + "PostHog-Request-Timestamp": "2026-01-02T03:04:05+00:00", + "PostHog-Sdk-Info": "posthog-python/", "User-Agent": "posthog-python/" }, "timeout": 15, - "url": "https://example.posthog.test/batch/" + "url": "https://example.posthog.test/i/v1/analytics/events" } diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index 3ab2f9f87..dd1843803 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -1,23 +1,34 @@ +import os import threading import unittest import uuid from unittest import mock +import pytest +from parameterized import parameterized + import posthog -from posthog.ai.utils import _capture_ai_event, finalize_ai_content, with_privacy_mode -from posthog.capture_mode import CaptureMode +from posthog.ai.utils import ( + _capture_ai_event, + _resolve_ai_client, + finalize_ai_content, + with_privacy_mode, +) +from posthog.async_client import AsyncPosthog +from posthog.capture_compression import CAPTURE_COMPRESSION_ENV_VAR, CaptureCompression from posthog.client import Client -from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE -from posthog.request import AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT +from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.version import VERSION +from posthog.test.capture_helpers import patch_capture_send, sent_batch from posthog.test.test_utils import TEST_API_KEY -def _events_by_path(mock_post): +def _events_by_path(mock_send): by_path: dict[str, list] = {} - for call in mock_post.call_args_list: - by_path.setdefault(call.kwargs["path"], []).extend(call.kwargs["batch"]) + for index, call in enumerate(mock_send.call_args_list): + by_path.setdefault(call.kwargs["path"], []).extend(sent_batch(mock_send, index)) return by_path @@ -29,24 +40,24 @@ def _client(self, **kwargs): def test_capture_ai_and_capture_ride_separate_lanes(self): client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("button_clicked", distinct_id="d") client.capture_ai("$ai_generation", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {EVENTS_ENDPOINT, AI_EVENTS_ENDPOINT}) + self.assertEqual(set(by_path), {_CAPTURE_V1_PATH, _CAPTURE_AI_V1_PATH}) self.assertEqual( - [e["event"] for e in by_path[EVENTS_ENDPOINT]], ["button_clicked"] + [e["event"] for e in by_path[_CAPTURE_V1_PATH]], ["button_clicked"] ) self.assertEqual( - [e["event"] for e in by_path[AI_EVENTS_ENDPOINT]], ["$ai_generation"] + [e["event"] for e in by_path[_CAPTURE_AI_V1_PATH]], ["$ai_generation"] ) - for call in mock_post.call_args_list: - events = {e["event"] for e in call.kwargs["batch"]} + for index, call in enumerate(mock_post.call_args_list): + events = {e["event"] for e in sent_batch(mock_post, index)} expected = ( {"$ai_generation"} - if call.kwargs["path"] == AI_EVENTS_ENDPOINT + if call.kwargs["path"] == _CAPTURE_AI_V1_PATH else {"button_clicked"} ) self.assertEqual(events, expected) @@ -55,13 +66,13 @@ def test_capture_does_not_reroute_ai_named_events(self): # The two-lane rule: `capture()` never special-cases AI events, no # matter their name. Only `capture_ai()` reaches the AI lane. client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("$ai_generation", distinct_id="d") client.flush() self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [EVENTS_ENDPOINT], + [_CAPTURE_V1_PATH], ) def test_capture_ai_returns_event_uuid_like_capture(self): @@ -71,22 +82,14 @@ def test_capture_ai_returns_event_uuid_like_capture(self): def test_sync_mode_capture_ai_posts_single_event_batch_to_ai_endpoint(self): client = Client(TEST_API_KEY, sync_mode=True) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client.capture_ai("$ai_generation", distinct_id="d") mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(mock_post.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + batch = sent_batch(mock_post) self.assertEqual([e["event"] for e in batch], ["$ai_generation"]) - def test_multimodal_client_routes_wrapper_captures_to_ai_lane(self): - client = self._client(enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.flush() - by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {AI_EVENTS_ENDPOINT}) - def test_disabled_client_never_starts_ai_lane(self): client = Client(TEST_API_KEY, disabled=True) client.capture_ai("$ai_generation", distinct_id="d") @@ -113,7 +116,6 @@ def test_analytics_consumers_keep_todays_parameters(self): thread=2, flush_at=7, flush_interval=0.5, - gzip=True, max_retries=4, timeout=9, historical_migration=True, @@ -122,29 +124,27 @@ def test_analytics_consumers_keep_todays_parameters(self): self.assertEqual(len(consumers), 2) for consumer in consumers: self.assertIs(consumer.queue, client.queue) - self.assertEqual(consumer.endpoint, EVENTS_ENDPOINT) + self.assertEqual(consumer.endpoint, _CAPTURE_V1_PATH) self.assertEqual(consumer.max_msg_size, MAX_MSG_SIZE) self.assertEqual(consumer.flush_at, 7) self.assertEqual(consumer.flush_interval, 0.5) - self.assertTrue(consumer.gzip) self.assertEqual(consumer.retries, 4) self.assertEqual(consumer.timeout, 9) self.assertTrue(consumer.historical_migration) - self.assertEqual(consumer.capture_mode, client.capture_mode) self.assertEqual(consumer.capture_compression, client.capture_compression) client.join() def test_analytics_traffic_posts_to_single_endpoint(self): client = Client(TEST_API_KEY, flush_interval=0.05) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("event_a", distinct_id="d") client.capture("event_b", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) - self.assertEqual(set(by_path), {EVENTS_ENDPOINT}) + self.assertEqual(set(by_path), {_CAPTURE_V1_PATH}) self.assertEqual( - sorted(e["event"] for e in by_path[EVENTS_ENDPOINT]), + sorted(e["event"] for e in by_path[_CAPTURE_V1_PATH]), ["event_a", "event_b"], ) client.join() @@ -152,9 +152,9 @@ def test_analytics_traffic_posts_to_single_endpoint(self): def test_sync_mode_analytics_path_unchanged(self): client = Client(TEST_API_KEY, sync_mode=True) self.assertIsNone(client.consumers) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client.capture("button_clicked", distinct_id="d") - self.assertEqual(mock_post.call_args.kwargs["path"], EVENTS_ENDPOINT) + self.assertEqual(mock_post.call_args.kwargs["path"], _CAPTURE_V1_PATH) class TestLaneSizeCaps(unittest.TestCase): @@ -176,64 +176,216 @@ def test_ai_lane_accepts_multi_megabyte_events(self): batch = consumer.next() self.assertEqual([e["event"] for e in batch], ["$ai_generation"]) - def test_ai_lane_drops_events_over_its_cap(self): - client = self._client() + @parameterized.expand( + [ + ("properties_at_endpoint_ceiling", {}, AI_MAX_PROPERTIES_SIZE, True), + ("over_guard", {}, AI_MAX_MSG_SIZE, False), + ( + "over_lowered_cap", + {"capture_ai_max_event_bytes": 1024 * 1024}, + 2 * 1024 * 1024, + False, + ), + ] + ) + def test_ai_lane_size_guard(self, _name, config, payload_bytes, accepted): + client = Client(TEST_API_KEY, send=False, flush_interval=0.05, **config) client._ai_lane.start() consumer = client._ai_lane.consumers[0] - client._ai_lane.queue.put(self._sized_event("$ai_generation", AI_MAX_MSG_SIZE)) - self.assertEqual(consumer.next(), []) + client._ai_lane.queue.put(self._sized_event("$ai_generation", payload_bytes)) + self.assertEqual( + [e["event"] for e in consumer.next()], + ["$ai_generation"] if accepted else [], + ) self.assertTrue(client._ai_lane.queue.empty()) - def test_analytics_lane_rejects_events_over_900kib(self): + def test_sync_mode_ai_event_over_cap_is_not_sent(self): + client = Client( + TEST_API_KEY, sync_mode=True, capture_ai_max_event_bytes=1024 * 1024 + ) + with patch_capture_send("client") as mock_send: + with self.assertLogs("posthog", level="ERROR") as logs: + result = client.capture_ai( + "$ai_generation", + distinct_id="d", + properties={"p": "x" * (2 * 1024 * 1024)}, + ) + + self.assertIsNone(result) + mock_send.assert_not_called() + self.assertIn("exceeds the 1024KiB limit", "\n".join(logs.output)) + + def test_sync_mode_ai_event_at_endpoint_ceiling_is_sent(self): + client = Client(TEST_API_KEY, sync_mode=True) + with patch_capture_send("client") as mock_send: + result = client.capture_ai( + "$ai_generation", + distinct_id="d", + properties={"p": "x" * AI_MAX_PROPERTIES_SIZE}, + ) + + self.assertIsNotNone(result) + mock_send.assert_called_once() + + def test_analytics_lane_sends_events_up_to_the_request_limit(self): + # Capture reports an analytics event over its per-event limit in the + # response, so the SDK only drops what no request could carry. client = self._client() consumer = client.consumers[0] - client.queue.put(self._sized_event("big_analytics_event", 2 * 1024 * 1024)) + big = self._sized_event("big_analytics_event", 2 * 1024 * 1024) + client.queue.put(big) + client.queue.put(self._sized_event("too_big_for_a_request", MAX_MSG_SIZE)) + self.assertEqual(consumer.next(), [big]) self.assertEqual(consumer.next(), []) self.assertTrue(client.queue.empty()) -class TestAiLaneV0Pinned(unittest.TestCase): - """The AI endpoint has no v1 form: the AI lane ignores `capture_mode=v1`.""" +class TestAiLaneWireConfig(unittest.TestCase): + """The AI lane has its own endpoint, compression, timeout, queue and size + guard, independent of the analytics lane's settings.""" - def test_ai_lane_consumers_pin_v0_and_ai_endpoint(self): - client = Client(TEST_API_KEY, send=False, capture_mode="v1", thread=2) + @parameterized.expand( + [ + ("defaults", {}, CaptureCompression.NONE, 30, 1000, AI_MAX_MSG_SIZE), + ( + "configured", + { + "capture_ai_compression": "zstd", + "capture_ai_timeout": 45, + "capture_ai_max_queue_size": 50, + "capture_ai_max_event_bytes": 1024 * 1024, + }, + CaptureCompression.ZSTD, + 45, + 50, + 1024 * 1024, + ), + ] + ) + def test_ai_lane_consumers_use_ai_config( + self, _name, config, compression, timeout, queue_size, max_event_bytes + ): + with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): + client = Client( + TEST_API_KEY, + send=False, + capture_compression="gzip", + timeout=9, + max_queue_size=77, + thread=2, + **config, + ) client._ai_lane.start() + self.assertEqual(client._ai_lane.queue.maxsize, queue_size) self.assertEqual(len(client._ai_lane.consumers), 2) for consumer in client._ai_lane.consumers: self.assertIs(consumer.queue, client._ai_lane.queue) - self.assertEqual(consumer.endpoint, AI_EVENTS_ENDPOINT) - self.assertEqual(consumer.max_msg_size, AI_MAX_MSG_SIZE) - self.assertEqual(consumer.capture_mode, CaptureMode.V0) - - def test_async_ai_events_use_v0_even_with_capture_mode_v1(self): - client = Client(TEST_API_KEY, capture_mode="v1", flush_interval=0.05) - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + self.assertEqual(consumer.endpoint, _CAPTURE_AI_V1_PATH) + self.assertEqual(consumer.max_msg_size, max_event_bytes) + self.assertEqual(consumer.capture_compression, compression) + self.assertEqual(consumer.timeout, timeout) + self.assertEqual(client.queue.maxsize, 77) + for consumer in client._analytics_lane.consumers: + self.assertEqual(consumer.capture_compression, CaptureCompression.GZIP) + self.assertEqual(consumer.timeout, 9) + client.join() + + @parameterized.expand( + [ + ( + "event_bytes_over_ceiling", + "capture_ai_max_event_bytes", + AI_MAX_MSG_SIZE + 1, + ), + ("event_bytes_zero", "capture_ai_max_event_bytes", 0), + ("event_bytes_float", "capture_ai_max_event_bytes", 1024.5), + ("event_bytes_bool", "capture_ai_max_event_bytes", True), + ("queue_size_negative", "capture_ai_max_queue_size", -1), + ("queue_size_string", "capture_ai_max_queue_size", "100"), + ("timeout_zero", "capture_ai_timeout", 0), + ("timeout_nan", "capture_ai_timeout", float("nan")), + ("timeout_inf", "capture_ai_timeout", float("inf")), + ("compression_unknown", "capture_ai_compression", "br"), + ] + ) + def test_invalid_ai_config_raises(self, _name, field, value): + with self.assertRaisesRegex(ValueError, field): + Client(TEST_API_KEY, send=False, **{field: value}) + + def test_async_lanes_keep_separate_path_and_compression(self): + client = Client(TEST_API_KEY, capture_compression="gzip", flush_interval=0.05) + with patch_capture_send("consumer") as mock_send: client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") client.flush() - mock_v1.assert_called() + sends = { + call.kwargs["path"]: call.kwargs["compression"] + for call in mock_send.call_args_list + } self.assertEqual( - [call.kwargs["path"] for call in mock_post.call_args_list], - [AI_EVENTS_ENDPOINT], + sends, + { + _CAPTURE_AI_V1_PATH: CaptureCompression.NONE, + _CAPTURE_V1_PATH: CaptureCompression.GZIP, + }, + ) + self.assertEqual( + _events_by_path(mock_send)[_CAPTURE_AI_V1_PATH][0]["event"], + "$ai_generation", ) client.join() - def test_sync_ai_events_use_v0_even_with_capture_mode_v1(self): - client = Client(TEST_API_KEY, sync_mode=True, capture_mode="v1") - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): + def test_sync_lanes_keep_separate_path_compression_and_timeout(self): + client = Client( + TEST_API_KEY, + sync_mode=True, + capture_compression="gzip", + capture_ai_compression="deflate", + timeout=9, + capture_ai_timeout=45, + ) + with patch_capture_send("client") as mock_send: client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - mock_v1.assert_called_once() + self.assertEqual( + [ + ( + call.kwargs["path"], + call.kwargs["compression"], + call.kwargs["timeout"], + ) + for call in mock_send.call_args_list + ], + [ + (_CAPTURE_AI_V1_PATH, CaptureCompression.DEFLATE, 45), + (_CAPTURE_V1_PATH, CaptureCompression.GZIP, 9), + ], + ) + + def test_flush_drains_ai_lane_while_waiting_on_analytics(self): + release_analytics = threading.Event() + ai_sent = threading.Event() + + def send(api_key, host, batch, **kwargs): + if kwargs["path"] == _CAPTURE_AI_V1_PATH: + ai_sent.set() + else: + release_analytics.wait(5) + + client = Client(TEST_API_KEY, flush_interval=30) + with patch_capture_send("consumer", side_effect=send): + client.capture("button_clicked", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") + flusher = threading.Thread(target=client.flush, args=(10,)) + flusher.start() + try: + self.assertTrue(ai_sent.wait(2)) + finally: + release_analytics.set() + flusher.join(5) + client.join() class TestAiLaneLazyStart(unittest.TestCase): @@ -241,12 +393,12 @@ def test_no_ai_consumers_until_first_capture_ai(self): client = Client(TEST_API_KEY, flush_interval=0.05) self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture("button_clicked", distinct_id="d") client.flush() self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture_ai("$ai_generation", distinct_id="d") self.assertEqual(len(client._ai_lane.consumers), 1) self.assertTrue(client._ai_lane.consumers[0].is_alive()) @@ -262,7 +414,7 @@ def fire(): client.capture_ai("$ai_generation", distinct_id="d") threads = [threading.Thread(target=fire) for _ in range(8)] - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): for thread in threads: thread.start() for thread in threads: @@ -285,7 +437,7 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): client = Client( TEST_API_KEY, flush_interval=0.05, enable_local_evaluation=False ) - with mock.patch("posthog.consumer.batch_post"): + with patch_capture_send("consumer"): client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) @@ -305,14 +457,14 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): self.assertTrue(client._analytics_lane.consumers[0].is_alive()) self.assertEqual(client._ai_lane.consumers, []) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client.capture("button_clicked", distinct_id="d") client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) self.assertEqual( - set(_events_by_path(mock_post)), {EVENTS_ENDPOINT, AI_EVENTS_ENDPOINT} + set(_events_by_path(mock_post)), {_CAPTURE_V1_PATH, _CAPTURE_AI_V1_PATH} ) client.join() @@ -328,11 +480,14 @@ def test_fork_rebuild_replaces_sync_mode_queues(self): class TestCaptureAiEventHelper(unittest.TestCase): - """`_capture_ai_event` rides the AI lane only when the client opted in.""" + """`_capture_ai_event` always rides the AI lane; `enable_full_ai_capture` only controls content.""" - def test_opted_in_routes_through_ai_lane(self): - client = Client(TEST_API_KEY, flush_interval=0.05, enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: + @parameterized.expand([("default", False), ("full_capture", True)]) + def test_routes_only_through_ai_lane(self, _name, full_capture): + client = Client( + TEST_API_KEY, flush_interval=0.05, enable_full_ai_capture=full_capture + ) + with patch_capture_send("consumer") as mock_post: _capture_ai_event( client, "$ai_generation", @@ -343,23 +498,10 @@ def test_opted_in_routes_through_ai_lane(self): self.assertEqual( [call.kwargs["path"] for call in mock_post.call_args_list], - [AI_EVENTS_ENDPOINT], + [_CAPTURE_AI_V1_PATH], ) client.join() - def test_default_keeps_capture_path(self): - client = Client(TEST_API_KEY, flush_interval=0.05) - with mock.patch("posthog.consumer.batch_post") as mock_post: - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.flush() - - self.assertEqual( - [call.kwargs["path"] for call in mock_post.call_args_list], - [EVENTS_ENDPOINT], - ) - self.assertEqual(client._ai_lane.consumers, []) - client.join() - def test_adds_ai_library_identity_and_preserves_provider_and_model(self): client = mock.Mock() _capture_ai_event( @@ -369,65 +511,117 @@ def test_adds_ai_library_identity_and_preserves_provider_and_model(self): properties={"$ai_provider": "openai", "$ai_model": "gpt-4o"}, ) - properties = client.capture.call_args.kwargs["properties"] + properties = client.capture_ai.call_args.kwargs["properties"] self.assertEqual(properties["$ai_lib"], "posthog-ai") self.assertEqual(properties["$ai_lib_version"], VERSION) self.assertEqual(properties["$ai_provider"], "openai") self.assertEqual(properties["$ai_model"], "gpt-4o") - def test_default_mock_clients_keep_seeing_capture(self): - # Downstream test suites pass Mock clients into the wrappers; without - # the opt-in they must keep seeing plain `capture()` calls. - client = mock.Mock() - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, - ) - client.capture_ai.assert_not_called() - - def test_opted_in_prefers_capture_ai(self): - client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) - client.enable_full_ai_capture = True - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture_ai.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, - ) - client.capture.assert_not_called() - def test_opted_in_duck_typed_client_without_method_falls_back(self): - client = mock.Mock(spec=["capture", "enable_full_ai_capture"]) - client.enable_full_ai_capture = True - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, - ) +_AZURE_KWARGS = { + "api_key": "test-key", + "azure_endpoint": "https://example.openai.azure.com", + "api_version": "2024-02-01", +} +_BEDROCK_KWARGS = { + "aws_region": "us-east-1", + "aws_access_key": "test-access-key", + "aws_secret_key": "test-secret-key", +} +_VERTEX_KWARGS = {"region": "us-east5", "project_id": "test", "access_token": "test"} - def test_client_multimodal_flag_prefers_capture_ai(self): - client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) - client.enable_full_ai_capture = True - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture_ai.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, - ) + +class TestAiIntegrationsRequireCaptureAi(unittest.TestCase): + @parameterized.expand( + [ + ("posthog.ai.openai", "OpenAI", "posthog_client", {"api_key": "test-key"}), + ( + "posthog.ai.openai", + "AsyncOpenAI", + "posthog_client", + {"api_key": "test-key"}, + ), + ("posthog.ai.openai", "AzureOpenAI", "posthog_client", _AZURE_KWARGS), + ("posthog.ai.openai", "AsyncAzureOpenAI", "posthog_client", _AZURE_KWARGS), + ( + "posthog.ai.anthropic", + "Anthropic", + "posthog_client", + {"api_key": "test-key"}, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropic", + "posthog_client", + {"api_key": "test-key"}, + ), + ( + "posthog.ai.anthropic", + "AnthropicBedrock", + "posthog_client", + _BEDROCK_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropicBedrock", + "posthog_client", + _BEDROCK_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AnthropicVertex", + "posthog_client", + _VERTEX_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropicVertex", + "posthog_client", + _VERTEX_KWARGS, + ), + ("posthog.ai.gemini", "Client", "posthog_client", {"api_key": "test-key"}), + ( + "posthog.ai.gemini", + "AsyncClient", + "posthog_client", + {"api_key": "test-key"}, + ), + ("posthog.ai.langchain", "CallbackHandler", "client", {}), + ("posthog.ai.openai_agents", "PostHogTracingProcessor", "client", {}), + ( + "posthog.ai.claude_agent_sdk", + "PostHogClaudeAgentProcessor", + "client", + {}, + ), + ], + name_func=lambda func, _num, param: f"{func.__name__}_{param.args[1]}", + ) + def test_integration_logs_and_disables_capture_for_client_without_capture_ai( + self, module, integration, client_argument, kwargs + ): + integration_class = getattr(pytest.importorskip(module), integration) + client = mock.Mock(spec=["capture", "privacy_mode"]) + + with self.assertLogs("posthog", level="ERROR") as logs: + integration_class(**{client_argument: client}, **kwargs) + + self.assertEqual(len(logs.records), 1) + self.assertIn("capture_ai", logs.output[0]) client.capture.assert_not_called() - def test_client_multimodal_flag_off_keeps_capture(self): - client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) - client.enable_full_ai_capture = False - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, + def test_disabled_ai_client_drops_ai_events_and_keeps_other_attributes(self): + client = mock.Mock(spec=["capture", "privacy_mode"]) + client.privacy_mode = True + + with self.assertLogs("posthog", level="ERROR"): + resolved = _resolve_ai_client(client) + + self.assertIsNone( + _capture_ai_event(resolved, "$ai_generation", distinct_id="d") ) + self.assertTrue(resolved.privacy_mode) + client.capture.assert_not_called() class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): @@ -435,9 +629,9 @@ class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): afterwards, even a lazy AI lane that never started before shutdown.""" def test_late_ai_capture_after_shutdown_starts_nothing_and_sends_nothing(self): - client = Client(TEST_API_KEY, enable_full_ai_capture=True, flush_interval=0.05) + client = Client(TEST_API_KEY, flush_interval=0.05) client.shutdown() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client._ai_lane.queue.join() self.assertEqual(client._ai_lane.consumers, []) @@ -499,10 +693,10 @@ def _client(self, **kwargs): def test_new_flag_routes_wrapper_captures_to_ai_lane(self): client = self._client(enable_full_ai_capture=True) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client.flush() - self.assertEqual(set(_events_by_path(mock_post)), {AI_EVENTS_ENDPOINT}) + self.assertEqual(set(_events_by_path(mock_post)), {_CAPTURE_AI_V1_PATH}) def test_deprecated_kwargs_map_to_new_flag(self): for kwargs in ({"_use_ai_lane": True}, {"_enable_multimodal_capture": True}): @@ -570,24 +764,24 @@ def _client(self, **kwargs): def test_returned_uuid_matches_the_wire_event_uuid(self): client = self._client() - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], returned_uuid) def test_supplied_uuid_is_preserved_end_to_end(self): client = self._client() supplied_uuid = str(uuid.uuid4()) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai( "$ai_generation", distinct_id="d", uuid=supplied_uuid ) client.flush() self.assertEqual(returned_uuid, supplied_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], supplied_uuid) def test_returned_uuid_reflects_before_send_replacement(self): @@ -598,12 +792,12 @@ def replace_uuid(event): return event client = self._client(before_send=replace_uuid) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(returned_uuid, replacement_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], replacement_uuid) def test_returned_uuid_is_regenerated_when_before_send_removes_it(self): @@ -612,26 +806,28 @@ def drop_uuid(event): return event client = self._client(before_send=drop_uuid) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertIsNotNone(returned_uuid) - batch = mock_post.call_args.kwargs["batch"] + batch = sent_batch(mock_post) self.assertEqual(batch[0]["uuid"], returned_uuid) class TestCaptureAiPrivacyMode(unittest.TestCase): """Privacy mode always wins over `enable_full_ai_capture`.""" - def test_privacy_mode_strips_content_despite_full_ai_capture(self): - client = Client( + @parameterized.expand([("sync", Client), ("async", AsyncPosthog)]) + def test_privacy_mode_strips_content_despite_full_ai_capture( + self, _name, client_cls + ): + client = client_cls( TEST_API_KEY, send=False, enable_full_ai_capture=True, privacy_mode=True, ) - self.addCleanup(client.join) payload = {"role": "user", "content": "sensitive prompt"} sanitized = with_privacy_mode( @@ -640,6 +836,33 @@ def test_privacy_mode_strips_content_despite_full_ai_capture(self): self.assertIsNone(sanitized) + @parameterized.expand( + [ + ("sync_default", Client, False), + ("sync_full_capture", Client, True), + ("async_default", AsyncPosthog, False), + ("async_full_capture", AsyncPosthog, True), + ] + ) + def test_full_ai_capture_controls_media_redaction( + self, _name, client_cls, full_capture + ): + client = client_cls( + TEST_API_KEY, send=False, enable_full_ai_capture=full_capture + ) + image = "data:image/jpeg;base64," + "A" * 64 + payload = [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": image}}], + } + ] + + sanitized = finalize_ai_content(payload, ph_client=client) + + sent_url = sanitized[0]["content"][0]["image_url"]["url"] + self.assertEqual(sent_url == image, full_capture) + if __name__ == "__main__": unittest.main() diff --git a/posthog/test/test_async_client.py b/posthog/test/test_async_client.py index fcb6e2298..5aa79fd09 100644 --- a/posthog/test/test_async_client.py +++ b/posthog/test/test_async_client.py @@ -7,13 +7,19 @@ import sys import threading from unittest import mock +from uuid import UUID import pytest -from posthog import AsyncClient, AsyncPosthog, CaptureCompression, CaptureMode -from posthog.consumer import MAX_MSG_SIZE +from posthog import AsyncClient, AsyncPosthog, CaptureCompression +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH +from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE from posthog.contexts import ( + identify_context, new_context, + set_context_option, + set_context_session, + tag, set_capture_exception_code_variables_context, set_code_variables_detect_secrets_context, set_code_variables_ignore_patterns_context, @@ -21,6 +27,7 @@ set_code_variables_mask_url_credentials_context, ) from posthog.request import APIError +from posthog.test.capture_helpers import patch_async_capture_send @pytest.mark.asyncio @@ -45,17 +52,17 @@ async def test_async_posthog_is_the_customer_facing_async_client(): async def test_capture_is_a_synchronous_queue_write_and_flushes(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog("test-key", flush_at=100, flush_interval=30) as client: event_uuid = client.capture( "async event", distinct_id="user-1", properties={"plan": "pro"}, ) - assert isinstance(event_uuid, str) + assert UUID(event_uuid).version == 7 await client.flush(timeout_seconds=1) assert len(batches) == 1 @@ -69,6 +76,26 @@ async def batch_post(*args, **kwargs): assert event["uuid"] == event_uuid +@pytest.mark.asyncio +@pytest.mark.parametrize( + "supplied, warnings", + [("not-a-uuid-secret-1", 1), (123, 1), ("", 0), (None, 0)], +) +async def test_capture_replaces_unusable_uuid_and_warns_only_when_invalid( + caplog, supplied, warnings +): + client = AsyncPosthog("test-key", send=False) + with caplog.at_level(logging.WARNING, logger="posthog"): + event_uuid = client.capture("async event", distinct_id="user-1", uuid=supplied) + await client.shutdown() + + assert UUID(event_uuid).version == 7 + logged = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert len(logged) == warnings + assert all(r.levelname == "WARNING" for r in logged) + assert all(str(supplied) not in r.getMessage() for r in logged) + + def test_capture_from_worker_thread_wakes_loop_bound_queue(): script = r""" import asyncio @@ -76,19 +103,18 @@ def test_capture_from_worker_thread_wakes_loop_bound_queue(): import threading from unittest import mock from posthog import AsyncPosthog +from posthog.test.capture_helpers import patch_async_capture_send async def main(): delivered = [] - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=batch_post - ): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_interval=30) - client._ensure_workers_started() - while not client._queue._getters: + client._ensure_workers_started(client._analytics_lane) + while not client._analytics_lane.queue._getters: await asyncio.sleep(0) asyncio.get_running_loop().set_debug(True) @@ -121,9 +147,12 @@ async def batch_post(*args, **kwargs): @pytest.mark.asyncio -async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission(): +@pytest.mark.parametrize("method_name", ["capture", "capture_ai"]) +async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission( + method_name, +): client = AsyncPosthog("test-key", flush_interval=30) - client._ensure_workers_started() + client._ensure_workers_started(client._analytics_lane) scheduled_callbacks = [] capture_result = [] @@ -134,7 +163,7 @@ async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission(): ): capture_thread = threading.Thread( target=lambda: capture_result.append( - client.capture("threaded event", distinct_id="user-1") + getattr(client, method_name)("threaded event", distinct_id="user-1") ), daemon=True, ) @@ -147,9 +176,12 @@ async def test_cross_thread_capture_rechecks_shutdown_before_queue_admission(): capture_thread.join(timeout=1) assert capture_result == [None] - for task in client._worker_tasks: + assert client._ai_lane.worker_tasks == [] + assert client._pending_queue_items() == 0 + worker_tasks = client._all_worker_tasks() + for task in worker_tasks: task.cancel() - await asyncio.gather(*client._worker_tasks, return_exceptions=True) + await asyncio.gather(*worker_tasks, return_exceptions=True) await client._close_transport() @@ -162,10 +194,10 @@ async def before_send(event): event["properties"]["from_before_send"] = True return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog( "test-key", before_send=before_send, flush_interval=30 ) as client: @@ -176,6 +208,41 @@ async def batch_post(*args, **kwargs): assert batches[0][0]["uuid"] == event_uuid +@pytest.mark.asyncio +@pytest.mark.parametrize("bad_id", [123, 1.5, None, ["user"], {"id": "user"}]) +async def test_capture_drops_an_event_whose_before_send_distinct_id_is_not_a_string( + bad_id, +): + batches = [] + + def before_send(event): + if event["event"] == "bad": + event["distinct_id"] = bad_id + return event + + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) + + with ( + patch_async_capture_send(side_effect=send_batch), + mock.patch("posthog.utils._warned_reasons", set()), + mock.patch.object(AsyncPosthog.log, "warning") as mock_warning, + ): + async with AsyncPosthog( + "test-key", before_send=before_send, flush_interval=30 + ) as client: + client.capture("bad", distinct_id="user") + client.capture("good", distinct_id="user") + assert await client.capture_immediate("bad", distinct_id="user") is None + await client.flush(timeout_seconds=1) + + events = [event for batch in batches for event in batch] + assert [(e["event"], e["distinct_id"]) for e in events] == [("good", "user")] + mock_warning.assert_called_once() + assert "distinct IDs must be strings" in mock_warning.call_args[0][0] + assert repr(bad_id) not in repr(mock_warning.call_args) + + @pytest.mark.asyncio async def test_capture_preserves_context_for_each_buffered_event(): request_id = contextvars.ContextVar("request_id") @@ -185,10 +252,10 @@ def before_send(event): event["properties"]["request_id"] = request_id.get() return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send, flush_interval=30) token = request_id.set("request-A") client.capture("event-A", distinct_id="user-1") @@ -217,7 +284,7 @@ def before_send(event): callback_thread = threading.get_ident() return event - with mock.patch("posthog._async_consumer.async_batch_post", new=mock.AsyncMock()): + with patch_async_capture_send(new=mock.AsyncMock()): client = AsyncPosthog("test-key", before_send=before_send) result = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() @@ -231,9 +298,7 @@ async def test_capture_drops_event_when_before_send_raises(): async def before_send(_event): raise RuntimeError("callback failed") - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: async with AsyncPosthog( "test-key", before_send=before_send, flush_interval=0.01 ) as client: @@ -241,18 +306,18 @@ async def before_send(_event): await client.flush(timeout_seconds=1) assert accepted_uuid is not None - batch_post.assert_not_awaited() + send_batch.assert_not_awaited() @pytest.mark.asyncio async def test_capture_immediate_waits_for_delivery(): delivered = asyncio.Event() - async def batch_post(*args, **kwargs): - assert kwargs["batch"][0]["event"] == "immediate event" + async def send_batch(api_key, host, batch, **kwargs): + assert batch[0]["event"] == "immediate event" delivered.set() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") event_uuid = await client.capture_immediate( "immediate event", distinct_id="user-1" @@ -271,10 +336,10 @@ async def before_send(event): event["properties"]["processed"] = True return event - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send) result = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() @@ -284,24 +349,42 @@ async def batch_post(*args, **kwargs): @pytest.mark.asyncio -async def test_capture_immediate_drops_oversized_event_after_before_send(): +@pytest.mark.parametrize( + ("method_name", "client_kwargs", "payload_size", "sent"), + [ + ("capture_immediate", {}, MAX_MSG_SIZE, False), + ("capture_ai_immediate", {}, AI_MAX_PROPERTIES_SIZE, True), + ("capture_ai_immediate", {}, AI_MAX_MSG_SIZE, False), + ("capture_ai_immediate", {"capture_ai_max_event_bytes": 1024}, 2048, False), + ], +) +async def test_capture_immediate_applies_its_lane_size_cap_after_before_send( + method_name, client_kwargs, payload_size, sent +): def before_send(event): - event["properties"]["user_input"] = "x" * MAX_MSG_SIZE + event["properties"]["user_input"] = "x" * payload_size return event - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: - client = AsyncPosthog("test-key", before_send=before_send) - result = await client.capture_immediate("event", distinct_id="user-1") + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: + client = AsyncPosthog("test-key", before_send=before_send, **client_kwargs) + result = await getattr(client, method_name)("event", distinct_id="user-1") await client.shutdown() - assert result is None - batch_post.assert_not_awaited() + assert (result is not None) is sent + assert send_batch.await_count == (1 if sent else 0) @pytest.mark.asyncio -async def test_capture_immediate_uses_capture_v1_without_building_httpx_client(): +@pytest.mark.parametrize( + ("method_name", "path", "compression", "timeout"), + [ + ("capture_immediate", _CAPTURE_V1_PATH, CaptureCompression.GZIP, 15), + ("capture_ai_immediate", _CAPTURE_AI_V1_PATH, CaptureCompression.DEFLATE, 45), + ], +) +async def test_capture_immediate_uses_its_lane_without_building_httpx_client( + method_name, path, compression, timeout +): with ( mock.patch( "posthog._async_consumer.async_send_v1_batch", new=mock.AsyncMock() @@ -310,33 +393,78 @@ async def test_capture_immediate_uses_capture_v1_without_building_httpx_client() ): client = AsyncPosthog( "test-key", - capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.GZIP, + capture_ai_compression=CaptureCompression.DEFLATE, + capture_ai_timeout=45, + ) + event_uuid = await getattr(client, method_name)( + "$ai_generation", distinct_id="user-1" ) - event_uuid = await client.capture_immediate("event", distinct_id="user-1") await client.shutdown() assert event_uuid is not None build_client.assert_not_called() send_v1.assert_awaited_once() - assert send_v1.await_args.kwargs["compression"] == CaptureCompression.GZIP + assert send_v1.await_args.kwargs["path"] == path + assert send_v1.await_args.kwargs["compression"] == compression + assert send_v1.await_args.kwargs["timeout"] == timeout assert send_v1.await_args.args[2][0]["uuid"] == event_uuid @pytest.mark.asyncio -async def test_missing_async_extra_does_not_accept_undeliverable_events(): - with mock.patch( - "posthog.async_client._require_httpx", - side_effect=RuntimeError("install posthog[async]"), - ): - client = AsyncPosthog("test-key") - assert client.capture("event", distinct_id="user-1") is None - assert ( - client.set(distinct_id="user-1", properties={"email": "a@example.com"}) - is None +async def test_ai_events_queue_on_their_own_lane_and_flush_drains_both(): + sent: dict[str, list[str]] = {} + + async def send_batch(api_key, host, batch, **kwargs): + sent.setdefault(kwargs["path"], []).extend(e["event"] for e in batch) + + with patch_async_capture_send(side_effect=send_batch): + client = AsyncPosthog("test-key", flush_at=100, flush_interval=30) + client.capture("pageview", distinct_id="user-1") + assert client._ai_lane.worker_tasks == [] + client.capture_ai( + "$ai_generation", + distinct_id="user-1", + properties={"$ai_input": "x" * AI_MAX_PROPERTIES_SIZE}, ) - assert client._pending_queue_items() == 0 - assert client._worker_tasks == [] + await client.flush(timeout_seconds=1) + assert sent == { + _CAPTURE_V1_PATH: ["pageview"], + _CAPTURE_AI_V1_PATH: ["$ai_generation"], + } + + client.capture_ai("$ai_span", distinct_id="user-1") + await client.shutdown() + + assert sent[_CAPTURE_AI_V1_PATH] == ["$ai_generation", "$ai_span"] + assert client._all_worker_tasks() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event", [None, 123]) +async def test_ai_capture_handles_a_non_string_event_name_like_capture(event): + client = AsyncPosthog("test-key", send=False) + try: + expected = client.capture(event, distinct_id="user-1") is None + assert (client.capture_ai(event, distinct_id="user-1") is None) is expected + # The immediate paths process the event, so they drop the invalid name. + assert await client.capture_immediate(event, distinct_id="user-1") is None + assert await client.capture_ai_immediate(event, distinct_id="user-1") is None + finally: + await client.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_key", ["", " ", "phx_secret"]) +async def test_missing_or_personal_api_key_disables_the_client(api_key): + with mock.patch.object(AsyncPosthog.log, "error") as mock_error: + client = AsyncPosthog(api_key, send=False) + try: + assert client.disabled + assert client.capture("event", distinct_id="user-1") is None + mock_error.assert_called_once() + assert "secret" not in repr(mock_error.call_args) + finally: await client.shutdown() @@ -346,7 +474,7 @@ async def test_send_false_accepts_without_starting_workers_or_transport(): client = AsyncPosthog("test-key", send=False) assert client.capture("event", distinct_id="user-1") is not None assert await client.capture_immediate("event", distinct_id="user-1") is not None - assert client._worker_tasks == [] + assert client._all_worker_tasks() == [] await client.shutdown() build_client.assert_not_called() @@ -382,10 +510,10 @@ async def test_identify_methods_enqueue_events( ): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): async with AsyncPosthog("test-key", flush_interval=30) as client: method = getattr(client, method_name) assert method(**method_kwargs) is not None @@ -394,6 +522,50 @@ async def batch_post(*args, **kwargs): assert batches[0][0]["event"] == expected_event +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "method_kwargs", "expected_distinct_id", "expected_session"), + [ + ( + "group_identify", + {"group_type": "company", "group_key": "acme-1"}, + "context-user", + None, + ), + ( + "alias", + {"previous_id": "anon-1", "distinct_id": "user-1"}, + "anon-1", + "context-session", + ), + ], +) +async def test_identity_methods_take_context_values_by_the_context_rule( + method_name, method_kwargs, expected_distinct_id, expected_session +): + batches = [] + + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) + + with patch_async_capture_send(side_effect=send_batch): + async with AsyncPosthog("test-key", flush_interval=30) as client: + with new_context(fresh=True): + identify_context("context-user") + set_context_session("context-session") + tag("tenant", "acme") + set_context_option("product_tour_id", "tour-1") + assert getattr(client, method_name)(**method_kwargs) is not None + assert client.alias("anon-2", None) is None + await client.flush(timeout_seconds=1) + + msg = batches[0][0] + assert msg["distinct_id"] == expected_distinct_id + assert msg["properties"].get("$session_id") == expected_session + assert msg["properties"]["tenant"] == "acme" + assert msg["options"]["product_tour_id"] == "tour-1" + + @pytest.mark.asyncio async def test_capture_after_shutdown_is_dropped_without_restarting_workers(): with mock.patch("posthog.async_client._build_client") as build_client: @@ -401,7 +573,7 @@ async def test_capture_after_shutdown_is_dropped_without_restarting_workers(): await client.shutdown() assert client.capture("event", distinct_id="user-1") is None assert await client.capture_immediate("event", distinct_id="user-1") is None - assert client._worker_tasks == [] + assert client._all_worker_tasks() == [] build_client.assert_not_called() @@ -409,12 +581,12 @@ async def test_capture_after_shutdown_is_dropped_without_restarting_workers(): async def test_batch_size_overflow_event_is_sent_in_the_next_batch(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch("posthog._async_consumer.BATCH_SIZE_LIMIT", 800), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("test-key", flush_at=10, flush_interval=30) client.capture("first", distinct_id="user-1", properties={"value": "a" * 400}) @@ -440,10 +612,10 @@ async def before_send(event): await allow_slow_callback.wait() return event - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog( "test-key", thread=2, @@ -469,12 +641,12 @@ async def test_shutdown_waits_for_an_in_flight_batch_instead_of_cancelling_it(): allow_upload = asyncio.Event() delivered = [] - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - delivered.extend(kwargs["batch"]) + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_at=1) client.capture("event", distinct_id="user-1") await upload_started.wait() @@ -498,9 +670,7 @@ async def before_send(event): callback_finished.set() return event - with mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post: + with patch_async_capture_send(new=mock.AsyncMock()) as send_batch: client = AsyncPosthog("test-key", before_send=before_send, flush_at=1) client.capture("event", distinct_id="user-1") await asyncio.wait_for(callback_finished.wait(), timeout=1) @@ -511,7 +681,7 @@ async def wait_until_closed(): await asyncio.wait_for(wait_until_closed(), timeout=1) - batch_post.assert_awaited_once() + send_batch.assert_awaited_once() assert client.capture("after shutdown", distinct_id="user-1") is None @@ -526,10 +696,10 @@ async def before_send(event): await allow_callback.wait() return event - async def batch_post(*args, **kwargs): - delivered.extend(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + delivered.extend(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", before_send=before_send, flush_at=1) client.capture("event", distinct_id="user-1") await callback_started.wait() @@ -544,10 +714,10 @@ async def batch_post(*args, **kwargs): @pytest.mark.asyncio async def test_shutdown_returns_when_all_workers_exited_with_queued_work(): client = AsyncPosthog("test-key", flush_interval=30) - client._ensure_workers_started() - for task in client._worker_tasks: + client._ensure_workers_started(client._analytics_lane) + for task in client._analytics_lane.worker_tasks: task.cancel() - await asyncio.gather(*client._worker_tasks, return_exceptions=True) + await asyncio.gather(*client._analytics_lane.worker_tasks, return_exceptions=True) assert client.capture("undrainable event", distinct_id="user-1") is not None try: @@ -563,7 +733,7 @@ async def test_shutdown_waits_for_immediate_operation_not_its_long_lived_caller( allow_upload = asyncio.Event() shutdown_task = None - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() @@ -573,7 +743,7 @@ async def capture_then_await_shutdown(client): assert shutdown_task is not None await shutdown_task - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") caller = asyncio.create_task(capture_then_await_shutdown(client)) await upload_started.wait() @@ -587,11 +757,11 @@ async def test_shutdown_waits_for_in_flight_immediate_capture(): upload_started = asyncio.Event() allow_upload = asyncio.Event() - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key") capture = asyncio.create_task( client.capture_immediate("event", distinct_id="user-1") @@ -615,16 +785,17 @@ async def test_reuses_and_closes_instance_owned_http_client(): "posthog.async_client._build_client", return_value=http_client ) as build, mock.patch( - "posthog._async_consumer.async_batch_post", new=mock.AsyncMock() - ) as batch_post, + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value={"flags": {}}), + ) as flags, ): client = AsyncPosthog("test-key") - await client.capture_immediate("first", distinct_id="user-1") - await client.capture_immediate("second", distinct_id="user-1") + await client._get_flags_decision("user-1") + await client._get_flags_decision("user-2") await client.shutdown() build.assert_called_once_with(client.host) - assert [call.kwargs["client"] for call in batch_post.await_args_list] == [ + assert [call.kwargs["client"] for call in flags.await_args_list] == [ http_client, http_client, ] @@ -636,21 +807,28 @@ def test_capture_before_loop_starts_is_flushed_when_loop_runs(): client = AsyncPosthog("test-key", flush_interval=30) assert client.capture("event", distinct_id="user-1") is not None - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) async def flush_and_close(): - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=batch_post - ): + with patch_async_capture_send(side_effect=send_batch): await client.shutdown() asyncio.run(flush_and_close()) assert batches[0][0]["event"] == "event" -@pytest.mark.parametrize(("option", "value"), [("flush_at", 0), ("flush_interval", 0)]) -def test_rejects_non_positive_batch_settings(option, value): +@pytest.mark.parametrize( + ("option", "value"), + [ + ("flush_at", 0), + ("flush_interval", 0), + ("capture_ai_max_queue_size", 0), + ("capture_ai_timeout", 0), + ("capture_ai_max_event_bytes", AI_MAX_MSG_SIZE + 1), + ], +) +def test_rejects_invalid_batch_settings(option, value): with pytest.raises(ValueError, match=option): AsyncPosthog("test-key", **{option: value}) @@ -663,6 +841,32 @@ async def test_capture_exception_never_raises_in_debug_mode(): await client.shutdown() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("level", "expected"), [(None, "error"), ("warning", "warning")] +) +async def test_capture_exception_sets_level_and_ignores_reserved_properties( + level, expected +): + client = AsyncPosthog("test-key", send=False) + with mock.patch.object(client, "capture", return_value="uuid") as capture: + client.capture_exception( + ValueError("boom"), + level=level, + properties={ + "$exception_list": [], + "$exception_level": "fatal", + "plan": "pro", + }, + ) + + properties = capture.call_args.kwargs["properties"] + assert properties["$exception_list"][0]["type"] == "ValueError" + assert properties["$exception_level"] == expected + assert properties["plan"] == "pro" + await client.shutdown() + + @pytest.mark.asyncio async def test_capture_exception_uses_context_code_variable_settings(): client = AsyncPosthog( @@ -699,7 +903,7 @@ async def test_capture_exception_uses_context_code_variable_settings(): @pytest.mark.asyncio async def test_queued_payload_is_not_written_to_debug_logs(caplog): caplog.set_level(logging.DEBUG, logger="posthog") - with mock.patch("posthog._async_consumer.async_batch_post", new=mock.AsyncMock()): + with patch_async_capture_send(new=mock.AsyncMock()): async with AsyncPosthog("test-key", flush_interval=30) as client: client.capture( "event", @@ -720,8 +924,7 @@ def on_error(error, batch): nonlocal callback_thread callback_thread = threading.get_ident() - with mock.patch( - "posthog._async_consumer.async_batch_post", + with patch_async_capture_send( side_effect=APIError(400, "failed"), ): client = AsyncPosthog("test-key", on_error=on_error, max_retries=0) @@ -734,14 +937,18 @@ def on_error(error, batch): @pytest.mark.asyncio @pytest.mark.parametrize("immediate", [False, True]) -async def test_failed_capture_does_not_log_server_response_detail(caplog, immediate): - caplog.set_level(logging.DEBUG, logger="posthog") - server_error = APIError(400, "password=server-secret") +async def test_capture_failure_inside_on_error_logs_instead_of_recursing( + caplog, immediate +): + calls = [] + client: AsyncPosthog - with mock.patch( - "posthog._async_consumer.async_batch_post", side_effect=server_error - ): - client = AsyncPosthog("test-key", flush_at=1, max_retries=0) + async def on_error(error, batch): + calls.append(batch[0]["event"]) + await client.capture_immediate("from_callback", distinct_id="user-1") + + with patch_async_capture_send(side_effect=APIError(503, "unavailable")): + client = AsyncPosthog("test-key", on_error=on_error, flush_at=1, max_retries=0) if immediate: await client.capture_immediate("event", distinct_id="user-1") else: @@ -749,9 +956,46 @@ async def test_failed_capture_does_not_log_server_response_detail(caplog, immedi await client.flush(timeout_seconds=1) await client.shutdown() + assert calls == ["event"] + assert ( + "1 event(s) not persisted by /i/v1/analytics/events: APIError (status=503)" + in caplog.text + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "path"), + [ + ("capture", _CAPTURE_V1_PATH), + ("capture_immediate", _CAPTURE_V1_PATH), + ("capture_ai", _CAPTURE_AI_V1_PATH), + ("capture_ai_immediate", _CAPTURE_AI_V1_PATH), + ], +) +async def test_failed_capture_logs_one_loss_line_without_response_detail( + caplog, method_name, path +): + caplog.set_level(logging.DEBUG, logger="posthog") + server_error = APIError(400, "password=server-secret") + + with patch_async_capture_send(side_effect=server_error): + client = AsyncPosthog("test-key", flush_at=1, max_retries=0) + result = getattr(client, method_name)("$ai_generation", distinct_id="user-1") + if method_name.endswith("_immediate"): + await result + await client.flush(timeout_seconds=1) + await client.shutdown() + assert "server-secret" not in caplog.text - assert "APIError" in caplog.text - assert "status=400" in caplog.text + loss_lines = [ + r.getMessage() + for r in caplog.records + if "event(s) not persisted by" in r.getMessage() + ] + assert loss_lines == [ + f"[PostHog] 1 event(s) not persisted by {path}: APIError (status=400)" + ] @pytest.mark.asyncio @@ -759,11 +1003,11 @@ async def test_flush_timeout_reports_unfinished_items(caplog): upload_started = asyncio.Event() allow_upload = asyncio.Event() - async def batch_post(*args, **kwargs): + async def send_batch(api_key, host, batch, **kwargs): upload_started.set() await allow_upload.wait() - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): client = AsyncPosthog("test-key", flush_at=1) client.capture("event", distinct_id="user-1") await upload_started.wait() diff --git a/posthog/test/test_async_consumer.py b/posthog/test/test_async_consumer.py index 9bb3f682f..db3e17feb 100644 --- a/posthog/test/test_async_consumer.py +++ b/posthog/test/test_async_consumer.py @@ -1,17 +1,12 @@ from __future__ import annotations import asyncio -import json from unittest import mock -import httpx import pytest -from freezegun import freeze_time from posthog._async_consumer import _AsyncConsumer from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode -from posthog.request import APIError def make_consumer(*, retries: int) -> _AsyncConsumer: @@ -20,140 +15,16 @@ def make_consumer(*, retries: int) -> _AsyncConsumer: "test-key", host="https://example.com", on_error=None, - process_event=mock.AsyncMock(side_effect=lambda event: event), + process_event=mock.AsyncMock(side_effect=lambda event, defaults: event), flush_at=100, flush_interval=1, - gzip=False, retries=retries, timeout=3, historical_migration=False, - capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, - http_client=mock.Mock(), ) -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("failures", "retry_after", "expected_delays"), - [ - (1, None, [1]), - (2, None, [1, 2]), - (2, 5, [5, 5]), - ], -) -async def test_request_retries_transient_failures_until_success( - failures, retry_after, expected_delays -): - error = APIError(503, "temporary", retry_after=retry_after) - consumer = make_consumer(retries=failures) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=[error] * failures + [None]), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - ): - await consumer.request([{"event": "test"}]) - - assert batch_post.await_count == failures + 1 - assert [call.args[0] for call in sleep.await_args_list] == expected_delays - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("retry_after", "expected_delay"), - [ - ("Tue, 08 Sep 2026 00:00:10 GMT", 10), - ("Tue, 08 Sep 2026 00:01:00 GMT", 30), - ("Mon, 07 Sep 2026 23:59:59 GMT", 1), - ("5", 5), - ("0", 1), - ("-1", 1), - ("invalid", 1), - (None, 1), - ], -) -async def test_request_honors_retry_after_from_http_response( - retry_after, expected_delay -): - headers = {"Retry-After": retry_after} if retry_after is not None else {} - responses = [ - httpx.Response(503, headers=headers, json={"detail": "temporary"}), - httpx.Response(200, json={"ok": True}), - ] - requests = [] - - def handle_request(request): - requests.append(request) - return responses.pop(0) - - consumer = make_consumer(retries=1) - batch = [{"event": "test", "distinct_id": "test-user"}] - async with httpx.AsyncClient( - base_url="https://example.com", transport=httpx.MockTransport(handle_request) - ) as client: - consumer.http_client = client - with ( - freeze_time("2026-09-08 00:00:00", real_asyncio=True), - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - ): - await consumer.request(batch) - - sleep.assert_awaited_once_with(expected_delay) - assert len(requests) == 2 - assert [json.loads(request.content)["batch"] for request in requests] == [ - batch, - batch, - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status", [400, 401, 413]) -async def test_request_does_not_retry_terminal_client_errors(status): - consumer = make_consumer(retries=3) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=APIError(status, "terminal")), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - pytest.raises(APIError), - ): - await consumer.request([{"event": "test"}]) - - batch_post.assert_awaited_once() - sleep.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_request_stops_after_configured_retry_limit(): - consumer = make_consumer(retries=2) - - with ( - mock.patch( - "posthog._async_consumer.async_batch_post", - new=mock.AsyncMock(side_effect=APIError(503, "temporary")), - ) as batch_post, - mock.patch( - "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() - ) as sleep, - pytest.raises(APIError), - ): - await consumer.request([{"event": "test"}]) - - assert batch_post.await_count == 3 - assert [call.args[0] for call in sleep.await_args_list] == [1, 2] - - @pytest.mark.asyncio @pytest.mark.parametrize("run_worker", [False, True], ids=["wait", "worker"]) async def test_get_or_flush_cancels_waiters_on_cancellation(run_worker): diff --git a/posthog/test/test_async_feature_flags.py b/posthog/test/test_async_feature_flags.py index 75c8b207e..da35bf17d 100644 --- a/posthog/test/test_async_feature_flags.py +++ b/posthog/test/test_async_feature_flags.py @@ -11,6 +11,7 @@ from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES from posthog.contexts import new_context, set_context_device_id from posthog.request import APIError +from posthog.test.capture_helpers import patch_async_capture_send def flags_response(*, minimal=False): @@ -138,15 +139,15 @@ async def test_empty_flag_keys_skips_remote_request(): async def test_snapshot_access_captures_one_flag_called_event_and_attaches_flags(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch( "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=flags_response()), ), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("project-key", flush_interval=30) snapshot = await client.evaluate_flags("user-1") @@ -171,15 +172,15 @@ async def batch_post(*args, **kwargs): async def test_minimal_flag_called_event_uses_strict_property_allowlist(): batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) with ( mock.patch( "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=flags_response(minimal=True)), ), - mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + patch_async_capture_send(side_effect=send_batch), ): client = AsyncPosthog("project-key", flush_interval=30) snapshot = await client.evaluate_flags("user-1", flag_keys=["beta"]) diff --git a/posthog/test/test_async_request.py b/posthog/test/test_async_request.py index d95da855a..f269db258 100644 --- a/posthog/test/test_async_request.py +++ b/posthog/test/test_async_request.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import json import logging import subprocess @@ -13,7 +12,6 @@ from posthog._async_request import ( _build_client, _process_response, - async_batch_post, async_flags, async_remote_config, ) @@ -84,148 +82,6 @@ def test_build_client_scopes_requests_to_host_without_following_redirects(): ) -@pytest.mark.asyncio -async def test_async_batch_post_uses_configured_host_and_sanitized_logs(caplog): - caplog.set_level(logging.DEBUG, logger="posthog") - client = FakeAsyncClient() - - await async_batch_post( - "test-secret-key", - "https://example.com", - batch=[{"properties": {"password": "super-secret"}}], - path="/batch/", - client=client, - ) - - assert client.calls[0][1] == ("https://example.com/batch/",) - assert "super-secret" not in caplog.text - assert "test-secret-key" not in caplog.text - assert "https://example.com" not in caplog.text - - -@pytest.mark.asyncio -async def test_async_batch_post_follows_same_origin_temporary_redirect(): - client = FakeAsyncClient( - [ - FakeResponse(307, headers={"Location": "/redirected-batch/"}), - FakeResponse(200), - ] - ) - - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - client=client, - ) - - assert [call[1] for call in client.calls] == [ - ("https://example.com/batch/",), - ("https://example.com/redirected-batch/",), - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status", [307, 308]) -@pytest.mark.parametrize( - "host", ["https://example.com/proxy", "https://example.com/proxy/"] -) -@pytest.mark.parametrize( - ("location", "redirected_path"), - [ - ("/proxy/redirected-batch/", "/proxy/redirected-batch/"), - ("https://example.com/proxy/redirected-batch/", "/proxy/redirected-batch/"), - ("/redirected-batch/", "/redirected-batch/"), - ("../redirected-batch/", "/proxy/redirected-batch/"), - ("?accepted=1", "/proxy/batch/?accepted=1"), - ], -) -async def test_async_batch_post_redirects_with_host_path_prefix( - status, host, location, redirected_path -): - requests = [] - responses = [ - httpx.Response(status, headers={"Location": location}), - httpx.Response(status, headers={"Location": "?attempt=2"}), - httpx.Response(200), - ] - - def handle_request(request): - requests.append(request) - return responses.pop(0) - - batch = [{"event": "test", "distinct_id": "test-user"}] - async with httpx.AsyncClient( - base_url=host, transport=httpx.MockTransport(handle_request) - ) as client: - await async_batch_post( - "test-key", host, batch=batch, path="/batch/", client=client - ) - - assert [str(request.url) for request in requests] == [ - "https://example.com/proxy/batch/", - f"https://example.com{redirected_path}", - f"https://example.com{redirected_path.split('?')[0]}?attempt=2", - ] - assert all(request.method == "POST" for request in requests) - assert all(request.content == requests[0].content for request in requests) - assert json.loads(requests[0].content)["batch"] == batch - - -@pytest.mark.asyncio -async def test_async_batch_post_rejects_cross_origin_temporary_redirect(): - client = FakeAsyncClient( - FakeResponse( - 307, - headers={"Location": "https://attacker.example/redirected-batch/"}, - ) - ) - - with pytest.raises(APIError): - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - client=client, - ) - - assert len(client.calls) == 1 - - -@pytest.mark.asyncio -async def test_async_batch_post_serializes_off_event_loop(): - client = FakeAsyncClient() - real_to_thread = asyncio.to_thread - - with mock.patch( - "posthog._async_request.asyncio.to_thread", wraps=real_to_thread - ) as to_thread: - await async_batch_post( - "test-key", - "https://example.com", - batch=[{"event": "event"}], - path="/batch/", - gzip=True, - client=client, - ) - - to_thread.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_async_batch_post_rejects_absolute_request_path(): - with pytest.raises(ValueError, match="relative"): - await async_batch_post( - "test-key", - "https://example.com", - batch=[], - path="https://attacker.example/batch/", - client=FakeAsyncClient(), - ) - - @pytest.mark.asyncio async def test_async_flags_sends_v2_request_payload(): client = FakeAsyncClient(FakeResponse(200, {"flags": {}})) diff --git a/posthog/test/test_before_send.py b/posthog/test/test_before_send.py index ee048290e..af0dac383 100644 --- a/posthog/test/test_before_send.py +++ b/posthog/test/test_before_send.py @@ -2,18 +2,25 @@ import unittest from unittest import mock +from parameterized import parameterized + import posthog from posthog.client import Client from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import ( + patch_capture_send, + sent_batch, + sent_events, +) class TestClient(unittest.TestCase): @classmethod def setUpClass(cls): # This ensures no real HTTP POST requests are made - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() @@ -42,7 +49,7 @@ def my_before_send(event): event["properties"]["processed_by_before_send"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -57,7 +64,7 @@ def my_before_send(event): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual( @@ -75,7 +82,7 @@ def replace_uuid(event): event["uuid"] = replacement_uuid return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -87,7 +94,7 @@ def replace_uuid(event): self.assertEqual(msg_uuid, replacement_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["uuid"], replacement_uuid) @@ -98,7 +105,7 @@ def remove_uuid(event): del event["uuid"] return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -110,7 +117,7 @@ def remove_uuid(event): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["uuid"], msg_uuid) @@ -122,7 +129,7 @@ def drop_test_events(event): return None return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -140,7 +147,7 @@ def drop_test_events(event): # Check the enqueued message mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["event"], "keep_me") @@ -152,7 +159,7 @@ def buggy_before_send(event): event["uuid"] = "invalid" raise ValueError("Oops!") - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -187,7 +194,7 @@ def buggy_before_send(event): ) try: with ( - mock.patch("posthog.consumer.batch_post") as mock_post, + patch_capture_send("consumer") as mock_post, mock.patch.object( client._analytics_lane, "enqueue", @@ -210,7 +217,7 @@ def add_unsupported_value(event): event["properties"]["marker"] = marker return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, before_send=add_unsupported_value, @@ -218,12 +225,12 @@ def add_unsupported_value(event): ) self.assertIsNotNone(client.capture("recleaned", distinct_id="user1")) - sent_event = mock_post.call_args.kwargs["batch"][0] + sent_event = sent_batch(mock_post)[0] self.assertIsNone(sent_event["properties"]["marker"]) def test_before_send_callback_non_dict_output_drops_event(self): with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch("posthog.client.Client.log.exception") as mock_log, ): client = Client( @@ -238,29 +245,45 @@ def test_before_send_callback_non_dict_output_drops_event(self): "before_send must return a dict or None", mock_log.call_args.args[0] ) - def test_malformed_before_send_event_does_not_stop_consumer_or_shutdown(self): - def add_invalid_mapping_key(event): + @parameterized.expand( + [ + # A key json cannot write costs only that key. + ( + "unwritable_key", + ("private-key",), + "private-value", + ["malformed", "valid"], + ), + # A value json cannot write costs only that event, never the batch. + ("non_finite_value", "private-key", float("nan"), ["valid"]), + ] + ) + def test_malformed_before_send_event_does_not_stop_consumer_or_shutdown( + self, _name, key, value, expected_events + ): + def add_malformed_property(event): if event["event"] == "malformed": - event["properties"][("private-key",)] = "private-value" + event["properties"][key] = value return event client = Client( FAKE_TEST_API_KEY, - before_send=add_invalid_mapping_key, - flush_at=1, + before_send=add_malformed_property, + flush_at=2, flush_interval=0.01, ) with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - self.assertLogs("posthog", level="ERROR") as logs, + patch_capture_send("consumer") as mock_post, + self.assertLogs("posthog", level="DEBUG") as logs, ): client.capture("malformed", distinct_id="user1") client.capture("valid", distinct_id="user1") client.shutdown() mock_post.assert_called_once() - sent_batch = mock_post.call_args.kwargs["batch"] - self.assertEqual([event["event"] for event in sent_batch], ["valid"]) + sent = sent_batch(mock_post) + self.assertEqual([event["event"] for event in sent], expected_events) + self.assertTrue(all(key not in event["properties"] for event in sent)) self.assertEqual(client.queue.unfinished_tasks, 0) self.assertTrue(all(not consumer.is_alive() for consumer in client.consumers)) self.assertNotIn("private-key", "\n".join(logs.output)) @@ -275,7 +298,7 @@ def add_marker(event): event["properties"]["marked"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -293,14 +316,12 @@ def add_marker(event): # Check all events were marked self.assertEqual(mock_post.call_count, 2) - for call in mock_post.call_args_list: - batch_data = call[1]["batch"] - enqueued_msg = batch_data[0] - self.assertTrue(enqueued_msg["properties"]["marked"]) + for event in sent_events(mock_post): + self.assertTrue(event["properties"]["marked"]) def test_before_send_callback_disabled_when_none(self): """Test that client works normally when before_send is None.""" - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -312,7 +333,7 @@ def test_before_send_callback_disabled_when_none(self): # Check the event was sent normally mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["event"], "normal_event") @@ -336,7 +357,7 @@ def scrub_pii(event): return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -357,7 +378,7 @@ def scrub_pii(event): # Check the enqueued message was scrubbed mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com") @@ -379,7 +400,7 @@ def my_before_send(event): event["properties"]["module_level_before_send"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: posthog.api_key = FAKE_TEST_API_KEY posthog.before_send = my_before_send posthog.sync_mode = True @@ -390,7 +411,7 @@ def my_before_send(event): self.assertIs(posthog.default_client.before_send, my_before_send) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) enqueued_msg = batch_data[0] self.assertTrue(enqueued_msg["properties"]["module_level_before_send"]) @@ -399,7 +420,7 @@ def my_before_send(event): event["properties"]["updated_after_init"] = True return event - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: posthog.api_key = FAKE_TEST_API_KEY posthog.sync_mode = True @@ -413,8 +434,8 @@ def my_before_send(event): self.assertIs(posthog.default_client.before_send, my_before_send) self.assertEqual(mock_post.call_count, 2) - first_batch = mock_post.call_args_list[0][1]["batch"] - second_batch = mock_post.call_args_list[1][1]["batch"] + first_batch = sent_batch(mock_post, 0) + second_batch = sent_batch(mock_post, 1) self.assertNotIn("updated_after_init", first_batch[0]["properties"]) self.assertTrue(second_batch[0]["properties"]["updated_after_init"]) diff --git a/posthog/test/test_capture_compression.py b/posthog/test/test_capture_compression.py index a4ce0154f..cb9376572 100644 --- a/posthog/test/test_capture_compression.py +++ b/posthog/test/test_capture_compression.py @@ -22,15 +22,9 @@ def setUp(self) -> None: self.addCleanup(patcher.stop) os.environ.pop(CAPTURE_COMPRESSION_ENV_VAR, None) - def test_defaults_to_none_with_no_kwarg_env_or_gzip(self) -> None: + def test_defaults_to_none_with_no_kwarg_or_env(self) -> None: self.assertIs(_resolve_capture_compression(None), CaptureCompression.NONE) - def test_gzip_fallback_used_when_nothing_else_set(self) -> None: - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, - ) - @parameterized.expand( [ ("enum_gzip", CaptureCompression.GZIP, CaptureCompression.GZIP), @@ -48,12 +42,9 @@ def test_gzip_fallback_used_when_nothing_else_set(self) -> None: def test_explicit_kwarg_takes_precedence_and_coerces( self, _name, kwarg, expected ) -> None: - # Env names a different value and gzip_fallback is on, so each row proves - # the explicit kwarg wins over both lower-precedence sources. + # Env names a different value, so each row proves the explicit kwarg wins. with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): - self.assertIs( - _resolve_capture_compression(kwarg, gzip_fallback=True), expected - ) + self.assertIs(_resolve_capture_compression(kwarg), expected) def test_invalid_kwarg_raises_even_with_valid_env(self) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "gzip"}): @@ -80,28 +71,16 @@ def test_env_var_resolution(self, _name, env_value, expected) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: env_value}): self.assertIs(_resolve_capture_compression(None), expected) - def test_env_var_takes_precedence_over_gzip_fallback(self) -> None: - with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "deflate"}): - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.DEFLATE, - ) - @parameterized.expand([("empty", ""), ("whitespace", " ")]) - def test_blank_env_var_falls_through_to_fallback(self, _name, env_value) -> None: + def test_blank_env_var_falls_through_to_none(self, _name, env_value) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: env_value}): self.assertIs(_resolve_capture_compression(None), CaptureCompression.NONE) - self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, - ) - def test_unrecognized_env_var_warns_and_uses_fallback(self) -> None: + def test_unrecognized_env_var_warns_and_uses_none(self) -> None: with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "bogus"}): with capture_message_only_logs() as stream: self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, + _resolve_capture_compression(None), CaptureCompression.NONE ) self.assertIn("bogus", stream.getvalue()) @@ -112,13 +91,12 @@ def test_explicit_zstd_without_package_raises(self, _name, kwarg) -> None: _resolve_capture_compression(kwarg) self.assertIn("posthog[zstd]", str(ctx.exception)) - def test_env_zstd_without_package_warns_and_uses_fallback(self) -> None: + def test_env_zstd_without_package_warns_and_uses_none(self) -> None: with mock.patch("posthog.capture_compression._zstandard", None): with mock.patch.dict(os.environ, {CAPTURE_COMPRESSION_ENV_VAR: "zstd"}): with capture_message_only_logs() as stream: self.assertIs( - _resolve_capture_compression(None, gzip_fallback=True), - CaptureCompression.GZIP, + _resolve_capture_compression(None), CaptureCompression.NONE ) self.assertIn("posthog[zstd]", stream.getvalue()) @@ -134,10 +112,6 @@ def test_client_defaults_to_none(self) -> None: client = Client(TEST_API_KEY, sync_mode=True) self.assertIs(client.capture_compression, CaptureCompression.NONE) - def test_client_gzip_flag_falls_back_to_gzip(self) -> None: - client = Client(TEST_API_KEY, sync_mode=True, gzip=True) - self.assertIs(client.capture_compression, CaptureCompression.GZIP) - @parameterized.expand( [ ("enum_deflate", CaptureCompression.DEFLATE, CaptureCompression.DEFLATE), @@ -145,11 +119,8 @@ def test_client_gzip_flag_falls_back_to_gzip(self) -> None: ("str_none", "none", CaptureCompression.NONE), ] ) - def test_client_kwarg_overrides_gzip_flag(self, _name, kwarg, expected) -> None: - # Even with the legacy gzip flag on, the explicit kwarg wins. - client = Client( - TEST_API_KEY, sync_mode=True, gzip=True, capture_compression=kwarg - ) + def test_client_kwarg_sets_compression(self, _name, kwarg, expected) -> None: + client = Client(TEST_API_KEY, sync_mode=True, capture_compression=kwarg) self.assertIs(client.capture_compression, expected) def test_client_propagates_to_consumers(self) -> None: diff --git a/posthog/test/test_capture_event.py b/posthog/test/test_capture_event.py new file mode 100644 index 000000000..e4a7c7633 --- /dev/null +++ b/posthog/test/test_capture_event.py @@ -0,0 +1,297 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from parameterized import parameterized + +from uuid import UUID + +from posthog.capture_event import ( + _build_v1_batch_body, + _canonical_event_uuid, + _to_v1_event, +) + + +def _legacy_msg(event="my_event", properties=None, **overrides) -> dict: + """Minimal legacy-shaped message as it looks coming off the queue.""" + msg = { + "event": event, + "uuid": "0190000000007000800000000000000a", + "distinct_id": "user-1", + "timestamp": "2026-06-27T12:00:00+00:00", + "type": "capture", + "properties": {"$lib": "posthog-python", "$lib_version": "9.9.9"}, + } + if properties is not None: + msg["properties"] = properties + msg.update(overrides) + return msg + + +_CANONICAL_UUID = "0190a8f3-1b2c-7d4e-8f90-123456789abc" + + +class TestCanonicalEventUuid(unittest.TestCase): + @parameterized.expand( + [ + ("canonical", _CANONICAL_UUID), + ("uppercase", _CANONICAL_UUID.upper()), + ("unhyphenated", _CANONICAL_UUID.replace("-", "")), + ("braced", "{" + _CANONICAL_UUID + "}"), + ("urn", "urn:uuid:" + _CANONICAL_UUID), + ("urn_uppercase", "URN:UUID:" + _CANONICAL_UUID.upper()), + ("uuid_instance", UUID(_CANONICAL_UUID)), + ] + ) + def test_accepted_forms_are_canonicalized(self, _name, value) -> None: + self.assertEqual(_canonical_event_uuid(value), _CANONICAL_UUID) + + @parameterized.expand( + [ + ("bare_uuid_prefix", "uuid:" + _CANONICAL_UUID), + ("misplaced_hyphen", "0190a8f31-b2c-7d4e-8f90-123456789abc"), + ("surrounding_whitespace", " " + _CANONICAL_UUID), + ("braced_unhyphenated", "{" + _CANONICAL_UUID.replace("-", "") + "}"), + ("too_short", _CANONICAL_UUID[:-1]), + ("empty", ""), + ("int", 123), + ] + ) + def test_other_forms_are_rejected(self, _name, value) -> None: + self.assertIsNone(_canonical_event_uuid(value)) + + +class TestToV1Event(unittest.TestCase): + def test_required_fields_preserved(self) -> None: + event = _to_v1_event(_legacy_msg(event="signed_up")) + self.assertEqual(event["event"], "signed_up") + self.assertEqual(event["uuid"], "0190000000007000800000000000000a") + self.assertEqual(event["distinct_id"], "user-1") + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_strips_lib_and_lib_version(self) -> None: + event = _to_v1_event(_legacy_msg()) + self.assertNotIn("$lib", event["properties"]) + self.assertNotIn("$lib_version", event["properties"]) + + def test_options_empty_dict_when_no_sentinels(self) -> None: + event = _to_v1_event(_legacy_msg(properties={"plain": "value"})) + self.assertEqual(event["options"], {}) + self.assertEqual(event["properties"], {"plain": "value"}) + + def test_does_not_leak_non_wire_top_level_keys(self) -> None: + event = _to_v1_event(_legacy_msg()) + # `type` is legacy-only; the v1 event carries only documented fields. + self.assertEqual( + set(event), + {"event", "uuid", "distinct_id", "timestamp", "options", "properties"}, + ) + + def test_does_not_mutate_input(self) -> None: + msg = _legacy_msg( + properties={"$cookieless_mode": True, "$session_id": "s-1"}, + options={"product_tour_id": "tour-1"}, + **{"$set": {"name": "Max"}}, + ) + original_properties = dict(msg["properties"]) + _to_v1_event(msg) + self.assertEqual(msg["properties"], original_properties) + self.assertEqual(msg["options"], {"product_tour_id": "tour-1"}) + self.assertIn("$set", msg) # top-level $set untouched on the original + + @parameterized.expand( + [ + ("cookieless_mode", "$cookieless_mode", "cookieless_mode", True), + ("ignore_sent_at", "$ignore_sent_at", "disable_skew_correction", "true"), + ( + "process_person_profile", + "$process_person_profile", + "process_person_profile", + 0, + ), + ("product_tour_id", "$product_tour_id", "product_tour_id", 123), + ] + ) + def test_legacy_property_fills_option_unchanged( + self, _name, prop_key, option_key, raw + ) -> None: + event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) + self.assertEqual(event["options"], {option_key: raw}) + self.assertNotIn(prop_key, event["properties"]) + + @parameterized.expand( + [ + ("option_set", {"cookieless_mode": False}, False), + ("option_null", {"cookieless_mode": None}, True), + ("option_missing", {}, True), + ] + ) + def test_caller_option_wins_over_legacy_property( + self, _name, options, expected + ) -> None: + event = _to_v1_event( + _legacy_msg(properties={"$cookieless_mode": True}, options=options) + ) + self.assertEqual(event["options"], {"cookieless_mode": expected}) + self.assertNotIn("$cookieless_mode", event["properties"]) + + def test_caller_options_pass_through_unchanged(self) -> None: + options = {"process_person_profile": "false", "future_option": {"a": [1]}} + event = _to_v1_event(_legacy_msg(options=options)) + self.assertEqual(event["options"], options) + + @parameterized.expand([("list", ["x"]), ("string", "x"), ("int", 1)]) + def test_non_dict_options_are_logged_and_ignored(self, _name, options) -> None: + with self.assertLogs("posthog", level="ERROR") as logs: + event = _to_v1_event( + _legacy_msg(properties={"$cookieless_mode": True}, options=options) + ) + self.assertEqual(event["options"], {"cookieless_mode": True}) + self.assertIn("options must be a dict", logs.output[0]) + + @parameterized.expand( + [ + ("session_id", "$session_id", "session_id", "s-123"), + ("window_id", "$window_id", "window_id", "w-456"), + ("empty_string", "$session_id", "session_id", ""), + ] + ) + def test_top_level_string_sentinels(self, _name, prop_key, field_name, raw) -> None: + event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) + self.assertEqual(event[field_name], raw) + self.assertNotIn(prop_key, event["properties"]) + + @parameterized.expand( + [ + ("number", "$session_id", 42, "number"), + ("bool", "$window_id", True, "bool"), + ("array", "$session_id", ["s-1"], "array"), + ("object", "$window_id", {"id": "w-1"}, "object"), + ] + ) + def test_non_string_sentinel_is_dropped_with_a_warning( + self, _name, prop_key, raw, type_name + ) -> None: + with self.assertLogs("posthog", level="WARNING") as logs: + event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) + self.assertNotIn(prop_key.lstrip("$"), event) + self.assertNotIn(prop_key, event["properties"]) + self.assertIn( + f"dropping {prop_key}: a {type_name} value is not a string", + logs.records[0].getMessage(), + ) + + def test_null_sentinel_is_dropped_silently(self) -> None: + with self.assertNoLogs("posthog", level="WARNING"): + event = _to_v1_event(_legacy_msg(properties={"$session_id": None})) + self.assertNotIn("session_id", event) + self.assertNotIn("$session_id", event["properties"]) + + def test_all_sentinels_together(self) -> None: + event = _to_v1_event( + _legacy_msg( + properties={ + "$cookieless_mode": True, + "$ignore_sent_at": True, + "$product_tour_id": "tour-x", + "$process_person_profile": False, + "$session_id": "s-1", + "$window_id": "w-1", + "$geoip_disable": True, + "custom": "keep", + } + ) + ) + self.assertEqual( + event["options"], + { + "cookieless_mode": True, + "disable_skew_correction": True, + "product_tour_id": "tour-x", + "process_person_profile": False, + }, + ) + self.assertEqual(event["session_id"], "s-1") + self.assertEqual(event["window_id"], "w-1") + # Non-sentinel props (including $geoip_disable) are left intact. + self.assertEqual( + event["properties"], {"$geoip_disable": True, "custom": "keep"} + ) + + @parameterized.expand([("set", "$set"), ("set_once", "$set_once")]) + def test_top_level_set_relocated_into_properties(self, _name, key) -> None: + msg = _legacy_msg(properties={}, **{key: {"email": "a@b.com"}}) + event = _to_v1_event(msg) + self.assertEqual(event["properties"][key], {"email": "a@b.com"}) + self.assertNotIn(key, event) # not a top-level v1 field + + def test_top_level_set_merges_with_existing_properties_set(self) -> None: + msg = _legacy_msg( + properties={"$set": {"a": "from_props", "b": "props_only"}}, + **{"$set": {"a": "from_top", "c": "top_only"}}, + ) + event = _to_v1_event(msg) + self.assertEqual( + event["properties"]["$set"], + {"a": "from_top", "b": "props_only", "c": "top_only"}, + ) + + def test_groups_left_in_properties(self) -> None: + event = _to_v1_event(_legacy_msg(properties={"$groups": {"company": "ph"}})) + self.assertEqual(event["properties"]["$groups"], {"company": "ph"}) + + def test_timestamp_naive_datetime_made_tz_aware(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp=datetime(2026, 6, 27, 12, 0, 0))) + parsed = datetime.fromisoformat(event["timestamp"]) + self.assertIsNotNone(parsed.tzinfo) + + def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event( + _legacy_msg( + timestamp=datetime( + 2026, + 6, + 27, + 17, + 45, + tzinfo=timezone(timedelta(hours=5, minutes=45)), + ) + ) + ) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) + self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") + + def test_timestamp_none_defaults_to_utc_now(self) -> None: + event = _to_v1_event(_legacy_msg(timestamp=None)) + parsed = datetime.fromisoformat(event["timestamp"]) + self.assertEqual(parsed.tzinfo, timezone.utc) + + +class TestBuildV1BatchBody(unittest.TestCase): + def test_envelope_shape_and_no_legacy_fields(self) -> None: + events = [{"event": "e"}] + body = _build_v1_batch_body(events) + self.assertEqual(body["batch"], events) + self.assertNotIn("api_key", body) + self.assertNotIn("sent_at", body) + + def test_created_at_is_tz_aware_rfc3339(self) -> None: + body = _build_v1_batch_body([]) + parsed = datetime.fromisoformat(body["created_at"]) + self.assertIsNotNone(parsed.tzinfo) + + def test_created_at_passthrough_used_verbatim(self) -> None: + # _send_v1_batch hoists created_at and passes it in so it stays stable + # across retry attempts. + body = _build_v1_batch_body([], created_at="2026-06-27T12:00:00+00:00") + self.assertEqual(body["created_at"], "2026-06-27T12:00:00+00:00") + + def test_historical_migration_omitted_when_false(self) -> None: + self.assertNotIn("historical_migration", _build_v1_batch_body([])) + + def test_historical_migration_present_when_true(self) -> None: + body = _build_v1_batch_body([], historical_migration=True) + self.assertIs(body["historical_migration"], True) diff --git a/posthog/test/test_capture_invariants.py b/posthog/test/test_capture_invariants.py new file mode 100644 index 000000000..48f933bee --- /dev/null +++ b/posthog/test/test_capture_invariants.py @@ -0,0 +1,111 @@ +import json +from typing import Any, Optional +from unittest import mock + +import pytest +from requests import Response + +from posthog import AsyncPosthog, CaptureCompression, Client +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH + +_UUID = "01890000-0000-7000-8000-000000000001" +_TIMESTAMP = "2026-01-02T03:04:05+00:00" + + +def _event_kwargs() -> dict[str, Any]: + return { + "distinct_id": "user-1", + "uuid": _UUID, + "timestamp": _TIMESTAMP, + "properties": { + "$process_person_profile": False, + "$product_tour_id": "tour-1", + "empty_list": [], + "empty_map": {}, + "none": None, + "nested": {"a": {"b": [1, None, {}]}}, + }, + "options": { + "process_person_profile": None, + "cookieless_mode": False, + "custom_list": [], + "custom_map": {"inner": {}}, + }, + } + + +def _record_wire(sent: list[tuple[str, list[dict]]]): + def post(url, data=None, **kwargs): + events = json.loads(data)["batch"] + sent.append((url, events)) + response = Response() + response.status_code = 200 + response._content = json.dumps( + {"results": {event["uuid"]: {"result": "ok"} for event in events}} + ).encode() + return response + + session = mock.Mock() + session.post.side_effect = post + return mock.patch("posthog.capture_send._get_session", return_value=session) + + +def _send_sync(method_name: str, before_send) -> list[tuple[str, list[dict]]]: + sent: list[tuple[str, list[dict]]] = [] + with _record_wire(sent): + client = Client( + "test-key", + sync_mode=True, + before_send=before_send, + capture_compression=CaptureCompression.NONE, + ) + assert getattr(client, method_name)("$ai_generation", **_event_kwargs()) + client.shutdown() + return sent + + +async def _send_async(method_name: str, before_send) -> list[tuple[str, list[dict]]]: + sent: list[tuple[str, list[dict]]] = [] + with _record_wire(sent): + client = AsyncPosthog( + "test-key", + before_send=before_send, + capture_compression=CaptureCompression.NONE, + ) + assert await getattr(client, method_name)("$ai_generation", **_event_kwargs()) + await client.shutdown() + return sent + + +def _pass_through(event: dict) -> Optional[dict]: + return event + + +@pytest.mark.parametrize( + ("method_name", "path"), + [("capture", _CAPTURE_V1_PATH), ("capture_ai", _CAPTURE_AI_V1_PATH)], +) +def test_pass_through_before_send_leaves_sync_wire_unchanged(method_name, path): + without_hook = _send_sync(method_name, None) + with_hook = _send_sync(method_name, _pass_through) + + assert [url.endswith(path) for url, _ in without_hook] == [True] + assert with_hook == without_hook + assert without_hook[0][1][0]["options"]["process_person_profile"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "path"), + [ + ("capture_immediate", _CAPTURE_V1_PATH), + ("capture_ai_immediate", _CAPTURE_AI_V1_PATH), + ], +) +async def test_pass_through_before_send_leaves_async_wire_unchanged(method_name, path): + without_hook = await _send_async(method_name, None) + with_hook = await _send_async(method_name, _pass_through) + + assert [url.endswith(path) for url, _ in without_hook] == [True] + assert with_hook == without_hook + assert without_hook[0][1][0]["options"]["process_person_profile"] is False diff --git a/posthog/test/test_capture_mode.py b/posthog/test/test_capture_mode.py deleted file mode 100644 index 473e1a886..000000000 --- a/posthog/test/test_capture_mode.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import unittest -from unittest import mock - -from parameterized import parameterized - -from posthog.capture_mode import ( - CAPTURE_MODE_ENV_VAR, - CaptureMode, - _resolve_capture_mode, -) -from posthog.client import Client -from posthog.consumer import Consumer -from posthog.test.logging_helpers import capture_message_only_logs -from posthog.test.test_utils import TEST_API_KEY - - -class TestResolveCaptureMode(unittest.TestCase): - def test_defaults_to_v0_with_no_kwarg_and_no_env(self) -> None: - with mock.patch.dict(os.environ, {}, clear=False): - os.environ.pop(CAPTURE_MODE_ENV_VAR, None) - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - - @parameterized.expand( - [ - # (name, kwarg, expected, opposite_env): the env always names the - # mode the kwarg must override, so every row proves the kwarg wins. - ("enum_v0", CaptureMode.V0, CaptureMode.V0, "v1"), - ("enum_v1", CaptureMode.V1, CaptureMode.V1, "v0"), - ("str_v0", "v0", CaptureMode.V0, "v1"), - ("str_v1", "v1", CaptureMode.V1, "v0"), - ("str_legacy_alias", "legacy", CaptureMode.V0, "v1"), - ("str_analytics_v1_alias", "analytics_v1", CaptureMode.V1, "v0"), - ("str_upper_and_padded", " V1 ", CaptureMode.V1, "v0"), - ] - ) - def test_explicit_kwarg_takes_precedence_and_coerces( - self, _name, kwarg, expected, opposite_env - ) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: opposite_env}): - self.assertIs(_resolve_capture_mode(kwarg), expected) - - def test_invalid_kwarg_raises_even_with_valid_env(self) -> None: - # The kwarg path is consulted before the env, so an invalid kwarg raises - # rather than silently falling back to a valid env value. - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: "v1"}): - with self.assertRaises(ValueError): - _resolve_capture_mode("bogus") - - @parameterized.expand( - [ - ("v0", "v0", CaptureMode.V0), - ("legacy", "legacy", CaptureMode.V0), - ("v1", "v1", CaptureMode.V1), - ("analytics_v1", "analytics_v1", CaptureMode.V1), - ("uppercase", "V1", CaptureMode.V1), - ("padded", " v1 ", CaptureMode.V1), - ] - ) - def test_env_var_resolution(self, _name, env_value, expected) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: env_value}): - self.assertIs(_resolve_capture_mode(None), expected) - - @parameterized.expand([("empty", ""), ("whitespace", " ")]) - def test_blank_env_var_defaults_to_v0(self, _name, env_value) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: env_value}): - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - - def test_unrecognized_env_var_warns_and_defaults_to_v0(self) -> None: - with mock.patch.dict(os.environ, {CAPTURE_MODE_ENV_VAR: "bogus"}): - with capture_message_only_logs() as stream: - self.assertIs(_resolve_capture_mode(None), CaptureMode.V0) - self.assertIn("bogus", stream.getvalue()) - - @parameterized.expand([("bad_str", "bogus"), ("wrong_type", 1)]) - def test_invalid_explicit_kwarg_raises(self, _name, value) -> None: - with self.assertRaises(ValueError): - _resolve_capture_mode(value) - - -class TestCaptureModePlumbing(unittest.TestCase): - def test_client_resolves_and_stores_default_v0(self) -> None: - with mock.patch.dict(os.environ, {}, clear=False): - os.environ.pop(CAPTURE_MODE_ENV_VAR, None) - client = Client(TEST_API_KEY, sync_mode=True) - self.assertIs(client.capture_mode, CaptureMode.V0) - - @parameterized.expand( - [ - ("enum_v1", CaptureMode.V1, CaptureMode.V1), - ("str_v1", "v1", CaptureMode.V1), - ("enum_v0", CaptureMode.V0, CaptureMode.V0), - ] - ) - def test_client_kwarg_sets_mode(self, _name, kwarg, expected) -> None: - client = Client(TEST_API_KEY, sync_mode=True, capture_mode=kwarg) - self.assertIs(client.capture_mode, expected) - - def test_client_propagates_mode_to_consumers(self) -> None: - # Async (non-sync) client builds Consumer threads; assert each carries - # the resolved mode. - client = Client(TEST_API_KEY, capture_mode=CaptureMode.V1, send=False, thread=2) - self.assertEqual(len(client.consumers), 2) - for consumer in client.consumers: - self.assertIs(consumer.capture_mode, CaptureMode.V1) - - def test_consumer_defaults_to_v0(self) -> None: - consumer = Consumer(None, TEST_API_KEY) - self.assertIs(consumer.capture_mode, CaptureMode.V0) diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_send.py similarity index 59% rename from posthog/test/test_capture_v1.py rename to posthog/test/test_capture_send.py index f4809d501..a2da43d72 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_send.py @@ -1,30 +1,32 @@ import json import unittest import zlib -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from unittest import mock +from uuid import UUID import zstandard from parameterized import parameterized +from posthog import capture_send from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import ( +from posthog.capture_event import _build_v1_batch_body, _to_v1_event +from posthog.capture_send import ( + _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH, _HEADER_ATTEMPT, _HEADER_REQUEST_ID, _HEADER_REQUEST_TIMESTAMP, _HEADER_SDK_INFO, _MAX_BACKOFF_SECONDS, - CaptureV1Error, - _build_v1_batch_body, + CaptureError, + CaptureEventResult, + _capture_loss_message, _parse_v1_response, _post_v1, _send_v1_batch, - _to_v1_event, _backoff, - _coerce_bool, - _coerce_str, ) from posthog.request import USER_AGENT @@ -48,17 +50,23 @@ def json(self): class _RecordingSession: - """Captures the args of a single ``.post`` and returns a canned response.""" + """Records each ``.post`` and returns canned responses, repeating the last.""" - def __init__(self, response): - self._response = response + def __init__(self, *responses): + self._responses = list(responses) self.calls = [] - def post(self, url, data=None, headers=None, timeout=None): + def post(self, url, data=None, headers=None, timeout=None, allow_redirects=True): self.calls.append( - {"url": url, "data": data, "headers": headers, "timeout": timeout} + { + "url": url, + "data": data, + "headers": headers, + "timeout": timeout, + "allow_redirects": allow_redirects, + } ) - return self._response + return self._responses[min(len(self.calls), len(self._responses)) - 1] class _PostV1Stub: @@ -84,9 +92,11 @@ def __call__( timeout=15, sdk_info=USER_AGENT, session=None, + path=_CAPTURE_V1_PATH, ): self.calls.append( { + "path": path, "attempt": attempt, "request_id": request_id, "compression": compression, @@ -127,271 +137,6 @@ def _results_response(directives, headers=None): return _FakeResponse(200, json_body={"results": results}, headers=headers) -def _legacy_msg(event="my_event", properties=None, **overrides) -> dict: - """Minimal legacy-shaped message as it looks coming off the queue.""" - msg = { - "event": event, - "uuid": "0190000000007000800000000000000a", - "distinct_id": "user-1", - "timestamp": "2026-06-27T12:00:00+00:00", - "type": "capture", - "properties": {"$lib": "posthog-python", "$lib_version": "9.9.9"}, - } - if properties is not None: - msg["properties"] = properties - msg.update(overrides) - return msg - - -class TestCoercion(unittest.TestCase): - @parameterized.expand( - [ - ("bool_true", True, True), - ("bool_false", False, False), - ("str_true", "true", True), - ("str_true_upper", "TRUE", True), - ("str_true_padded", " true ", True), - ("str_one", "1", True), - ("str_false", "false", False), - ("str_zero", "0", False), - ("int_nonzero", 5, True), - ("int_zero", 0, False), - ("float_nonzero", 1.5, True), - ("float_zero", 0.0, False), - ("neg_int", -1, True), - ("str_yes_uncoercible", "yes", None), - ("str_empty_uncoercible", "", None), - ("none_uncoercible", None, None), - ("dict_uncoercible", {"a": 1}, None), - ] - ) - def test_coerce_bool(self, _name, value, expected) -> None: - self.assertIs(_coerce_bool(value), expected) - - @parameterized.expand( - [ - ("str", "tour-1", "tour-1"), - ("empty_str", "", ""), - ("int", 123, None), - ("bool", True, None), - ("none", None, None), - ] - ) - def test_coerce_str(self, _name, value, expected) -> None: - self.assertEqual(_coerce_str(value), expected) - - -class TestToV1Event(unittest.TestCase): - def test_required_fields_preserved(self) -> None: - event = _to_v1_event(_legacy_msg(event="signed_up")) - self.assertEqual(event["event"], "signed_up") - self.assertEqual(event["uuid"], "0190000000007000800000000000000a") - self.assertEqual(event["distinct_id"], "user-1") - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_strips_lib_and_lib_version(self) -> None: - event = _to_v1_event(_legacy_msg()) - self.assertNotIn("$lib", event["properties"]) - self.assertNotIn("$lib_version", event["properties"]) - - def test_options_empty_dict_when_no_sentinels(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"plain": "value"})) - self.assertEqual(event["options"], {}) - self.assertEqual(event["properties"], {"plain": "value"}) - - def test_does_not_leak_non_wire_top_level_keys(self) -> None: - event = _to_v1_event(_legacy_msg()) - # `type` is legacy-only; the v1 event carries only documented fields. - self.assertEqual( - set(event), - {"event", "uuid", "distinct_id", "timestamp", "options", "properties"}, - ) - - def test_does_not_mutate_input(self) -> None: - msg = _legacy_msg( - properties={"$cookieless_mode": True, "$session_id": "s-1"}, - **{"$set": {"name": "Max"}}, - ) - original_properties = dict(msg["properties"]) - _to_v1_event(msg) - self.assertEqual(msg["properties"], original_properties) - self.assertIn("$set", msg) # top-level $set untouched on the original - - @parameterized.expand( - [ - ("cookieless_mode", "$cookieless_mode", "cookieless_mode", True, True), - ( - "ignore_sent_at_rename", - "$ignore_sent_at", - "disable_skew_correction", - "true", - True, - ), - ( - "process_person_profile", - "$process_person_profile", - "process_person_profile", - "false", - False, - ), - ( - "product_tour_id", - "$product_tour_id", - "product_tour_id", - "tour-7", - "tour-7", - ), - ] - ) - def test_option_sentinels_lifted_renamed_and_coerced( - self, _name, prop_key, wire_key, raw, expected - ) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - self.assertEqual(event["options"], {wire_key: expected}) - self.assertNotIn(prop_key, event["properties"]) - - @parameterized.expand( - [ - ("bad_bool", "$cookieless_mode", "maybe"), - ("bad_tour_id_int", "$product_tour_id", 123), - ] - ) - def test_option_sentinel_removed_but_omitted_on_bad_coercion( - self, _name, prop_key, raw - ) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - # Removed from properties (sentinels must never reach v1 props) but not - # emitted as an option, so a wrong type cannot 400 the whole batch. - self.assertNotIn(prop_key, event["properties"]) - self.assertEqual(event["options"], {}) - - @parameterized.expand( - [ - ("session_id", "$session_id", "session_id", "s-123"), - ("window_id", "$window_id", "window_id", "w-456"), - ] - ) - def test_top_level_string_sentinels(self, _name, prop_key, field_name, raw) -> None: - event = _to_v1_event(_legacy_msg(properties={prop_key: raw})) - self.assertEqual(event[field_name], raw) - self.assertNotIn(prop_key, event["properties"]) - - def test_top_level_sentinel_omitted_but_removed_when_not_string(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"$session_id": 42})) - self.assertNotIn("session_id", event) - self.assertNotIn("$session_id", event["properties"]) - - def test_all_sentinels_together(self) -> None: - event = _to_v1_event( - _legacy_msg( - properties={ - "$cookieless_mode": True, - "$ignore_sent_at": "1", - "$product_tour_id": "tour-x", - "$process_person_profile": 0, - "$session_id": "s-1", - "$window_id": "w-1", - "$geoip_disable": True, - "custom": "keep", - } - ) - ) - self.assertEqual( - event["options"], - { - "cookieless_mode": True, - "disable_skew_correction": True, - "product_tour_id": "tour-x", - "process_person_profile": False, - }, - ) - self.assertEqual(event["session_id"], "s-1") - self.assertEqual(event["window_id"], "w-1") - # Non-sentinel props (including $geoip_disable) are left intact. - self.assertEqual( - event["properties"], {"$geoip_disable": True, "custom": "keep"} - ) - - @parameterized.expand([("set", "$set"), ("set_once", "$set_once")]) - def test_top_level_set_relocated_into_properties(self, _name, key) -> None: - msg = _legacy_msg(properties={}, **{key: {"email": "a@b.com"}}) - event = _to_v1_event(msg) - self.assertEqual(event["properties"][key], {"email": "a@b.com"}) - self.assertNotIn(key, event) # not a top-level v1 field - - def test_top_level_set_merges_with_existing_properties_set(self) -> None: - # properties wins on key collision. - msg = _legacy_msg( - properties={"$set": {"a": "from_props", "b": "props_only"}}, - **{"$set": {"a": "from_top", "c": "top_only"}}, - ) - event = _to_v1_event(msg) - self.assertEqual( - event["properties"]["$set"], - {"a": "from_props", "b": "props_only", "c": "top_only"}, - ) - - def test_groups_left_in_properties(self) -> None: - event = _to_v1_event(_legacy_msg(properties={"$groups": {"company": "ph"}})) - self.assertEqual(event["properties"]["$groups"], {"company": "ph"}) - - def test_timestamp_naive_datetime_made_tz_aware(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp=datetime(2026, 6, 27, 12, 0, 0))) - parsed = datetime.fromisoformat(event["timestamp"]) - self.assertIsNotNone(parsed.tzinfo) - - def test_timestamp_aware_datetime_converted_to_exact_utc_instant(self) -> None: - event = _to_v1_event( - _legacy_msg( - timestamp=datetime( - 2026, - 6, - 27, - 17, - 45, - tzinfo=timezone(timedelta(hours=5, minutes=45)), - ) - ) - ) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_timestamp_parseable_string_converted_to_exact_utc_instant(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp="2026-06-27T17:45:00+05:45")) - self.assertEqual(event["timestamp"], "2026-06-27T12:00:00+00:00") - - def test_timestamp_none_defaults_to_utc_now(self) -> None: - event = _to_v1_event(_legacy_msg(timestamp=None)) - parsed = datetime.fromisoformat(event["timestamp"]) - self.assertEqual(parsed.tzinfo, timezone.utc) - - -class TestBuildV1BatchBody(unittest.TestCase): - def test_envelope_shape_and_no_legacy_fields(self) -> None: - events = [{"event": "e"}] - body = _build_v1_batch_body(events) - self.assertEqual(body["batch"], events) - self.assertNotIn("api_key", body) - self.assertNotIn("sent_at", body) - - def test_created_at_is_tz_aware_rfc3339(self) -> None: - body = _build_v1_batch_body([]) - parsed = datetime.fromisoformat(body["created_at"]) - self.assertIsNotNone(parsed.tzinfo) - - def test_created_at_passthrough_used_verbatim(self) -> None: - # _send_v1_batch hoists created_at and passes it in so it stays stable - # across retry attempts. - body = _build_v1_batch_body([], created_at="2026-06-27T12:00:00+00:00") - self.assertEqual(body["created_at"], "2026-06-27T12:00:00+00:00") - - def test_historical_migration_omitted_when_false(self) -> None: - self.assertNotIn("historical_migration", _build_v1_batch_body([])) - - def test_historical_migration_present_when_true(self) -> None: - body = _build_v1_batch_body([], historical_migration=True) - self.assertIs(body["historical_migration"], True) - - class TestPostV1(unittest.TestCase): def _post(self, response, **kwargs): session = _RecordingSession(response) @@ -410,6 +155,8 @@ def _post(self, response, **kwargs): def test_url_uses_v1_path_and_trims_host(self) -> None: call = self._post(_results_response({})) self.assertEqual(call["url"], "https://app.posthog.com" + _CAPTURE_V1_PATH) + call = self._post(_results_response({}), path=_CAPTURE_AI_V1_PATH) + self.assertEqual(call["url"], "https://app.posthog.com" + _CAPTURE_AI_V1_PATH) def test_required_headers_present(self) -> None: headers = self._post(_results_response({}))["headers"] @@ -422,11 +169,11 @@ def test_required_headers_present(self) -> None: self.assertEqual(request_timestamp.utcoffset(), timedelta(0)) def test_custom_sdk_info_headers(self) -> None: - headers = self._post( - _results_response({}), sdk_info="posthog-python-mcp/0.3.0" - )["headers"] - self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python-mcp/0.3.0") - self.assertEqual(headers["User-Agent"], "posthog-python-mcp/0.3.0") + headers = self._post(_results_response({}), sdk_info="posthog-python/9.9.9")[ + "headers" + ] + self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python/9.9.9") + self.assertEqual(headers["User-Agent"], "posthog-python/9.9.9") def test_no_api_key_in_body(self) -> None: # v1 authenticates via the Bearer header; the key must not leak into the body. @@ -467,11 +214,111 @@ def test_zstd_sets_encoding_header_and_emits_standard_frame(self) -> None: self.assertEqual(len(body["batch"]), 1) def test_zstd_without_package_raises_actionable_error(self) -> None: - with mock.patch("posthog.capture_v1._zstandard", None): + with mock.patch("posthog.capture_send._zstandard", None): with self.assertRaises(ValueError) as ctx: self._post(_results_response({}), compression=CaptureCompression.ZSTD) self.assertIn("posthog[zstd]", str(ctx.exception)) + @parameterized.expand( + [ + ( + "relative_location", + "https://us.i.posthog.com", + "/i/v1/analytics/events?retry=1", + "https://us.i.posthog.com/i/v1/analytics/events?retry=1", + ), + ( + "host_path_prefix", + "https://example.com/ingest", + "https://example.com/ingest/i/v1/analytics/events", + "https://example.com/ingest/i/v1/analytics/events", + ), + ( + "explicit_default_port", + "https://us.i.posthog.com", + "https://us.i.posthog.com:443/other", + "https://us.i.posthog.com/other", + ), + ( + "unicode_host_idna_location", + "https://bücher.example", + "https://xn--bcher-kva.example/ingest", + "https://bücher.example/ingest", + ), + ( + "idna_host_unicode_location", + "https://xn--bcher-kva.example", + "https://bücher.example/ingest", + "https://xn--bcher-kva.example/ingest", + ), + ] + ) + def test_follows_same_origin_redirect_with_same_body( + self, _name, host, location, expected_url + ) -> None: + final = _results_response({}) + session = _RecordingSession( + _FakeResponse(307, headers={"Location": location}), final + ) + body = _build_v1_batch_body([_to_v1_event(_msg("u-1"))]) + res = _post_v1( + "phc_key", host, body, attempt=1, request_id="r", session=session + ) + + self.assertIs(res, final) + self.assertEqual([c["url"] for c in session.calls][1], expected_url) + self.assertEqual(session.calls[0]["data"], session.calls[1]["data"]) + self.assertEqual(session.calls[0]["headers"], session.calls[1]["headers"]) + self.assertTrue(all(c["allow_redirects"] is False for c in session.calls)) + + @parameterized.expand( + [ + ("other_host", 307, "https://attacker.example.com/collect"), + ("other_unicode_host", 307, "https://bücher-andere.example/collect"), + ("loopback", 308, "http://127.0.0.1:8080/collect"), + ("https_to_http", 307, "http://us.i.posthog.com/i/v1/analytics/events"), + ("other_port", 308, "https://us.i.posthog.com:8443/i/v1/analytics/events"), + ("missing_location", 307, None), + ("not_307_or_308", 302, "/i/v1/analytics/events"), + ] + ) + def test_does_not_follow_other_redirects(self, _name, status, location) -> None: + redirect = _FakeResponse( + status, headers={"Location": location} if location else {} + ) + session = _RecordingSession(redirect, _results_response({})) + body = _build_v1_batch_body([_to_v1_event(_msg("u-1"))]) + # Failed-batch reporting owns warnings, so the transport logs nothing here. + with self.assertNoLogs("posthog", level="WARNING"): + res = _post_v1( + "phc_key", + "https://us.i.posthog.com", + body, + attempt=1, + request_id="r", + session=session, + ) + + self.assertIs(res, redirect) + self.assertEqual(len(session.calls), 1) + + def test_stops_after_max_redirects(self) -> None: + loop = _FakeResponse(307, headers={"Location": "/i/v1/analytics/events"}) + session = _RecordingSession(loop) + body = _build_v1_batch_body([_to_v1_event(_msg("u-1"))]) + with self.assertNoLogs("posthog", level="WARNING"): + res = _post_v1( + "phc_key", + "https://us.i.posthog.com", + body, + attempt=1, + request_id="r", + session=session, + ) + + self.assertIs(res, loop) + self.assertEqual(len(session.calls), 6) + class TestParseV1Response(unittest.TestCase): def test_success_parses_results_with_details(self) -> None: @@ -522,20 +369,21 @@ class TestSendV1Batch(unittest.TestCase): """Drives ``_send_v1_batch`` with a stubbed ``_post_v1`` and no real sleeps.""" def setUp(self) -> None: - sleep_patch = mock.patch("posthog.capture_v1.time.sleep") + sleep_patch = mock.patch("posthog.capture_send.time.sleep") self.sleep = sleep_patch.start() self.addCleanup(sleep_patch.stop) + _patch_no_jitter(self) def _run(self, batch, outcomes, **kwargs): stub = _PostV1Stub(outcomes) - with mock.patch("posthog.capture_v1._post_v1", stub): + with mock.patch("posthog.capture_send._post_v1", stub): _send_v1_batch("phc_key", "https://app.posthog.com", batch, **kwargs) return stub def _run_expecting_error(self, batch, outcomes, **kwargs): stub = _PostV1Stub(outcomes) - with mock.patch("posthog.capture_v1._post_v1", stub): - with self.assertRaises(CaptureV1Error) as ctx: + with mock.patch("posthog.capture_send._post_v1", stub): + with self.assertRaises(CaptureError) as ctx: _send_v1_batch("phc_key", "https://app.posthog.com", batch, **kwargs) return stub, ctx.exception @@ -548,9 +396,9 @@ def test_custom_sdk_info_is_forwarded(self) -> None: stub = self._run( [_msg("u-1")], [_results_response({"u-1": "ok"})], - sdk_info="posthog-python-mcp/0.3.0", + sdk_info="posthog-python/9.9.9", ) - self.assertEqual(stub.calls[0]["sdk_info"], "posthog-python-mcp/0.3.0") + self.assertEqual(stub.calls[0]["sdk_info"], "posthog-python/9.9.9") def test_absent_uuid_treated_as_accepted(self) -> None: # Empty results map: the event is neither retried nor errored. @@ -580,18 +428,21 @@ def test_request_id_and_created_at_stable_attempt_increments(self) -> None: ], ) self.assertEqual(stub.calls[0]["request_id"], stub.calls[1]["request_id"]) + self.assertEqual(UUID(stub.calls[0]["request_id"]).version, 7) # created_at is hoisted once, so the envelope timestamp is identical # across retry attempts (only the attempt header increments). self.assertEqual(stub.calls[0]["created_at"], stub.calls[1]["created_at"]) self.assertEqual([c["attempt"] for c in stub.calls], [1, 2]) - def test_compression_forwarded_to_post_v1(self) -> None: + def test_compression_and_path_forwarded_to_post_v1(self) -> None: stub = self._run( [_msg("u-1")], [_results_response({"u-1": "ok"})], compression=CaptureCompression.DEFLATE, + path=_CAPTURE_AI_V1_PATH, ) self.assertEqual(stub.calls[0]["compression"], CaptureCompression.DEFLATE) + self.assertEqual(stub.calls[0]["path"], _CAPTURE_AI_V1_PATH) def test_drop_on_2xx_surfaces_via_error(self) -> None: # A server-chosen drop is terminal: even on an all-ok-otherwise 2xx with @@ -623,13 +474,15 @@ def test_drop_accumulated_across_attempts_surfaces_on_later_success(self) -> Non stub, exc = self._run_expecting_error( batch, [ - _results_response({"u-drop": ("drop", "billing"), "u-retry": "retry"}), + _results_response( + {"u-drop": ("drop", "llm_events_over_quota"), "u-retry": "retry"} + ), _results_response({"u-retry": "ok"}), ], ) self.assertEqual(len(stub.calls), 2) self.assertEqual(stub.calls[1]["uuids"], ["u-retry"]) # only retry resent - self.assertEqual(exc.drops, [("u-drop", "billing")]) + self.assertEqual(exc.drops, [("u-drop", "llm_events_over_quota")]) self.assertEqual(exc.retry_exhausted, []) def test_retry_exhausted_raises_with_uuids(self) -> None: @@ -644,18 +497,58 @@ def test_retry_exhausted_raises_with_uuids(self) -> None: def test_retry_exhausted_carries_earlier_drops(self) -> None: # A drop seen on attempt 1 rides along on the retry-exhaustion error. - batch = [_msg("u-drop"), _msg("u-retry")] + batch = [_msg("u-ok"), _msg("u-drop"), _msg("u-retry")] stub, exc = self._run_expecting_error( batch, [ - _results_response({"u-drop": ("drop", "billing"), "u-retry": "retry"}), - _results_response({"u-retry": "retry"}), + _results_response( + { + "u-ok": "ok", + "u-drop": ("drop", "llm_events_over_quota"), + "u-retry": "retry", + } + ), + _results_response({"u-retry": ("retry", "not_persisted")}), ], max_retries=1, ) self.assertEqual(len(stub.calls), 2) + self.assertEqual(exc.endpoint, _CAPTURE_V1_PATH) self.assertEqual(exc.retry_exhausted, ["u-retry"]) - self.assertEqual(exc.drops, [("u-drop", "billing")]) + self.assertEqual(exc.drops, [("u-drop", "llm_events_over_quota")]) + self.assertEqual( + exc.event_results, + { + "u-ok": CaptureEventResult("ok"), + "u-drop": CaptureEventResult("drop", "llm_events_over_quota"), + "u-retry": CaptureEventResult("retry", "not_persisted"), + }, + ) + self.assertEqual( + exc.verdict_summary(), "drop/llm_events_over_quota=1, retry/not_persisted=1" + ) + + @parameterized.expand( + [ + ("uppercase", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"), + ("no_hyphens", "aaaaaaaaaaaa4aaa8aaaaaaaaaaaaaaa"), + ("braced", "{aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa}"), + ("urn", "urn:uuid:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ] + ) + def test_results_match_non_canonical_uuid(self, _name, sent_uuid) -> None: + # Capture parses any of these forms but keys results canonically, so a + # verdict for a uuid `before_send` rewrote must still reach its event. + canonical = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + stub, exc = self._run_expecting_error( + [_msg(sent_uuid)], + [ + _results_response({canonical: "retry"}), + _results_response({canonical: ("drop", "llm_events_over_quota")}), + ], + ) + self.assertEqual(len(stub.calls), 2) + self.assertEqual(exc.drops, [(canonical, "llm_events_over_quota")]) def test_malformed_2xx_is_terminal(self) -> None: stub, exc = self._run_expecting_error( @@ -664,7 +557,14 @@ def test_malformed_2xx_is_terminal(self) -> None: self.assertEqual(len(stub.calls), 1) self.assertEqual(exc.status, 200) - @parameterized.expand([("bad_request", 400), ("rate_limited", 429)]) + @parameterized.expand( + [ + ("bad_request", 400), + ("rate_limited", 429), + ("unfollowed_redirect", 307), + ("unfollowed_permanent_redirect", 308), + ] + ) def test_terminal_status_raises_immediately(self, _name, status) -> None: stub, exc = self._run_expecting_error( [_msg("u-1")], @@ -699,19 +599,27 @@ def test_transport_error_then_success(self) -> None: ) self.assertEqual(len(stub.calls), 2) - def test_transport_error_exhausted_reraises_original(self) -> None: + def test_transport_error_exhausted_raises_capture_error(self) -> None: stub = _PostV1Stub([ConnectionError("boom"), ConnectionError("boom")]) - with mock.patch("posthog.capture_v1._post_v1", stub): - with self.assertRaises(ConnectionError): + with mock.patch("posthog.capture_send._post_v1", stub): + with self.assertRaises(CaptureError) as ctx: _send_v1_batch( - "phc_key", "https://app.posthog.com", [_msg("u-1")], max_retries=1 + "phc_key", + "https://app.posthog.com", + [_msg("u-1")], + max_retries=1, + path=_CAPTURE_AI_V1_PATH, ) self.assertEqual(len(stub.calls), 2) + self.assertEqual(ctx.exception.status, 0) + self.assertEqual(ctx.exception.endpoint, _CAPTURE_AI_V1_PATH) + self.assertEqual(ctx.exception.attempts, 2) + self.assertIsInstance(ctx.exception.__cause__, ConnectionError) def test_negative_max_retries_still_attempts_delivery_once(self) -> None: stub = _PostV1Stub([_results_response({"u-1": "ok"})]) - with mock.patch("posthog.capture_v1._post_v1", stub): + with mock.patch("posthog.capture_send._post_v1", stub): _send_v1_batch( "phc_key", "https://app.posthog.com", [_msg("u-1")], max_retries=-1 ) @@ -722,41 +630,119 @@ def test_negative_max_retries_still_attempts_delivery_once(self) -> None: def test_small_retry_after_does_not_shorten_backoff(self) -> None: # A Retry-After smaller than the configured backoff must not make the # client retry earlier than its own schedule (Retry-After is a minimum). - # attempt_index=1 -> configured backoff 2s; Retry-After 0.5s is ignored. + # attempt_index=1 -> configured backoff 0.2s; Retry-After 0.1s is ignored. stub = self._run( [_msg("u-1")], [ _results_response({"u-1": "retry"}), - _results_response({"u-1": "retry"}, headers={"Retry-After": "0.5"}), + _results_response({"u-1": "retry"}, headers={"Retry-After": "0.1"}), _results_response({"u-1": "ok"}), ], max_retries=3, ) self.assertEqual(len(stub.calls), 3) - # First backoff (attempt_index 0) waits 1s; second (attempt_index 1) - # keeps the 2s configured backoff rather than the smaller 0.5s header. - self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [1, 2]) + # First backoff (attempt_index 0) waits 0.1s; second (attempt_index 1) + # keeps the 0.2s configured backoff rather than the smaller 0.1s header. + self.assertEqual([c.args[0] for c in self.sleep.call_args_list], [0.1, 0.2]) + + +class TestCaptureLossMessage(unittest.TestCase): + @parameterized.expand( + [ + ( + "partial_2xx", + CaptureError( + 200, + "2 event(s) not delivered", + endpoint=_CAPTURE_V1_PATH, + drops=[("u-drop", "llm_events_over_quota")], + retry_exhausted=["u-retry"], + event_results={ + "u-ok": CaptureEventResult("ok"), + "u-drop": CaptureEventResult("drop", "llm_events_over_quota"), + "u-retry": CaptureEventResult("retry", "not_persisted"), + }, + ), + 3, + "2 event(s) not persisted by /i/v1/analytics/events: 1 dropped, " + "1 out of retries (drop/llm_events_over_quota=1, retry/not_persisted=1)", + ), + ( + "request_failure_after_partial_success", + CaptureError( + 503, + "unavailable", + endpoint=_CAPTURE_AI_V1_PATH, + event_results={ + "u-ok": CaptureEventResult("warning"), + "u-retry": CaptureEventResult("retry"), + }, + ), + 2, + "1 event(s) not persisted by /i/v1/ai/events: CaptureError (status=503)", + ), + ( + "other_exception_uses_caller_endpoint", + ValueError("bad payload"), + 4, + "4 event(s) not persisted by /i/v1/analytics/events: ValueError", + ), + ] + ) + def test_message(self, _name, error, batch_size, expected) -> None: + self.assertEqual( + _capture_loss_message(error, batch_size, _CAPTURE_V1_PATH), expected + ) + + +def _patch_no_jitter(test: unittest.TestCase) -> None: + # 0.5 draws a jitter factor of exactly 1.0. + patch = mock.patch.object(capture_send.random, "random", return_value=0.5) + patch.start() + test.addCleanup(patch.stop) class TestBackoff(unittest.TestCase): """Directly exercises ``_backoff``'s Retry-After-as-minimum + cap policy.""" + def setUp(self) -> None: + _patch_no_jitter(self) + @parameterized.expand( [ # (attempt_index, retry_after, expected sleep seconds) - ("first_no_header", 0, None, 1), - ("second_no_header", 1, None, 2), + ("first_no_header", 0, None, 0.1), + ("second_no_header", 1, None, 0.2), ("exp_capped_at_30", 10, None, 30), - ("zero_header_uses_backoff", 0, 0, 1), + ("zero_header_uses_backoff", 0, 0, 0.1), ("larger_header_wins", 0, 5.0, 5.0), - ("smaller_header_ignored", 3, 2.0, 8), # configured 8 > 2.0 - ("equal_header_and_backoff", 0, 1.0, 1), + ("smaller_header_ignored", 3, 0.5, 0.8), # configured 0.8 > 0.5 + ("equal_header_and_backoff", 1, 0.2, 0.2), ("header_at_ceiling", 0, 30.0, 30), ("header_above_ceiling_clamped", 0, 120.0, _MAX_BACKOFF_SECONDS), ("absurd_header_clamped", 0, 10**9, _MAX_BACKOFF_SECONDS), ] ) def test_backoff(self, _name, attempt_index, retry_after, expected) -> None: - with mock.patch("posthog.capture_v1.time.sleep") as sleep: + with mock.patch("posthog.capture_send.time.sleep") as sleep: + _backoff(attempt_index, retry_after) + sleep.assert_called_once() + self.assertAlmostEqual(sleep.call_args.args[0], expected) + + @parameterized.expand( + [ + # (draw, attempt_index, retry_after, expected sleep seconds) + ("lowest_draw_shortens_by_a_quarter", 0.0, 1, None, 0.15), + ("highest_draw_lengthens_by_a_quarter", 1.0, 1, None, 0.25), + ("jittered_wait_still_capped", 1.0, 10, None, _MAX_BACKOFF_SECONDS), + ("jitter_never_shortens_retry_after", 0.0, 1, 0.2, 0.2), + ("jitter_can_lengthen_past_retry_after", 1.0, 1, 0.2, 0.25), + ] + ) + def test_jitter(self, _name, draw, attempt_index, retry_after, expected) -> None: + with ( + mock.patch.object(capture_send.random, "random", return_value=draw), + mock.patch("posthog.capture_send.time.sleep") as sleep, + ): _backoff(attempt_index, retry_after) - sleep.assert_called_once_with(expected) + self.assertAlmostEqual(sleep.call_args.args[0], expected) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 531bbf1c9..fda6a5290 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -18,14 +18,22 @@ import pytest from posthog.capture_compression import CaptureCompression +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.client import Client -from posthog.contexts import get_context_session_id, new_context, set_context_session +from posthog.contexts import ( + get_context_session_id, + identify_context, + new_context, + set_context_option, + set_context_session, +) from posthog.request import APIError, GetResponse from posthog.test.logging_helpers import capture_message_only_logs from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.types import FeatureFlag, FeatureFlagResult, LegacyFlagMetadata from posthog.version import VERSION from posthog.contexts import tag +from posthog.test.capture_helpers import patch_capture_send, sent_batch, sent_events # Legacy single-flag behavior remains covered here; warning emission itself is @@ -53,8 +61,8 @@ class TestClient(unittest.TestCase): @classmethod def setUpClass(cls): # This ensures no real HTTP POST requests are made - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() @@ -77,23 +85,32 @@ def test_requires_api_key(self): @parameterized.expand( [ - ("valid_key", " \nphc_validkey\t ", "phc_validkey", False, False), - ("whitespace_only", " \n\t ", "", True, True), - ("empty_string", "", "", True, True), + ("valid_key", " \nphc_validkey\t ", "phc_validkey", False, None), + ("whitespace_only", " \n\t ", "", True, "api_key is empty"), + ("empty_string", "", "", True, "api_key is empty"), + ( + "personal_api_key", + " phx_secret ", + "phx_secret", + True, + "personal API key", + ), ] ) def test_trims_api_key_whitespace( - self, _, raw_api_key, expected_api_key, expected_disabled, expect_error_log + self, _, raw_api_key, expected_api_key, expected_disabled, expected_error ): with mock.patch.object(Client.log, "error") as mock_error: client = Client(raw_api_key, send=False) self.assertEqual(client.api_key, expected_api_key) self.assertEqual(client.disabled, expected_disabled) - if expect_error_log: - mock_error.assert_called_once_with( - "api_key is empty after trimming whitespace; check your project API key" - ) + if expected_error: + mock_error.assert_called_once() + message = " ".join(str(arg) for arg in mock_error.call_args.args) + self.assertIn(expected_error, message) + self.assertNotIn("secret", message) + self.assertIsNone(client.capture("event", distinct_id="user")) else: mock_error.assert_not_called() @@ -292,7 +309,7 @@ def test_empty_flush(self): self.client.flush() def test_empty_flush_does_not_drain_a_later_event(self): - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_at=100, flush_interval=0.5) client.flush() @@ -321,7 +338,7 @@ def test_flush_timeout_returns_when_queue_does_not_drain(self): def test_flush_does_not_wait_for_flush_interval(self): # flush() must attempt delivery now rather than letting the consumer sit # on a below-flush_at batch until flush_interval elapses. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_interval=30) client.capture("event", distinct_id="distinct_id") @@ -335,7 +352,7 @@ def test_flush_does_not_wait_for_flush_interval(self): def test_flush_delivers_when_flush_interval_exceeds_the_flush_timeout(self): # Waiting out flush_interval meant a flush_interval longer than the # flush timeout delivered nothing at all. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_interval=30) client.capture("event", distinct_id="distinct_id") @@ -346,7 +363,7 @@ def test_flush_delivers_when_flush_interval_exceeds_the_flush_timeout(self): def test_flush_keeps_batches_whole(self): # Draining early must not turn a full queue into one request per event. - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: client = Client(FAKE_TEST_API_KEY, flush_at=10, flush_interval=30) for _ in range(30): client.capture("event", distinct_id="distinct_id") @@ -355,7 +372,8 @@ def test_flush_keeps_batches_whole(self): self.assertTrue(client.queue.empty()) batch_sizes = [ - len(call.kwargs["batch"]) for call in mock_post.call_args_list + len(sent_batch(mock_post, index)) + for index in range(mock_post.call_count) ] self.assertEqual(sum(batch_sizes), 30) self.assertLessEqual(len(batch_sizes), 5) @@ -378,7 +396,7 @@ def test_flush_logs_and_returns_on_unexpected_error(self): client.queue.task_done() def test_basic_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture("python test event", distinct_id="distinct_id") self.assertIsNotNone(msg_uuid) @@ -386,7 +404,7 @@ def test_basic_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -403,7 +421,7 @@ def test_basic_capture(self): assert msg["properties"]["$os_version"] == mock.ANY def test_capture_omits_is_server_when_disabled(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -413,26 +431,12 @@ def test_capture_omits_is_server_when_disabled(self): client.capture("python test event", distinct_id="distinct_id") self.assertFalse(self.failed) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertNotIn("$is_server", msg["properties"]) - def test_is_server_not_overridden_by_super_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: - client = Client( - FAKE_TEST_API_KEY, - on_error=self.set_fail, - sync_mode=True, - super_properties={"$is_server": False}, - ) - client.capture("python test event", distinct_id="distinct_id") - self.assertFalse(self.failed) - - msg = mock_post.call_args[1]["batch"][0] - self.assertEqual(msg["properties"]["$is_server"], True) - def test_basic_capture_with_uuid(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) uuid = str(uuid4()) msg_uuid = client.capture( @@ -443,7 +447,7 @@ def test_basic_capture_with_uuid(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -454,7 +458,7 @@ def test_basic_capture_with_uuid(self): self.assertEqual(msg["properties"]["$lib_version"], VERSION) def test_basic_capture_with_uuid_object(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) uuid = UUID("00000000-0000-4000-8000-000000000002") msg_uuid = client.capture( @@ -464,54 +468,77 @@ def test_basic_capture_with_uuid_object(self): self.assertFalse(self.failed) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["uuid"], str(uuid)) @parameterized.expand( [ - ("empty string", ""), - ("invalid string", "not-a-uuid"), + ("uppercase", "0190A3B4-C5D6-7E8F-9A0B-1C2D3E4F5A6B"), + ("no hyphens", "0190a3b4c5d67e8f9a0b1c2d3e4f5a6b"), + ("braced", "{0190a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b}"), + ] + ) + def test_capture_sends_and_returns_canonical_uuid(self, _name, supplied): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + msg_uuid = client.capture( + "python test event", distinct_id="distinct_id", uuid=supplied + ) + + canonical = "0190a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b" + self.assertEqual(msg_uuid, canonical) + self.assertEqual(sent_batch(mock_post)[0]["uuid"], canonical) + + @parameterized.expand( + [ + ("invalid string", "not-a-uuid-secret-1"), ("short string", "1234"), ("integer", 123), ] ) - def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid( + def test_capture_with_invalid_uuid_warns_and_falls_back_to_generated_uuid( self, _name, invalid_uuid ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) - with self.assertLogs("posthog", level="ERROR") as logs: + with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.capture( "python test event", distinct_id="distinct_id", uuid=invalid_uuid ) self.assertIsNotNone(msg_uuid) - UUID(msg_uuid) + self.assertEqual(UUID(msg_uuid).version, 7) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["uuid"], msg_uuid) self.assertNotEqual(msg["uuid"], str(invalid_uuid)) - self.assertTrue( - any( - f"Invalid event uuid {invalid_uuid!r}" in message - and "Expected a valid UUID string or uuid.UUID instance" in message - and "Falling back to a generated UUID" in message - for message in logs.output + uuid_warnings = [r for r in logs.records if "Event uuid" in r.getMessage()] + self.assertEqual(len(uuid_warnings), 1) + self.assertEqual(uuid_warnings[0].levelname, "WARNING") + self.assertNotIn(str(invalid_uuid), uuid_warnings[0].getMessage()) + + def test_capture_with_empty_uuid_generates_one_without_logging(self): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + with self.assertNoLogs("posthog", level="WARNING"): + msg_uuid = client.capture( + "python test event", distinct_id="distinct_id", uuid="" ) - ) + + self.assertEqual(UUID(msg_uuid).version, 7) + self.assertEqual(sent_batch(mock_post)[0]["uuid"], msg_uuid) @parameterized.expand( [ - ("empty string", ""), ("invalid string", "not-a-uuid"), ("short string", "1234"), ("integer", 123), ] ) def test_capture_with_invalid_uuid_falls_back_in_debug(self, _name, invalid_uuid): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, debug=True, sync_mode=True) - with self.assertLogs("posthog", level="ERROR"): + with self.assertLogs("posthog", level="WARNING"): msg_uuid = client.capture( "python test event", distinct_id="distinct_id", uuid=invalid_uuid ) @@ -521,7 +548,7 @@ def test_capture_with_invalid_uuid_falls_back_in_debug(self, _name, invalid_uuid mock_post.assert_called_once() def test_basic_capture_with_project_api_key(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -534,7 +561,7 @@ def test_basic_capture_with_project_api_key(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -544,7 +571,7 @@ def test_basic_capture_with_project_api_key(self): self.assertEqual(msg["properties"]["$lib_version"], VERSION) def test_basic_super_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, super_properties={"source": "repo-name"}, @@ -556,7 +583,7 @@ def test_basic_super_properties(self): # Check the enqueued message mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -573,6 +600,48 @@ def test_basic_capture_exception(self): self.assertEqual(capture_call[0][0], "$exception") self.assertEqual(capture_call[1]["distinct_id"], "distinct_id") + def test_capture_exception_ignores_reserved_properties(self): + properties = { + "$exception_list": [{"type": "CustomError", "value": "custom"}], + "$exception_level": "warning", + "$exception_source": "custom.source", + "$exception_issue_id": "custom-issue-id", + "plan": "pro", + } + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + self.client.capture_exception( + Exception("test exception"), properties=properties + ) + + captured_properties = patch_capture.call_args.kwargs["properties"] + self.assertEqual(captured_properties["$exception_list"][0]["type"], "Exception") + self.assertEqual(captured_properties["$exception_level"], "error") + self.assertNotIn("$exception_source", captured_properties) + self.assertNotIn("$exception_issue_id", captured_properties) + self.assertEqual(captured_properties["plan"], "pro") + + @parameterized.expand( + [ + ("default", None, None, "error"), + ("caller_level", None, "warning", "warning"), + ("alias", None, "WARN", "warning"), + ("unknown_level", None, "loud", "error"), + ("integration_level_wins", "fatal", "warning", "fatal"), + ] + ) + def test_capture_exception_level(self, _name, integration_level, level, expected): + capture_metadata = {"level": integration_level} if integration_level else {} + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + self.client.capture_exception( + Exception("test exception"), + level=level, + _capture_metadata=capture_metadata, + ) + + captured_properties = patch_capture.call_args.kwargs["properties"] + self.assertEqual(captured_properties["$exception_level"], expected) + @parameterized.expand( [ ( @@ -611,13 +680,13 @@ def test_capture_exception_uses_current_otel_span_context( ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client(FAKE_TEST_API_KEY, sync_mode=True) client.capture_exception(Exception("test exception"), properties=properties) - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] if expected_trace_id is None: self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -685,7 +754,7 @@ def test_capture_uses_current_otel_span_context_when_enabled( ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client( @@ -694,7 +763,7 @@ def test_capture_uses_current_otel_span_context_when_enabled( capture = getattr(client, entrypoint) capture("$ai_event", distinct_id="distinct_id", properties=properties) - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] if expected_trace_id is None: self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -712,14 +781,14 @@ def test_capture_does_not_attach_otel_span_context_by_default(self, entrypoint): ) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, use_span(NonRecordingSpan(span_context)), ): client = Client(FAKE_TEST_API_KEY, sync_mode=True) capture = getattr(client, entrypoint) capture("$ai_event", distinct_id="distinct_id") - event = mock_post.call_args.kwargs["batch"][0] + event = sent_batch(mock_post)[0] self.assertNotIn("$trace_id", event["properties"]) self.assertNotIn("$span_id", event["properties"]) @@ -856,7 +925,7 @@ def test_capture_exception_logs_when_enabled(self): def test_basic_capture_with_feature_flags(self, patch_flags): patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -871,7 +940,7 @@ def test_basic_capture_with_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -979,7 +1048,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -996,7 +1065,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1017,7 +1086,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): self.assertEqual(patch_flags.call_count, 0) # test that flags are not evaluated without local evaluation - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1031,7 +1100,7 @@ def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] assert "$feature/beta-feature" not in msg["properties"] @@ -1149,7 +1218,7 @@ def test_dont_override_capture_with_local_flags(self, patch_flags): }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1169,7 +1238,7 @@ def test_dont_override_capture_with_local_flags(self, patch_flags): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1199,7 +1268,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1214,7 +1283,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1283,7 +1352,7 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, host="https://app.posthog.com", @@ -1304,13 +1373,13 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") self.assertTrue(isinstance(msg["timestamp"], str)) self.assertIsNotNone(msg.get("uuid")) - self.assertTrue("$geoip_disable" not in msg["properties"]) + self.assertIs(msg["properties"]["$geoip_disable"], False) self.assertEqual(msg["distinct_id"], "distinct_id") self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertEqual(msg["properties"]["$lib_version"], VERSION) @@ -1343,7 +1412,7 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them( ): patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1358,7 +1427,7 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1421,7 +1490,7 @@ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_ }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1440,7 +1509,7 @@ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_ # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1503,7 +1572,7 @@ def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_fl }, } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1522,7 +1591,7 @@ def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_fl # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -1549,7 +1618,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_true( self, patch_flags ): """Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation""" - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1591,7 +1660,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_true( # Check the message includes the local flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/local-flag"], True) @@ -1604,7 +1673,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_false( """Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation""" patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1638,7 +1707,7 @@ def test_capture_with_send_feature_flags_options_only_evaluate_locally_false( # Check the message includes the remote flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value") @@ -1650,7 +1719,7 @@ def test_capture_with_send_feature_flags_options_default_behavior( """Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation""" patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1676,7 +1745,7 @@ def test_capture_with_send_feature_flags_options_default_behavior( # Check the message includes the flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -1688,7 +1757,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): """Test that capture_exception also supports SendFeatureFlagsOptions""" patch_flags.return_value = {"featureFlags": {"exception-flag": True}} - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -1718,7 +1787,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): # Check the message includes the flag mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$exception") @@ -1727,7 +1796,7 @@ def test_capture_exception_with_send_feature_flags_options(self, patch_flags): def test_stringifies_distinct_id(self): # A large number that loses precision in node: # node -e "console.log(157963456373623802 + 1)" > 157963456373623800 - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "python test event", distinct_id=157963456373623802 @@ -1737,13 +1806,13 @@ def test_stringifies_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "157963456373623802") def test_advanced_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "python test event", @@ -1757,7 +1826,7 @@ def test_advanced_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1770,7 +1839,7 @@ def test_advanced_capture(self): self.assertTrue("$groups" not in msg["properties"]) def test_capture_converts_aware_timestamp_to_utc_without_changing_instant(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1780,11 +1849,11 @@ def test_capture_converts_aware_timestamp_to_utc_without_changing_instant(self): ), ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") def test_capture_converts_parseable_timestamp_string_to_utc(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1792,14 +1861,14 @@ def test_capture_converts_parseable_timestamp_string_to_utc(self): timestamp="2014-09-03T05:30:00+05:30", ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @parameterized.expand(["2026-06-27", "not-an-iso-timestamp"]) def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestamp): now = datetime(2026, 6, 27, 12, 30, tzinfo=timezone.utc) with ( - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch("posthog.client.datetime", wraps=datetime) as mock_datetime, mock.patch.object(Client.log, "warning") as mock_warning, ): @@ -1812,17 +1881,83 @@ def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestam ) self.assertIsNotNone(result) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2026-06-27T12:30:00+00:00") - mock_warning.assert_called_once_with( - "Invalid timestamp %r. Falling back to the current UTC time.", timestamp + mock_warning.assert_called_once() + self.assertNotIn(timestamp, repr(mock_warning.call_args)) + + @parameterized.expand( + [ + ("missing", None, None), + ("non_string", 123, None), + ("empty", "", None), + ("set_by_before_send", "valid", 123), + ] + ) + def test_capture_drops_an_invalid_event_name_and_logs_once( + self, _, event, hook_event + ): + def before_send(msg): + if hook_event is not None: + msg["event"] = hook_event + return msg + + with ( + patch_capture_send("client") as mock_post, + mock.patch("posthog.utils._warned_reasons", set()), + mock.patch.object(Client.log, "warning") as mock_warning, + ): + client = Client(FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send) + results = [client.capture(event, distinct_id="user") for _ in range(2)] + + self.assertEqual(results, [None, None]) + mock_post.assert_not_called() + mock_warning.assert_called_once() + + @parameterized.expand( + [ + ("int", 123), + ("float", 1.5), + ("none", None), + ("list", ["user"]), + ("dict", {"id": "user"}), + ] + ) + def test_capture_drops_an_event_whose_before_send_distinct_id_is_not_a_string( + self, _, bad_id + ): + def before_send(msg): + if msg["event"] == "bad": + msg["distinct_id"] = bad_id + return msg + + with ( + patch_capture_send("client") as mock_post, + mock.patch("posthog.utils._warned_reasons", set()), + mock.patch.object(Client.log, "warning") as mock_warning, + ): + client = Client(FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send) + results = [ + client.capture(event, distinct_id="user") + for event in ("bad", "good", "bad") + ] + + self.assertIsNone(results[0]) + self.assertIsNotNone(results[1]) + self.assertIsNone(results[2]) + self.assertEqual( + [(m["event"], m["distinct_id"]) for m in sent_batch(mock_post)], + [("good", "user")], ) + mock_warning.assert_called_once() + self.assertIn("distinct IDs must be strings", mock_warning.call_args[0][0]) + self.assertNotIn(repr(bad_id), repr(mock_warning.call_args)) def test_capture_does_not_normalize_datetime_properties(self): property_value = datetime( 2014, 9, 3, 5, 30, tzinfo=timezone(timedelta(hours=5, minutes=30)) ) - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) client.capture( "python test event", @@ -1830,11 +1965,11 @@ def test_capture_does_not_normalize_datetime_properties(self): properties={"caller_datetime": property_value}, ) - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertIs(msg["properties"]["caller_datetime"], property_value) def test_groups_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.capture( "test_event", @@ -1846,7 +1981,7 @@ def test_groups_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -1855,7 +1990,7 @@ def test_groups_capture(self): ) def test_basic_set(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set( distinct_id="distinct_id", properties={"trait": "value"} @@ -1865,7 +2000,7 @@ def test_basic_set(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["$set"]["trait"], "value") @@ -1874,7 +2009,7 @@ def test_basic_set(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_advanced_set(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set( distinct_id="distinct_id", @@ -1887,7 +2022,7 @@ def test_advanced_set(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1899,7 +2034,7 @@ def test_advanced_set(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_basic_set_once(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set_once( distinct_id="distinct_id", properties={"trait": "value"} @@ -1909,7 +2044,7 @@ def test_basic_set_once(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["$set_once"]["trait"], "value") @@ -1918,7 +2053,7 @@ def test_basic_set_once(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_advanced_set_once(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.set_once( distinct_id="distinct_id", @@ -1931,7 +2066,7 @@ def test_advanced_set_once(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1943,7 +2078,7 @@ def test_advanced_set_once(self): self.assertEqual(msg["distinct_id"], "distinct_id") def test_basic_group_identify(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify("organization", "id:5") @@ -1951,7 +2086,7 @@ def test_basic_group_identify(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -1971,7 +2106,7 @@ def test_basic_group_identify(self): self.assertIsNotNone(msg.get("uuid")) def test_basic_group_identify_with_distinct_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", "id:5", distinct_id="distinct_id" @@ -1980,7 +2115,7 @@ def test_basic_group_identify_with_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2001,7 +2136,7 @@ def test_basic_group_identify_with_distinct_id(self): self.assertIsNotNone(msg.get("uuid")) def test_advanced_group_identify(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", @@ -2015,7 +2150,7 @@ def test_advanced_group_identify(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2034,7 +2169,7 @@ def test_advanced_group_identify(self): self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") def test_advanced_group_identify_with_distinct_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify( "organization", @@ -2049,7 +2184,7 @@ def test_advanced_group_identify_with_distinct_id(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "$groupidentify") @@ -2076,7 +2211,7 @@ def test_advanced_group_identify_with_distinct_id(self): ] ) def test_group_identify_without_group_type_is_dropped(self, _name, group_type): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.group_identify(group_type, "id:5") @@ -2092,7 +2227,7 @@ def test_group_identify_without_group_type_is_dropped(self, _name, group_type): ] ) def test_group_identify_without_group_key_is_dropped(self, _name, group_key): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.group_identify("organization", group_key) @@ -2102,18 +2237,18 @@ def test_group_identify_without_group_key_is_dropped(self, _name, group_key): self.assertIn("group_key", logs.output[0]) def test_group_identify_accepts_falsy_non_string_group_key(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.group_identify("organization", 0) self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] # The group key is validated, not normalized - it goes out as passed. self.assertEqual(msg["properties"]["$group_key"], 0) def test_basic_alias(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.alias("previousId", "distinct_id") self.assertIsNotNone(msg_uuid) @@ -2121,7 +2256,7 @@ def test_basic_alias(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["distinct_id"], "previousId") self.assertEqual(msg["properties"]["alias"], "distinct_id") @@ -2133,7 +2268,7 @@ def test_basic_alias(self): ] ) def test_alias_without_previous_id_is_dropped(self, _name, previous_id): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.alias(previous_id, "distinct_id") @@ -2143,18 +2278,95 @@ def test_alias_without_previous_id_is_dropped(self, _name, previous_id): self.assertIn("previous_id", logs.output[0]) def test_alias_accepts_non_string_previous_id(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) msg_uuid = client.alias(0, "distinct_id") self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - msg = mock_post.call_args[1]["batch"][0] + msg = sent_batch(mock_post)[0] self.assertEqual(msg["distinct_id"], "0") self.assertEqual(msg["properties"]["distinct_id"], "0") + def _capture_in_request_context(self, call): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + with new_context(fresh=True): + identify_context("context-user") + set_context_session("context-session") + tag("tenant", "acme") + set_context_option("product_tour_id", "tour-1") + result = call(client) + return result, (sent_batch(mock_post)[0] if mock_post.called else None) + + @parameterized.expand( + [ + ("caller_id", "caller-user", "caller-user"), + ("whitespace_caller_id", " ", "context-user"), + ] + ) + def test_group_identify_takes_context_values_but_not_its_session( + self, _, distinct_id, expected_distinct_id + ): + _, msg = self._capture_in_request_context( + lambda client: client.group_identify( + "company", "acme-1", distinct_id=distinct_id + ) + ) + + self.assertEqual(msg["distinct_id"], expected_distinct_id) + self.assertNotIn("$session_id", msg["properties"]) + self.assertEqual(msg["properties"]["tenant"], "acme") + self.assertEqual(msg["options"]["product_tour_id"], "tour-1") + + def test_group_identify_without_any_distinct_id_uses_the_group_id(self): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + client.group_identify("company", "acme-1") + client.group_identify("company", "acme-1") + + self.assertEqual( + [msg["distinct_id"] for msg in sent_events(mock_post)], + ["$company_acme-1", "$company_acme-1"], + ) + + @parameterized.expand( + [ + ("caller_target", "user-1", "user-1"), + ("context_only_target", None, None), + ("whitespace_target", " ", None), + ] + ) + def test_alias_takes_its_target_only_from_the_caller( + self, _, distinct_id, expected_alias + ): + result, msg = self._capture_in_request_context( + lambda client: client.alias("anon-1", distinct_id) + ) + + if expected_alias is None: + self.assertIsNone(result) + self.assertIsNone(msg) + return + self.assertEqual(msg["properties"]["alias"], expected_alias) + self.assertEqual(msg["properties"]["$session_id"], "context-session") + self.assertEqual(msg["properties"]["tenant"], "acme") + self.assertEqual(msg["options"]["product_tour_id"], "tour-1") + + @parameterized.expand([("none", None), ("empty", ""), ("whitespace", " \t ")]) + def test_capture_without_a_distinct_id_sends_a_personless_v7_id( + self, _, distinct_id + ): + with patch_capture_send("client") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + client.capture("event", distinct_id=distinct_id) + + msg = sent_batch(mock_post)[0] + self.assertEqual(UUID(msg["distinct_id"]).version, 7) + self.assertEqual(msg["options"]["process_person_profile"], False) + def test_alias_without_distinct_id_is_dropped(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with self.assertLogs("posthog", level="WARNING") as logs: msg_uuid = client.alias("previousId", None) @@ -2190,7 +2402,7 @@ def test_alias_without_distinct_id_is_dropped(self): def test_capture_with_session_id_variations( self, test_name, session_id, additional_properties, expected_properties ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) properties = {"$session_id": session_id, **additional_properties} @@ -2203,7 +2415,7 @@ def test_capture_with_session_id_variations( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -2217,7 +2429,7 @@ def test_capture_with_session_id_variations( self.assertEqual(msg["properties"][key], value) def test_session_id_preserved_with_groups(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) session_id = "group-session-101" @@ -2232,7 +2444,7 @@ def test_session_id_preserved_with_groups(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], session_id) @@ -2242,7 +2454,7 @@ def test_session_id_preserved_with_groups(self): ) def test_session_id_with_anonymous_event(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) session_id = "anonymous-session-202" @@ -2259,7 +2471,7 @@ def test_session_id_with_anonymous_event(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], session_id) @@ -2339,7 +2551,7 @@ def test_session_id_with_different_event_types( additional_properties, expected_additional_properties, ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) properties = {"$session_id": session_id, **additional_properties} @@ -2351,7 +2563,7 @@ def test_session_id_with_different_event_types( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], event_name) @@ -2369,10 +2581,10 @@ def test_session_id_with_different_event_types( [ # test_name, super_properties, event_session_id, expected_session_id, expected_super_props ( - "super_properties_override_session_id", + "event_session_id_overrides_super_properties", {"$session_id": "super-session", "source": "test"}, "event-session-808", - "super-session", + "event-session-808", {"source": "test"}, ), ( @@ -2406,7 +2618,7 @@ def test_session_id_with_super_properties_variations( expected_session_id, expected_super_props, ): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, super_properties=super_properties, sync_mode=True ) @@ -2421,7 +2633,7 @@ def test_session_id_with_super_properties_variations( # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$session_id"], expected_session_id) @@ -2704,22 +2916,94 @@ def flush(timeout_seconds): self.assertEqual(timeouts, [0, None]) - def test_sync_send_failure_does_not_invoke_async_on_error(self): - on_error = mock.Mock() + def test_sync_send_failure_invokes_on_error_after_releasing_send_slot(self): + error = Exception("upload failed") + active_sends_in_callback = [] + client: Client + + def on_error(err, batch): + active_sends_in_callback.append(client._analytics_lane._active_sync_sends) + self.assertIs(err, error) + self.assertEqual([event["event"] for event in batch], ["event"]) + client.shutdown() + + client = Client(FAKE_TEST_API_KEY, sync_mode=True, on_error=on_error) + + with patch_capture_send("client", side_effect=error): + result = client.capture("event", distinct_id="distinct_id") + + self.assertIsNone(result) + self.assertEqual(active_sends_in_callback, [0]) + + def test_sync_send_failure_inside_on_error_logs_instead_of_recursing(self): + calls = [] + client: Client + + def on_error(err, batch): + calls.append(batch[0]["event"]) + client.capture("from_callback", distinct_id="distinct_id") + + client = Client(FAKE_TEST_API_KEY, sync_mode=True, on_error=on_error) + + with patch_capture_send("client", side_effect=Exception("upload failed")): + with self.assertLogs("posthog", level="ERROR") as logs: + client.capture("event", distinct_id="distinct_id") + + self.assertEqual(calls, ["event"]) + self.assertIn( + "1 event(s) not persisted by /i/v1/analytics/events: Exception", + "\n".join(logs.output), + ) + + @parameterized.expand([("capture",), ("capture_ai",)]) + def test_sync_send_failure_in_debug_reports_once_then_raises(self, method_name): + errors = [] client = Client( FAKE_TEST_API_KEY, sync_mode=True, - on_error=on_error, + debug=True, + on_error=lambda err, batch: errors.append(err), ) + failure = APIError(503, "unavailable") - with mock.patch( - "posthog.client.batch_post", side_effect=Exception("upload failed") + with patch_capture_send("client", side_effect=failure): + with self.assertRaises(APIError) as raised: + getattr(client, method_name)("event", distinct_id="distinct_id") + + self.assertIs(raised.exception, failure) + self.assertEqual(errors, [failure]) + + @parameterized.expand( + [ + ("analytics", "capture", _CAPTURE_V1_PATH), + ("ai", "capture_ai", _CAPTURE_AI_V1_PATH), + ] + ) + def test_sync_send_failure_without_on_error_logs_aggregate_line( + self, _name, method_name, path + ): + client = Client(FAKE_TEST_API_KEY, sync_mode=True) + + with patch_capture_send( + "client", side_effect=APIError(400, "password=server-secret") ): - result = client.capture("event", distinct_id="distinct_id") + with self.assertLogs("posthog", level="ERROR") as logs: + result = getattr(client, method_name)( + "$ai_generation", distinct_id="distinct_id" + ) self.assertIsNone(result) - on_error.assert_not_called() - self.assertEqual(client._analytics_lane._active_sync_sends, 0) + output = "\n".join(logs.output) + loss_lines = [ + r.getMessage() + for r in logs.records + if "event(s) not persisted by" in r.getMessage() + ] + self.assertEqual( + loss_lines, + [f"[PostHog] 1 event(s) not persisted by {path}: APIError (status=400)"], + ) + self.assertNotIn("server-secret", output) def test_on_error_can_request_shutdown_with_pending_work(self): first_send_started = threading.Event() @@ -3341,12 +3625,21 @@ def test_shutdown_does_not_wait_for_idle_consumers_flush_interval(self): self.assertLess(time.monotonic() - start, 1) - def test_shutdown_waits_for_racing_enqueue_before_draining(self): + @parameterized.expand( + [ + ("analytics", "capture", "_analytics_lane", _CAPTURE_V1_PATH), + ("ai_first_event", "capture_ai", "_ai_lane", _CAPTURE_AI_V1_PATH), + ] + ) + def test_shutdown_waits_for_racing_enqueue_before_draining( + self, _name, method_name, lane_attr, path + ): client = Client(FAKE_TEST_API_KEY, flush_interval=0.01) + lane = getattr(client, lane_attr) put_started = threading.Event() release_put = threading.Event() shutdown_done = threading.Event() - original_put = client.queue.put + original_put = lane.queue.put capture_result = [] def blocking_put(*args, **kwargs): @@ -3356,14 +3649,17 @@ def blocking_put(*args, **kwargs): capture_thread = threading.Thread( target=lambda: capture_result.append( - client.capture("racing event", distinct_id="distinct_id") + getattr(client, method_name)("$ai_racing", distinct_id="distinct_id") ) ) shutdown_thread = threading.Thread( target=lambda: (client.shutdown(), shutdown_done.set()) ) - with mock.patch.object(client.queue, "put", side_effect=blocking_put): + with ( + patch_capture_send("consumer") as mock_send, + mock.patch.object(lane.queue, "put", side_effect=blocking_put), + ): capture_thread.start() self.assertTrue(put_started.wait(2)) shutdown_thread.start() @@ -3379,7 +3675,17 @@ def blocking_put(*args, **kwargs): self.assertFalse(shutdown_thread.is_alive()) self.assertTrue(shutdown_done.is_set()) self.assertIsNotNone(capture_result[0]) - self.assertTrue(client.queue.empty()) + self.assertTrue(lane.queue.empty()) + self.assertEqual( + [ + (call.kwargs["path"], len(sent_batch(mock_send, i))) + for i, call in enumerate(mock_send.call_args_list) + ], + [(path, 1)], + ) + self.assertFalse( + any(c.is_alive() for lane in client._lanes for c in lane.consumers) + ) def test_shutdown_waits_for_sync_send_and_rejects_later_sends(self): client = Client(FAKE_TEST_API_KEY, sync_mode=True) @@ -3401,7 +3707,7 @@ def blocking_post(*args, **kwargs): target=lambda: (client.shutdown(), shutdown_done.set()) ) - with mock.patch("posthog.client.batch_post", side_effect=blocking_post) as post: + with patch_capture_send("client", side_effect=blocking_post) as post: capture_thread.start() self.assertTrue(send_started.wait(2)) shutdown_thread.start() @@ -3424,7 +3730,7 @@ def blocking_post(*args, **kwargs): post.assert_called_once() def test_synchronous(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, sync_mode=True) msg_uuid = client.capture("test event", distinct_id="distinct_id") @@ -3502,8 +3808,12 @@ def test_numeric_distinct_id(self): def test_debug(self): Client("bad_key", debug=True) - def test_gzip(self): - client = Client(FAKE_TEST_API_KEY, on_error=self.fail, gzip=True) + def test_gzip_compression(self): + client = Client( + FAKE_TEST_API_KEY, + on_error=self.fail, + capture_compression=CaptureCompression.GZIP, + ) for _ in range(10): client.capture( "event", distinct_id="distinct_id", properties={"trait": "value"} @@ -3516,14 +3826,12 @@ def test_user_defined_flush_at(self): FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3 ) - def mock_post_fn(*args, **kwargs): - self.assertEqual(len(kwargs["batch"]), 10) + def mock_post_fn(api_key, host, batch, **kwargs): + self.assertEqual(len(batch), 10) # the post function should be called 2 times, with a batch size of 10 # each time. - with mock.patch( - "posthog.consumer.batch_post", side_effect=mock_post_fn - ) as mock_post: + with patch_capture_send("consumer", side_effect=mock_post_fn) as mock_post: for _ in range(20): client.capture( "event", distinct_id="distinct_id", properties={"trait": "value"} @@ -3576,7 +3884,7 @@ def test_disabled_with_feature_flags(self, patch_flags): self.assertTrue(client.queue.empty()) def test_enabled_to_disabled(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3590,7 +3898,7 @@ def test_enabled_to_disabled(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["event"], "python test event") @@ -3600,7 +3908,7 @@ def test_enabled_to_disabled(self): self.assertFalse(self.failed) def test_disable_geoip_default_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3612,12 +3920,12 @@ def test_disable_geoip_default_on_events(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) capture_msg = batch_data[0] self.assertEqual(capture_msg["properties"]["$geoip_disable"], True) def test_disable_geoip_override_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3643,17 +3951,17 @@ def test_disable_geoip_override_on_events(self): self.assertEqual(mock_post.call_count, 2) # Check set event - set_batch = mock_post.call_args_list[0][1]["batch"] + set_batch = sent_batch(mock_post, 0) capture_msg = set_batch[0] self.assertEqual(capture_msg["properties"]["$geoip_disable"], True) # Check page event - page_batch = mock_post.call_args_list[1][1]["batch"] + page_batch = sent_batch(mock_post, 1) identify_msg = page_batch[0] - self.assertEqual("$geoip_disable" not in identify_msg["properties"], True) + self.assertIs(identify_msg["properties"]["$geoip_disable"], False) def test_disable_geoip_method_overrides_init_on_events(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -3667,9 +3975,9 @@ def test_disable_geoip_method_overrides_init_on_events(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] - self.assertTrue("$geoip_disable" not in msg["properties"]) + self.assertIs(msg["properties"]["$geoip_disable"], False) @mock.patch("posthog.client.flags") def test_disable_geoip_default_on_decide(self, patch_flags): @@ -4119,7 +4427,7 @@ def test_get_decide_returns_normalized_decide_response(self, patch_flags): } def test_set_context_session_with_capture(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("context-session-123") @@ -4134,7 +4442,7 @@ def test_set_context_session_with_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4143,7 +4451,7 @@ def test_set_context_session_with_capture(self): @parameterized.expand([("new_context",), ("scoped",)]) def test_client_context_helpers_apply_to_capture(self, context_helper): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) def capture_in_context(): @@ -4171,7 +4479,7 @@ def scoped_capture(): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "context-user") @@ -4182,7 +4490,7 @@ def scoped_capture(): self.assertEqual(client.get_tags(), {}) def test_client_scoped_context_helpers_apply_to_capture_async(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) @client.scoped(fresh=True) @@ -4197,7 +4505,7 @@ async def scoped_capture(): self.assertIsNotNone(msg_uuid) mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["distinct_id"], "async-scoped-user") @@ -4213,7 +4521,7 @@ async def scoped_capture(): self.assertEqual(client.get_tags(), {}) def test_set_context_session_with_page_explicit_properties(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("page-explicit-session-789") @@ -4230,7 +4538,7 @@ def test_set_context_session_with_page_explicit_properties(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4241,7 +4549,7 @@ def test_set_context_session_override_in_capture(self): """Test that explicit session ID overrides context session ID in capture""" from posthog.contexts import new_context, set_context_session - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): set_context_session("context-session-override") @@ -4259,7 +4567,7 @@ def test_set_context_session_override_in_capture(self): # Get the enqueued message from the mock mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual( @@ -4462,7 +4770,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): } } - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client( FAKE_TEST_API_KEY, on_error=self.set_fail, @@ -4490,7 +4798,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): # Check the message includes only the filtered flags mock_post.assert_called_once() - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$feature/flag1"], "value1") @@ -4498,7 +4806,7 @@ def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): # flag2 should not be included since it wasn't requested self.assertNotIn("$feature/flag2", msg["properties"]) - @mock.patch("posthog.client.batch_post") + @patch_capture_send("client") def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_post): """Test that get_feature_flag_result returns a FeatureFlagResult when payload is empty string""" client = Client( @@ -4549,7 +4857,7 @@ def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_pos self.assertEqual(result.get_value(), "empty-variant") self.assertIsNone(result.payload) - @mock.patch("posthog.client.batch_post") + @patch_capture_send("client") def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post): """Test that get_all_flags_and_payloads includes flags with empty string payloads""" client = Client( @@ -4605,14 +4913,14 @@ def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post): ) def test_context_tags_added(self): - with mock.patch("posthog.client.batch_post") as mock_post: + with patch_capture_send("client") as mock_post: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) with new_context(): tag("random_tag", 12345) client.capture("python test event", distinct_id="distinct_id") - batch_data = mock_post.call_args[1]["batch"] + batch_data = sent_batch(mock_post) msg = batch_data[0] self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"]) @@ -4661,30 +4969,18 @@ def test_debug_flag_re_raises_exceptions(self, mock_enqueue): class TestClientCaptureRetrySemantics(unittest.TestCase): - @parameterized.expand( - [ - ("v0_sync", "v0", True), - ("v1_sync", "v1", True), - ("v0_async", "v0", False), - ("v1_async", "v1", False), - ] - ) - def test_negative_max_retries_still_attempts_delivery_once( - self, _name, capture_mode, sync_mode - ): + @parameterized.expand([("sync", True), ("async", False)]) + def test_negative_max_retries_still_attempts_delivery_once(self, _name, sync_mode): response = mock.Mock(status_code=200, headers={}, text="") response.json.return_value = {"results": {}} client = None - with ( - mock.patch("posthog.client.batch_post") as sync_v0_post, - mock.patch("posthog.consumer.batch_post") as async_v0_post, - mock.patch("posthog.capture_v1._post_v1", return_value=response) as v1_post, - ): + with mock.patch( + "posthog.capture_send._post_v1", return_value=response + ) as v1_post: try: client = Client( FAKE_TEST_API_KEY, - capture_mode=capture_mode, sync_mode=sync_mode, max_retries=-1, flush_at=1, @@ -4695,59 +4991,30 @@ def test_negative_max_retries_still_attempts_delivery_once( client.flush() self.assertEqual(client.max_retries, 0) - if capture_mode == "v1": - v1_post.assert_called_once() - sync_v0_post.assert_not_called() - async_v0_post.assert_not_called() - elif sync_mode: - sync_v0_post.assert_called_once() - async_v0_post.assert_not_called() - v1_post.assert_not_called() - else: - async_v0_post.assert_called_once() - sync_v0_post.assert_not_called() - v1_post.assert_not_called() + v1_post.assert_called_once() finally: if client is not None and not sync_mode: client.shutdown() -class TestClientSyncCaptureMode(unittest.TestCase): - """Sync-mode `_enqueue` selects the analytics submitter by `capture_mode`.""" +class TestClientSyncCapture(unittest.TestCase): + """Sync-mode `_enqueue` sends analytics events through the v1 submitter.""" def _client(self, **kwargs): return Client(FAKE_TEST_API_KEY, sync_mode=True, **kwargs) - @parameterized.expand( - [ - ("v0", None, False), - ("v1", "v1", True), - ] - ) - def test_capture_mode_selects_sync_submitter(self, _name, capture_mode, expects_v1): - kwargs = {"capture_mode": capture_mode} if capture_mode else {} - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - self._client(**kwargs).capture("evt", distinct_id="d") - if expects_v1: - mock_post.assert_not_called() - mock_v1.assert_called_once() - sent_batch = mock_v1.call_args.args[2] - self.assertEqual(len(sent_batch), 1) - self.assertEqual(sent_batch[0]["event"], "evt") - else: - mock_v1.assert_not_called() - mock_post.assert_called_once() - - def test_v1_sync_forwards_config_to_submitter(self): - with ( - mock.patch("posthog.client.batch_post"), - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): + def test_sync_capture_posts_to_analytics_endpoint(self): + with mock.patch("posthog.client._send_v1_batch") as mock_v1: + self._client().capture("evt", distinct_id="d") + mock_v1.assert_called_once() + batch = mock_v1.call_args.args[2] + self.assertEqual(len(batch), 1) + self.assertEqual(batch[0]["event"], "evt") + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) + + def test_sync_forwards_config_to_submitter(self): + with mock.patch("posthog.client._send_v1_batch") as mock_v1: self._client( - capture_mode="v1", capture_compression=CaptureCompression.GZIP, max_retries=4, historical_migration=True, @@ -4757,29 +5024,15 @@ def test_v1_sync_forwards_config_to_submitter(self): self.assertEqual(kwargs["max_retries"], 4) self.assertEqual(kwargs["historical_migration"], True) - def test_v1_sync_gzip_flag_falls_back_to_gzip_compression(self): - # Legacy `gzip=True` with no explicit capture_compression -> GZIP on v1. - with ( - mock.patch("posthog.client.batch_post"), - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - self._client(capture_mode="v1", gzip=True).capture("evt", distinct_id="d") - self.assertEqual( - mock_v1.call_args.kwargs["compression"], CaptureCompression.GZIP - ) - - def test_v1_sync_ai_named_event_through_capture_uses_v1(self): + def test_sync_ai_named_event_through_capture_uses_analytics_endpoint(self): # `capture()` never special-cases AI events: an `$ai_*`-named event - # follows `capture_mode` and rides the v1 submitter like any analytics - # event. Only `capture_ai()` reaches the AI lane. - with ( - mock.patch("posthog.client.batch_post") as mock_post, - mock.patch("posthog.client._send_v1_batch") as mock_v1, - ): - client = self._client(capture_mode="v1") + # rides the analytics endpoint like any other event. Only + # `capture_ai()` reaches the AI lane. + with mock.patch("posthog.client._send_v1_batch") as mock_v1: + client = self._client() client.capture("$ai_generation", distinct_id="d") - mock_post.assert_not_called() mock_v1.assert_called_once() - sent_batch = mock_v1.call_args.args[2] - self.assertEqual(len(sent_batch), 1) - self.assertEqual(sent_batch[0]["event"], "$ai_generation") + batch = mock_v1.call_args.args[2] + self.assertEqual(len(batch), 1) + self.assertEqual(batch[0]["event"], "$ai_generation") + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) diff --git a/posthog/test/test_code_variables.py b/posthog/test/test_code_variables.py index 6263b4b8d..4e8fab36a 100644 --- a/posthog/test/test_code_variables.py +++ b/posthog/test/test_code_variables.py @@ -112,17 +112,16 @@ def extract( import os import posthog from posthog import Posthog -from requests import Response import posthog.request +from posthog.test.capture_helpers import offline_v1_post -def offline_post(url, **kwargs): - response = Response() - response.status_code = 200 - return response +posthog.request._session.post = offline_v1_post -posthog.request._session.post = offline_post +def print_event(event): + print("captured:", event, flush=True) + return event def make_client(**options): @@ -132,6 +131,7 @@ def make_client(**options): debug=True, enable_exception_autocapture=True, project_root=os.path.dirname(os.path.abspath(__file__)), + before_send=print_event, **options, ) """ @@ -141,9 +141,9 @@ def run_app(tmpdir, body, *, env=None): """Write and run a tiny PostHog app that raises an uncaught exception. The app already has ``make_client(**options)`` and ``posthog`` imported; the body - creates the client, defines the failing code, and triggers it. In debug mode the - autocaptured payload (including ``code_variables``) is printed, which is what the - end-to-end tests assert against. Returns the combined stdout/stderr. + creates the client, defines the failing code, and triggers it. A ``before_send`` + hook prints the autocaptured payload (including ``code_variables``), which is what + the end-to-end tests assert against. Returns the combined stdout/stderr. """ app = tmpdir.join("app.py") app.write(_APP_HEADER + "\n" + dedent(body)) @@ -1030,8 +1030,8 @@ def outer(note): class TestEndToEnd: """The whole code-variables pipeline through a real subprocess: an uncaught - exception is autocaptured and its serialized payload (with code_variables) is - printed in debug mode.""" + exception is autocaptured and its payload (with code_variables) is printed by a + before_send hook.""" def test_code_variables_are_captured_and_masked(self, tmpdir): # the failing frame's locals: one safe dict, two secrets, one ignored dunder @@ -1056,7 +1056,7 @@ def trigger_error(): ) assert "ZeroDivisionError" in output assert "'code_variables':" in output # the key form, not the temp-path word - # scalars show up in the debug log in their repr form + # scalars show up in the printed payload in their repr form assert "'greeting': 'hello world'" in output assert "'count': 42" in output # a dict variable is double-encoded as a JSON string (locks the wire format) diff --git a/posthog/test/test_consumer.py b/posthog/test/test_consumer.py index c2c54cf52..f4cf3188d 100644 --- a/posthog/test/test_consumer.py +++ b/posthog/test/test_consumer.py @@ -2,9 +2,6 @@ import threading import time import unittest -from datetime import datetime, timedelta, timezone -from email.utils import format_datetime -from typing import Any from unittest import mock from parameterized import parameterized @@ -15,9 +12,9 @@ from Queue import Queue from posthog.capture_compression import CaptureCompression -from posthog.capture_mode import CaptureMode +from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH from posthog.consumer import MAX_MSG_SIZE, Consumer, _DrainSignal -from posthog.request import AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT, APIError +from posthog.test.capture_helpers import patch_capture_send, sent_batch from posthog.test.logging_helpers import capture_message_only_logs from posthog.test.test_utils import TEST_API_KEY @@ -207,16 +204,37 @@ def test_max_msg_size_param_raises_per_event_ceiling(self) -> None: q.put(big_msg) self.assertEqual(consumer.next(), [big_msg]) + @parameterized.expand( + [ + # A small event serializes to 18 bytes, the "big" one to 81. + ("closes_before_overflow", 43, [0, 1, 2, 3], [[0, 1], [2, 3]]), + ("event_over_limit_goes_alone", 30, [0, "big", 1], [[0], ["big"], [1]]), + ] + ) + def test_batch_byte_limit_is_checked_before_appending( + self, _name, limit, ids, expected + ) -> None: + q = Queue() + consumer = Consumer(q, "", flush_at=10, flush_interval=0.01) + for i in ids: + q.put({"m": "x" * (60 if i == "big" else 1), "i": i}) + + with mock.patch("posthog.consumer.BATCH_SIZE_LIMIT", limit): + batches = [[e["i"] for e in consumer.next()] for _ in expected] + + self.assertEqual(batches, expected) + self.assertEqual(q.unfinished_tasks, len(ids)) + def test_upload(self) -> None: q = Queue() consumer = Consumer(q, TEST_API_KEY, flush_at=1) event = _track_event() q.put(event) - with mock.patch("posthog.consumer.batch_post") as post: + with patch_capture_send("consumer") as post: success = consumer.upload() self.assertTrue(success) post.assert_called_once() - self.assertEqual(post.call_args.kwargs["batch"], [event]) + self.assertEqual(sent_batch(post), [event]) self.assertEqual(q.unfinished_tasks, 0) self.assertTrue(q.empty()) @@ -235,9 +253,11 @@ def test_message_only_error_logs_include_posthog_prefix(self) -> None: # other tests can log into the same stream. Assert on the line under # test rather than on the entire capture. upload_logs = [ - line for line in logs.getvalue().splitlines() if "error uploading" in line + line for line in logs.getvalue().splitlines() if "not persisted" in line ] - expected_log = "[PostHog] error uploading: boom" + expected_log = ( + "[PostHog] 1 event(s) not persisted by /i/v1/analytics/events: Exception" + ) self.assertEqual( [line for line in upload_logs if line == expected_log], [expected_log] ) @@ -307,12 +327,12 @@ def test_multiple_uploads_per_interval(self) -> None: delivered = threading.Event() batches = [] - def record_batch(*args, **kwargs): - batches.append(kwargs["batch"]) + def record_batch(api_key, host, batch, **kwargs): + batches.append(batch) if len(batches) == 2: delivered.set() - with mock.patch("posthog.consumer.batch_post", side_effect=record_batch): + with patch_capture_send("consumer", side_effect=record_batch): consumer.start() try: events = [ @@ -327,86 +347,15 @@ def record_batch(*args, **kwargs): consumer.join(15) self.assertFalse(consumer.is_alive()) - def test_request(self) -> None: - consumer = Consumer(None, TEST_API_KEY) - batch = [_track_event()] - with mock.patch("posthog.consumer.batch_post") as post: - consumer.request(batch) - post.assert_called_once_with( - TEST_API_KEY, - None, - gzip=False, - timeout=15, - batch=batch, - historical_migration=False, - path="/batch/", - ) - - def _run_retry_test( - self, - exception: Exception, - exception_count: int, - retries: int = 10, - expected_attempts: int = 3, - raises: bool = False, - ) -> None: - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= exception_count: - raise exception - - consumer = Consumer(None, TEST_API_KEY, retries=retries) - batch = [_track_event()] - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post) as post, - mock.patch("posthog.consumer.time.sleep"), - ): - if raises: - with self.assertRaises(type(exception)) as raised: - consumer.request(batch) - self.assertIs(raised.exception, exception) - else: - consumer.request(batch) - self.assertEqual(post.call_count, expected_attempts) - for call in post.call_args_list: - self.assertEqual(call.kwargs["batch"], batch) - - @parameterized.expand( - [ - ("general_errors", Exception("generic exception"), 2), - ("server_errors", APIError(500, "Internal Server Error"), 2), - ("rate_limit_errors", APIError(429, "Too Many Requests"), 2), - ] - ) - def test_request_retries_on_retriable_errors( - self, _name: str, exception: Exception, exception_count: int - ) -> None: - self._run_retry_test(exception, exception_count) - - def test_request_does_not_retry_client_errors(self) -> None: - self._run_retry_test( - APIError(400, "Client Errors"), 1, expected_attempts=1, raises=True - ) - - def test_request_fails_when_exceptions_exceed_retries(self) -> None: - self._run_retry_test( - APIError(500, "Internal Server Error"), - 4, - retries=3, - expected_attempts=4, - raises=True, - ) - def test_negative_retries_still_attempts_delivery_once(self) -> None: consumer = Consumer(None, TEST_API_KEY, retries=-1) - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_send: consumer.request([_track_event()]) self.assertEqual(consumer.retries, 0) - mock_post.assert_called_once() + mock_send.assert_called_once() + self.assertEqual(mock_send.call_args.kwargs["max_retries"], 0) def test_pause(self) -> None: consumer = Consumer(None, TEST_API_KEY) @@ -602,7 +551,7 @@ def test_max_batch_size(self) -> None: # Let's capture 8MB of data to trigger two batches n_msgs = int(8_000_000 / msg_size) - with mock.patch.object(consumer, "_send") as mock_send: + with mock.patch.object(consumer, "request") as mock_send: consumer.start() try: for _ in range(0, n_msgs + 2): @@ -620,101 +569,6 @@ def test_max_batch_size(self) -> None: consumer.join(5) self.assertFalse(consumer.is_alive()) - def test_request_sleeps_with_retry_after(self) -> None: - error = APIError(429, "Too Many Requests", retry_after=5.0) - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 1: - raise error - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - mock_sleep.assert_called_once_with(5.0) - - def test_request_uses_exponential_backoff_without_retry_after(self) -> None: - error = APIError(503, "Service Unavailable") - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 3: - raise error - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - self.assertEqual( - mock_sleep.call_args_list, - [ - mock.call(1), # 2^0 - mock.call(2), # 2^1 - mock.call(4), # 2^2 - ], - ) - - @parameterized.expand( - [ - ("huge_numeric", "1000000000", [30, 30]), - ("small_numeric", "0.25", [1, 2]), - ("huge_date", "Fri, 01 Jan 2100 00:00:00 GMT", [30, 30]), - ("small_date", None, [1, 2]), - ] - ) - def test_request_bounds_retry_after_without_reducing_attempts( - self, _name: str, retry_after_header: str | None, expected_sleeps: list[int] - ) -> None: - if retry_after_header is None: - retry_after_header = format_datetime( - datetime.now(timezone.utc) + timedelta(seconds=1), usegmt=True - ) - - retry_response = mock.Mock( - status_code=503, - headers={"Retry-After": retry_after_header}, - text="Service Unavailable", - ) - retry_response.json.return_value = {"detail": "Service Unavailable"} - success_response = mock.Mock(status_code=200) - session = mock.Mock() - session.post.side_effect = [retry_response, retry_response, success_response] - - consumer = Consumer(None, TEST_API_KEY, retries=2) - with ( - mock.patch("posthog.request._get_session", return_value=session), - mock.patch("posthog.consumer.time.sleep") as mock_sleep, - ): - consumer.request([_track_event()]) - - self.assertEqual(session.post.call_count, 3) - self.assertEqual( - [call.args[0] for call in mock_sleep.call_args_list], expected_sleeps - ) - - def test_request_retries_on_408(self) -> None: - call_count = [0] - - def mock_post(*args: Any, **kwargs: Any) -> None: - call_count[0] += 1 - if call_count[0] <= 1: - raise APIError(408, "Request Timeout") - - consumer = Consumer(None, TEST_API_KEY, retries=3) - with ( - mock.patch("posthog.consumer.batch_post", side_effect=mock_post), - mock.patch("posthog.consumer.time.sleep"), - ): - consumer.request([_track_event()]) - self.assertEqual(call_count[0], 2) - @parameterized.expand( [ ("on_error_succeeds", False), @@ -751,48 +605,28 @@ def _ai_event(event_name: str = "$ai_generation") -> dict[str, str]: return {"type": "track", "event": event_name, "distinct_id": "distinct_id"} -class TestConsumerCaptureModeRouting(unittest.TestCase): - """`capture_mode` selects the submitter; V0 posts to the consumer's `endpoint`.""" +class TestConsumerSubmitterRouting(unittest.TestCase): + """Every consumer sends through the capture v1 submitter to its `endpoint`.""" - @parameterized.expand( - [ - ("v0", CaptureMode.V0, False), - ("v1", CaptureMode.V1, True), - ] - ) - def test_capture_mode_selects_analytics_submitter( - self, _name, mode, expects_v1 - ) -> None: - consumer = Consumer(None, TEST_API_KEY, capture_mode=mode) + def test_default_posts_to_analytics_endpoint(self) -> None: + consumer = Consumer(None, TEST_API_KEY) batch = [_track_event()] - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request(batch) - if expects_v1: - mock_post.assert_not_called() - mock_v1.assert_called_once() - self.assertEqual(mock_v1.call_args.args[2], batch) - else: - mock_v1.assert_not_called() - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], EVENTS_ENDPOINT) - - def test_v1_forwards_consumer_config_to_submitter(self) -> None: + mock_v1.assert_called_once() + self.assertEqual(sent_batch(mock_v1), batch) + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_V1_PATH) + + def test_forwards_consumer_config_to_submitter(self) -> None: consumer = Consumer( None, TEST_API_KEY, - capture_mode=CaptureMode.V1, capture_compression=CaptureCompression.DEFLATE, timeout=7, retries=4, historical_migration=True, ) - with ( - mock.patch("posthog.consumer.batch_post"), - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request([_track_event()]) kwargs = mock_v1.call_args.kwargs self.assertEqual(kwargs["compression"], CaptureCompression.DEFLATE) @@ -800,25 +634,11 @@ def test_v1_forwards_consumer_config_to_submitter(self) -> None: self.assertEqual(kwargs["max_retries"], 4) self.assertEqual(kwargs["historical_migration"], True) - def test_v0_posts_to_configured_endpoint(self) -> None: - consumer = Consumer(None, TEST_API_KEY, endpoint=AI_EVENTS_ENDPOINT) + def test_posts_to_configured_endpoint(self) -> None: + consumer = Consumer(None, TEST_API_KEY, endpoint=_CAPTURE_AI_V1_PATH) batch = [_ai_event()] - with mock.patch("posthog.consumer.batch_post") as mock_post: - consumer.request(batch) - mock_post.assert_called_once() - self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) - self.assertEqual(mock_post.call_args.kwargs["batch"], batch) - - def test_v1_routes_whole_batch_through_v1_submitter(self) -> None: - # A consumer doesn't know AI events exist: with `capture_mode` v1, the - # whole batch (including `$ai_*`-named events) rides the v1 submitter. - consumer = Consumer(None, TEST_API_KEY, capture_mode=CaptureMode.V1) - batch = [_ai_event(), _track_event()] - with ( - mock.patch("posthog.consumer.batch_post") as mock_post, - mock.patch("posthog.consumer._send_v1_batch") as mock_v1, - ): + with patch_capture_send("consumer") as mock_v1: consumer.request(batch) - mock_v1.assert_called_once() - self.assertEqual(mock_v1.call_args.args[2], batch) - mock_post.assert_not_called() + mock_v1.assert_called_once() + self.assertEqual(mock_v1.call_args.kwargs["path"], _CAPTURE_AI_V1_PATH) + self.assertEqual(sent_batch(mock_v1), batch) diff --git a/posthog/test/test_contexts.py b/posthog/test/test_contexts.py index e75a6610d..73a1ab466 100644 --- a/posthog/test/test_contexts.py +++ b/posthog/test/test_contexts.py @@ -7,7 +7,9 @@ import posthog from posthog.client import Client from posthog.contexts import ( + get_context_options, get_tags, + set_context_option, new_context, scoped, tag, @@ -384,6 +386,26 @@ def context_state(): ), ) + def test_context_options_inherit_like_tags(self): + with new_context(fresh=True): + set_context_option("cookieless_mode", True) + set_context_option("process_person_profile", False) + + with new_context(fresh=False): + set_context_option("process_person_profile", True) + assert get_context_options() == { + "cookieless_mode": True, + "process_person_profile": True, + } + + with new_context(fresh=True): + assert get_context_options() == {} + + assert get_context_options() == { + "cookieless_mode": True, + "process_person_profile": False, + } + def test_child_tags_override_parent_tags_in_non_fresh_context(self): with new_context(fresh=True): tag("shared_key", "parent_value") diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py new file mode 100644 index 000000000..f3fb1237c --- /dev/null +++ b/posthog/test/test_event_options.py @@ -0,0 +1,555 @@ +import inspect +import logging + +import pytest + +from posthog import AsyncPosthog, new_context, set_context_option, tag +from posthog.ai.utils import _capture_ai_event +from posthog.capture_event import _to_v1_event +from posthog.client import Client +from posthog.test.capture_helpers import ( + patch_async_capture_send, + patch_capture_send, + sent_events, +) +from posthog.test.test_utils import FAKE_TEST_API_KEY + +OPTIONS = {"cookieless_mode": True, "future_option": "kept"} + +CAPTURE_CALLS = { + "capture": lambda c, o: c.capture("e", distinct_id="u", options=o), + "capture_ai": lambda c, o: c.capture_ai( + "$ai_generation", distinct_id="u", options=o + ), + "set": lambda c, o: c.set(distinct_id="u", properties={"a": 1}, options=o), + "set_once": lambda c, o: c.set_once( + distinct_id="u", properties={"a": 1}, options=o + ), + "group_identify": lambda c, o: c.group_identify( + "company", "acme", distinct_id="u", options=o + ), + "alias": lambda c, o: c.alias("previous", "u", options=o), + "capture_exception": lambda c, o: c.capture_exception( + ValueError("boom"), distinct_id="u", options=o + ), +} +ASYNC_CAPTURE_CALLS = {k: v for k, v in CAPTURE_CALLS.items() if k != "capture_ai"} + + +def _sync_wire_events(call, before_send=None, **config) -> list[dict]: + with patch_capture_send("client") as send: + client = Client( + FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send, **config + ) + assert call(client) is not None + return [_to_v1_event(msg) for msg in sent_events(send)] + + +async def _async_wire_events(call, before_send=None, **config) -> list[dict]: + batches: list[list[dict]] = [] + + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) + + with patch_async_capture_send(side_effect=send_batch): + async with AsyncPosthog( + "test-key", before_send=before_send, **config + ) as client: + result = call(client) + if inspect.isawaitable(result): + result = await result + assert result is not None + await client.flush(timeout_seconds=1) + return [_to_v1_event(msg) for batch in batches for msg in batch] + + +@pytest.mark.parametrize("method", list(CAPTURE_CALLS)) +def test_sync_options_reach_the_wire(method): + events = _sync_wire_events(lambda c: CAPTURE_CALLS[method](c, OPTIONS)) + assert [e["options"] for e in events] == [OPTIONS] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", list(ASYNC_CAPTURE_CALLS)) +async def test_async_options_reach_the_wire(method): + events = await _async_wire_events(lambda c: ASYNC_CAPTURE_CALLS[method](c, OPTIONS)) + assert [e["options"] for e in events] == [OPTIONS] + + +def _hook(msg): + properties = {k: v for k, v in msg["properties"].items() if k != "$product_tour_id"} + return {**msg, "options": {"cookieless_mode": False}, "properties": properties} + + +def _hooked_capture(client): + return client.capture( + "e", + distinct_id="u", + properties={"$product_tour_id": "tour-1"}, + options={"cookieless_mode": True}, + ) + + +def test_sync_before_send_changes_options_and_legacy_properties(): + events = _sync_wire_events(_hooked_capture, before_send=_hook) + assert events[0]["options"] == {"cookieless_mode": False} + + +@pytest.mark.asyncio +async def test_async_before_send_changes_options_and_legacy_properties(): + events = await _async_wire_events(_hooked_capture, before_send=_hook) + assert events[0]["options"] == {"cookieless_mode": False} + + +def test_sync_non_dict_options_are_logged_and_event_is_sent(caplog): + with caplog.at_level(logging.ERROR, logger="posthog"): + events = _sync_wire_events( + lambda c: c.capture("e", distinct_id="u", options=["cookieless_mode"]) + ) + assert events[0]["options"] == {} + assert "options must be a dict" in caplog.text + + +@pytest.mark.asyncio +async def test_async_non_dict_options_are_logged_and_event_is_sent(caplog): + with caplog.at_level(logging.ERROR, logger="posthog"): + events = await _async_wire_events( + lambda c: c.capture("e", distinct_id="u", options=["cookieless_mode"]) + ) + assert events[0]["options"] == {} + assert "options must be a dict" in caplog.text + + +PP = "process_person_profile" + +# Layers, lowest first: personless (no distinct_id), super, context, event. +LAYER_CASES = { + "personless_alone": ({}, {}, {}, None, {PP: False}), + "identified_sends_no_option": ({}, {}, {}, "u", {}), + "super_beats_personless": ({PP: True}, {}, {}, None, {PP: True}), + "context_beats_super": ({PP: True}, {PP: False}, {}, "u", {PP: False}), + "event_beats_context": ({}, {PP: False}, {PP: True}, "u", {PP: True}), + "event_beats_all": ({PP: False}, {PP: False}, {PP: True}, None, {PP: True}), + "null_event_option_is_filled": ({PP: True}, {}, {PP: None}, "u", {PP: True}), + "layers_merge_by_key": ( + {"cookieless_mode": True}, + {"product_tour_id": "t"}, + {}, + "u", + {"cookieless_mode": True, "product_tour_id": "t"}, + ), +} + + +def _layered_capture(context_options, event_options, distinct_id): + def call(client): + with new_context(fresh=True): + for key, value in context_options.items(): + set_context_option(key, value) + return client.capture("e", distinct_id=distinct_id, options=event_options) + + return call + + +@pytest.mark.parametrize("case", list(LAYER_CASES)) +def test_sync_option_layers(case): + super_options, context, event, distinct_id, expected = LAYER_CASES[case] + events = _sync_wire_events( + _layered_capture(context, event, distinct_id), super_options=super_options + ) + assert events[0]["options"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", list(LAYER_CASES)) +async def test_async_option_layers(case): + super_options, context, event, distinct_id, expected = LAYER_CASES[case] + events = await _async_wire_events( + _layered_capture(context, event, distinct_id), super_options=super_options + ) + assert events[0]["options"] == expected + + +@pytest.mark.parametrize( + "method", ["capture", "set", "set_once", "group_identify", "alias"] +) +def test_sync_super_options_reach_every_path(method): + events = _sync_wire_events( + lambda c: CAPTURE_CALLS[method](c, None), super_options=OPTIONS + ) + assert [e["options"] for e in events] == [OPTIONS] + + +@pytest.mark.parametrize("method", ["capture", "set", "set_once"]) +def test_sync_context_options_reach_user_paths(method): + def call(client): + with new_context(fresh=True): + set_context_option("cookieless_mode", True) + return CAPTURE_CALLS[method](client, None) + + events = _sync_wire_events(call) + assert [e["options"] for e in events] == [{"cookieless_mode": True}] + + +def test_personless_option_beats_legacy_property(): + events = _sync_wire_events( + lambda c: c.capture("e", properties={"$process_person_profile": True}) + ) + assert events[0]["options"] == {PP: False} + assert "$process_person_profile" not in events[0]["properties"] + + +def test_legacy_super_property_fills_option_when_unset(): + events = _sync_wire_events( + lambda c: c.capture("e", distinct_id="u"), + super_properties={"$process_person_profile": False}, + ) + assert events[0]["options"] == {PP: False} + + +def _layered_properties(client): + with new_context(fresh=True): + tag("from_context", "context") + tag("shared", "context") + return client.capture( + "e", distinct_id="u", properties={"shared": "event", "only_event": 1} + ) + + +SUPER_PROPERTIES = {"shared": "super", "from_context": "super", "only_super": 1} + + +def test_sync_event_and_context_properties_beat_super_properties(): + events = _sync_wire_events(_layered_properties, super_properties=SUPER_PROPERTIES) + properties = events[0]["properties"] + assert ( + properties["shared"], + properties["from_context"], + properties["only_super"], + properties["only_event"], + ) == ("event", "context", 1, 1) + + +@pytest.mark.asyncio +async def test_async_event_and_context_properties_beat_super_properties(): + events = await _async_wire_events( + _layered_properties, super_properties=SUPER_PROPERTIES + ) + properties = events[0]["properties"] + assert ( + properties["shared"], + properties["from_context"], + properties["only_super"], + properties["only_event"], + ) == ("event", "context", 1, 1) + + +DEFAULTS_CONFIG = { + "super_properties": {"shared": "super", "only_super": 1}, + "super_options": {"cookieless_mode": True}, +} + + +def _recording_hook(seen): + def hook(msg): + seen.append((dict(msg["properties"]), dict(msg["options"]))) + removed = {"only_super", "$is_server"} + properties = { + **{k: v for k, v in msg["properties"].items() if k not in removed}, + "shared": "hook", + } + return {**msg, "properties": properties, "options": {"cookieless_mode": False}} + + return hook + + +def _set_context_values(): + tag("from_context", "context") + set_context_option("product_tour_id", "context-tour") + + +def _context_capture(method): + def call(client): + if method == "capture": + with new_context(fresh=True): + _set_context_values() + return client.capture("e", distinct_id="u") + + async def immediate(): + with new_context(fresh=True): + _set_context_values() + return await client.capture_immediate("e", distinct_id="u") + + return immediate() + + return call + + +def _assert_hook_sees_defaults_and_has_final_say(seen, events): + hook_properties, hook_options = seen[0] + assert ( + hook_properties["shared"], + hook_properties["only_super"], + hook_properties["from_context"], + hook_properties["$is_server"], + hook_properties["$geoip_disable"], + ) == ("super", 1, "context", True, True) + assert "$os" in hook_properties + assert hook_options == {"cookieless_mode": True, "product_tour_id": "context-tour"} + properties = events[0]["properties"] + assert (properties["shared"], properties["from_context"]) == ("hook", "context") + assert not {"only_super", "$is_server"} & properties.keys() + assert events[0]["options"] == {"cookieless_mode": False} + + +def test_sync_defaults_fill_before_before_send(): + seen: list = [] + events = _sync_wire_events( + _context_capture("capture"), + before_send=_recording_hook(seen), + **DEFAULTS_CONFIG, + ) + _assert_hook_sees_defaults_and_has_final_say(seen, events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["capture", "capture_immediate"]) +async def test_async_defaults_fill_before_before_send(method): + seen: list = [] + events = await _async_wire_events( + _context_capture(method), before_send=_recording_hook(seen), **DEFAULTS_CONFIG + ) + _assert_hook_sees_defaults_and_has_final_say(seen, events) + + +@pytest.mark.asyncio +async def test_async_queued_event_keeps_capture_time_defaults(): + def capture_then_change_defaults(client): + result = client.capture("e", distinct_id="d") + client.super_properties["plan"] = "pro" + client.super_options["cookieless_mode"] = True + return result + + events = await _async_wire_events( + capture_then_change_defaults, + super_properties={"plan": "free"}, + super_options={"cookieless_mode": False}, + ) + assert events[0]["properties"]["plan"] == "free" + assert events[0]["options"]["cookieless_mode"] is False + + +NESTED_SUPER_PROPERTIES = { + "$set": {"plan": "free", "source": "super"}, + "$groups": {"company": "acme", "team": "core", "region": "us"}, + "$unset": ["stale"], +} + + +def _nested_capture(client): + return client.capture( + "e", + distinct_id="u", + properties={ + "$set": {"plan": "pro"}, + "$unset": ["other"], + "$groups": {"company": "event", "team": "event"}, + }, + groups={"company": "posthog"}, + ) + + +def _assert_nested_properties(events): + properties = events[0]["properties"] + assert properties["$set"] == {"plan": "pro", "source": "super"} + assert properties["$groups"] == { + "company": "posthog", + "team": "event", + "region": "us", + } + assert properties["$unset"] == ["other"] + + +def test_nested_properties_fill_one_level_deep(): + events = _sync_wire_events( + _nested_capture, super_properties=NESTED_SUPER_PROPERTIES + ) + _assert_nested_properties(events) + + +@pytest.mark.asyncio +async def test_async_nested_properties_fill_one_level_deep(): + events = await _async_wire_events( + _nested_capture, super_properties=NESTED_SUPER_PROPERTIES + ) + _assert_nested_properties(events) + + +CALLER_SDK_VALUES = {"$is_server": False, "$geoip_disable": False, "$os": "caller-os"} +SDK_FLAGS = {"$is_server": False, "$geoip_disable": False} + + +def _capture_with_caller_sdk_values(source): + def call(client): + with new_context(fresh=True): + if source == "context": + for key, value in CALLER_SDK_VALUES.items(): + tag(key, value) + properties = CALLER_SDK_VALUES if source == "event" else None + return client.capture("e", distinct_id="u", properties=properties) + + return call + + +def _sdk_value_config(source): + return {"super_properties": CALLER_SDK_VALUES} if source == "super" else {} + + +def _assert_caller_values(events, expected): + properties = events[0]["properties"] + assert {key: properties.get(key) for key in expected} == expected + + +@pytest.mark.parametrize("source", ["event", "context", "super"]) +def test_sync_caller_values_beat_sdk_values(source): + events = _sync_wire_events( + _capture_with_caller_sdk_values(source), **_sdk_value_config(source) + ) + _assert_caller_values(events, CALLER_SDK_VALUES) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["event", "context", "super"]) +async def test_async_caller_values_beat_sdk_values(source): + events = await _async_wire_events( + _capture_with_caller_sdk_values(source), **_sdk_value_config(source) + ) + _assert_caller_values(events, CALLER_SDK_VALUES) + + +GEOIP_CALL_CASES = [ + pytest.param(True, "context", False, True, id="call_beats_context"), + pytest.param(True, "super", False, True, id="call_beats_super"), + pytest.param(False, "super", True, False, id="call_false_beats_super"), + pytest.param(False, None, None, False, id="call_false_beats_client_setting"), + pytest.param(True, "event", False, False, id="event_property_beats_call"), +] + + +def _capture_with_call_geoip(call_value, source, layer_value): + def call(client): + with new_context(fresh=True): + if source == "context": + tag("$geoip_disable", layer_value) + properties = {"$geoip_disable": layer_value} if source == "event" else None + return client.capture( + "e", distinct_id="u", properties=properties, disable_geoip=call_value + ) + + return call + + +def _geoip_layer_config(source, layer_value): + if source == "super": + return {"super_properties": {"$geoip_disable": layer_value}} + return {} + + +@pytest.mark.parametrize("call_value, source, layer_value, expected", GEOIP_CALL_CASES) +def test_sync_call_disable_geoip_is_an_event_value( + call_value, source, layer_value, expected +): + events = _sync_wire_events( + _capture_with_call_geoip(call_value, source, layer_value), + disable_geoip=True, + **_geoip_layer_config(source, layer_value), + ) + assert events[0]["properties"]["$geoip_disable"] is expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call_value, source, layer_value, expected", GEOIP_CALL_CASES) +async def test_async_call_disable_geoip_is_an_event_value( + call_value, source, layer_value, expected +): + events = await _async_wire_events( + _capture_with_call_geoip(call_value, source, layer_value), + disable_geoip=True, + **_geoip_layer_config(source, layer_value), + ) + assert events[0]["properties"]["$geoip_disable"] is expected + + +@pytest.mark.parametrize("method", list(CAPTURE_CALLS)) +def test_sync_super_properties_beat_sdk_values_on_every_path(method): + events = _sync_wire_events( + lambda c: CAPTURE_CALLS[method](c, None), super_properties=SDK_FLAGS + ) + _assert_caller_values(events, SDK_FLAGS) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", list(ASYNC_CAPTURE_CALLS)) +async def test_async_super_properties_beat_sdk_values_on_every_path(method): + events = await _async_wire_events( + lambda c: ASYNC_CAPTURE_CALLS[method](c, None), super_properties=SDK_FLAGS + ) + _assert_caller_values(events, SDK_FLAGS) + + +@pytest.mark.parametrize("method", ["set", "set_once"]) +def test_set_call_wins_over_super_person_properties(method): + key = f"${method}" + events = _sync_wire_events( + lambda c: getattr(c, method)(distinct_id="u", properties={"plan": "pro"}), + super_properties={key: {"plan": "free", "source": "super"}}, + ) + assert events[0]["properties"][key] == {"plan": "pro", "source": "super"} + + +def test_late_options_replace_and_remove_legacy_properties(): + def call(client): + with new_context(fresh=True): + set_context_option("product_tour_id", "context-tour") + return client.capture( + "e", distinct_id="u", properties={"$product_tour_id": "event-tour"} + ) + + events = _sync_wire_events( + call, + super_properties={"$cookieless_mode": False}, + super_options={"cookieless_mode": True}, + ) + assert events[0]["options"] == { + "cookieless_mode": True, + "product_tour_id": "context-tour", + } + assert not {"$cookieless_mode", "$product_tour_id"} & events[0]["properties"].keys() + + +def _personless_ai_capture(client): + with new_context(fresh=True): + set_context_option(PP, True) + return _capture_ai_event( + client, + "$ai_generation", + distinct_id="trace-1", + properties={"$process_person_profile": True}, + options={PP: True, "cookieless_mode": True}, + personless=True, + ) + + +@pytest.mark.parametrize( + "before_send, expected", + [ + (None, {PP: False, "cookieless_mode": True}), + (lambda m: {**m, "options": {PP: True}}, {PP: True}), + ], + ids=["sdk_option_wins", "before_send_can_change_it"], +) +def test_personless_ai_event_overrides_every_user_layer(before_send, expected): + events = _sync_wire_events( + _personless_ai_capture, before_send=before_send, super_options={PP: True} + ) + assert events[0]["options"] == expected diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index ef83c433f..cf7272709 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -157,6 +157,16 @@ def test_exception_hooks_delegate_and_restore_previous_hooks(monkeypatch): capture.close() assert client.capture_exception.call_count == 2 + assert client.capture_exception.call_args_list[0].kwargs["_capture_metadata"] == { + "level": "fatal", + "source": "python.sys_excepthook", + "mechanism": {"type": "onuncaughtexception", "handled": False}, + } + assert client.capture_exception.call_args_list[1].kwargs["_capture_metadata"] == { + "level": "error", + "source": "python.threading_excepthook", + "mechanism": {"type": "onuncaughtexception", "handled": False}, + } sys_hook.assert_called_once_with(*exc_info) thread_hook.assert_called_once_with(thread_args) assert sys.excepthook is sys_hook @@ -248,7 +258,7 @@ def test_uncaught_thread_exception_preserves_default_diagnostic(): from posthog.exception_capture import ExceptionCapture class Client: - def capture_exception(self, exception, distinct_id=None): + def capture_exception(self, exception, distinct_id=None, _capture_metadata=None): print(f"captured:{exception[0].__name__}") capture = ExceptionCapture(Client()) @@ -278,15 +288,10 @@ def test_excepthook(tmpdir): dedent( """ from posthog import Posthog - from requests import Response import posthog.request + from posthog.test.capture_helpers import offline_v1_post - def offline_post(url, **kwargs): - response = Response() - response.status_code = 200 - return response - - posthog.request._session.post = offline_post + posthog.request._session.post = offline_v1_post posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch)) # frame_value = "LOL" @@ -305,11 +310,13 @@ def offline_post(url, **kwargs): assert b"ZeroDivisionError" in output assert b"LOL" in output - assert b"DEBUG:posthog:[PostHog] data uploaded successfully" in output - assert ( - b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"' - in output - ) + assert b"[PostHog] capture v1 response" in output + assert b" ok=1 " in output + assert b'"type": "ZeroDivisionError", "value": "division by zero"' in output + assert b'"$exception_level": "fatal"' in output + assert b'"$exception_source": "python.sys_excepthook"' in output + assert b'"type": "onuncaughtexception"' in output + assert b'"handled": false' in output class _RootError(Exception): @@ -328,6 +335,10 @@ class _LeafTwo(Exception): pass +class _ExceptionWithMetadata(Exception): + exceptions = 1 + + def test_exception_list_canonical_order_explicit_cause(): # Canonical ordering: $exception_list[0] is the caught/outermost exception # and the root cause is last. For `raise B from A`, B is caught and A is the @@ -348,6 +359,19 @@ def test_exception_list_canonical_order_explicit_cause(): assert types == ["_WrapperError", "_RootError"] assert exceptions[0]["value"] == "wrapper" assert exceptions[-1]["value"] == "root" + assert exceptions[0]["mechanism"] == { + "type": "generic", + "handled": True, + "synthetic": False, + "exception_id": 0, + } + assert exceptions[1]["mechanism"] == { + "type": "chained", + "source": "cause", + "synthetic": False, + "exception_id": 1, + "parent_id": 0, + } def test_exception_list_canonical_order_implicit_context(): @@ -369,6 +393,20 @@ def test_exception_list_canonical_order_implicit_context(): assert types == ["_WrapperError", "_RootError"] assert exceptions[0]["value"] == "wrapper" assert exceptions[-1]["value"] == "root" + assert exceptions[1]["mechanism"]["source"] == "context" + + +def test_ordinary_exception_does_not_treat_exceptions_attribute_as_group_members(): + from posthog.exception_utils import exceptions_from_error_tuple + + try: + raise _ExceptionWithMetadata("ordinary") + except _ExceptionWithMetadata: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + assert [exception["type"] for exception in exceptions] == ["_ExceptionWithMetadata"] @pytest.mark.skipif( @@ -392,3 +430,96 @@ def test_exception_list_canonical_order_exception_group(): types = [e["type"] for e in exceptions] assert types[0] == "ExceptionGroup" assert types[1:] == ["_LeafOne", "_LeafTwo"] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_exception_group_serializes_a_repeated_object_only_once(): + from posthog.exception_utils import exceptions_from_error_tuple + + shared = _LeafOne("shared") + try: + raise ExceptionGroup("group", [shared, shared]) # noqa: F821 + except BaseException: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + assert [exception["value"] for exception in exceptions] == [ + "group", + "shared", + ] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_exception_group_member_inspection_budget_counts_duplicates(): + from posthog.exception_utils import exceptions_from_error_tuple + + shared = _LeafOne("shared") + excluded = _LeafTwo("after-budget") + group = ExceptionGroup( # noqa: F821 -- builtin on 3.11+ + "group", [shared] * 1_000 + [excluded] + ) + + exceptions = exceptions_from_error_tuple((type(group), group, None)) + + assert [exception["value"] for exception in exceptions] == ["group", "shared"] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_nested_exception_groups_share_member_inspection_budget(): + from posthog.exception_utils import exceptions_from_error_tuple + + shared = _LeafOne("shared") + inner_excluded = _LeafTwo("inner-after-budget") + outer_excluded = _LeafTwo("outer-after-budget") + inner = ExceptionGroup( # noqa: F821 -- builtin on 3.11+ + "inner", [shared] * 999 + [inner_excluded] + ) + outer = ExceptionGroup( # noqa: F821 -- builtin on 3.11+ + "outer", [inner, outer_excluded] + ) + + exceptions = exceptions_from_error_tuple((type(outer), outer, None)) + + assert [exception["value"] for exception in exceptions] == [ + "outer", + "inner", + "shared", + ] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_last_inspected_group_member_retains_its_cause_chain(): + from posthog.exception_utils import exceptions_from_error_tuple + + shared = _LeafOne("shared") + cause = _RootError("cause") + final_member = _LeafTwo("last-inspected") + final_member.__cause__ = cause + excluded = _LeafTwo("after-budget") + group = ExceptionGroup( # noqa: F821 -- builtin on 3.11+ + "group", [shared] * 999 + [final_member, excluded] + ) + + exceptions = exceptions_from_error_tuple((type(group), group, None)) + + assert [exception["value"] for exception in exceptions] == [ + "group", + "shared", + "last-inspected", + "cause", + ] + assert exceptions[-1]["mechanism"]["source"] == "cause" + assert exceptions[-1]["mechanism"]["parent_id"] == 2 diff --git a/posthog/test/test_feature_flag_called_minimization.py b/posthog/test/test_feature_flag_called_minimization.py index c32b4ea27..81cf76a1f 100644 --- a/posthog/test/test_feature_flag_called_minimization.py +++ b/posthog/test/test_feature_flag_called_minimization.py @@ -13,8 +13,10 @@ from parameterized import parameterized +from posthog.capture_event import _to_v1_event from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client from posthog.request import GetResponse +from posthog.test.capture_helpers import record_sync_capture_sends from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.utils import system_context @@ -64,21 +66,15 @@ def _local_flag_definition(has_experiment): class _CapturedEventsMixin: - """Builds a non-sending client whose fully-enriched events are captured via - ``before_send``, so tests assert the exact wire shape after every enrichment - step (system context, super properties, $lib, ...).""" + """Builds a sync client whose fully-enriched events are recorded at upload, + so tests assert the exact wire shape after every enrichment step (system + context, super properties, $lib, ...).""" def _make_client(self, **kwargs): - captured = [] - - def before_send(msg): - captured.append(msg) - return msg - + captured = record_sync_capture_sends(self) client = Client( FAKE_TEST_API_KEY, - send=False, - before_send=before_send, + sync_mode=True, super_properties={"app_version": "1.2.3"}, **kwargs, ) @@ -153,6 +149,34 @@ def test_gated_non_experiment_flag_with_groups_keeps_group_context( self.assertEqual(properties["$groups"], {"organization": "org-1"}) self.assertLessEqual(set(properties), _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES) + @mock.patch("posthog.client.flags") + def test_gated_event_keeps_option_sources(self, patch_flags): + patch_flags.return_value = _flags_response(has_experiment=False, gate=True) + client, captured = self._make_client() + client.super_properties = { + "$cookieless_mode": True, + "$ignore_sent_at": True, + "$product_tour_id": "tour-1", + "$window_id": "w-1", + "app_version": "1.2.3", + } + + client.get_feature_flag_result("person-flag", "some-distinct-id") + + event = _to_v1_event( + next(m for m in captured if m["event"] == "$feature_flag_called") + ) + self.assertEqual( + event["options"], + { + "cookieless_mode": True, + "disable_skew_correction": True, + "product_tour_id": "tour-1", + }, + ) + self.assertEqual(event["window_id"], "w-1") + self.assertNotIn("app_version", event["properties"]) + @mock.patch("posthog.client.flags") def test_remote_evaluation_uses_this_responses_gate_not_a_concurrent_update( self, patch_flags diff --git a/posthog/test/test_flag_definition_cache.py b/posthog/test/test_flag_definition_cache.py index 2d05997d4..d221245d9 100644 --- a/posthog/test/test_flag_definition_cache.py +++ b/posthog/test/test_flag_definition_cache.py @@ -17,6 +17,7 @@ ) from posthog.request import GetResponse from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import patch_capture_send class MockCacheProvider: @@ -94,8 +95,8 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase): @classmethod def setUpClass(cls): # Prevent real HTTP requests - cls.client_post_patcher = mock.patch("posthog.client.batch_post") - cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") + cls.client_post_patcher = patch_capture_send("client") + cls.consumer_post_patcher = patch_capture_send("consumer") cls.client_post_patcher.start() cls.consumer_post_patcher.start() diff --git a/posthog/test/test_gevent_compat.py b/posthog/test/test_gevent_compat.py index 34e0655bf..2948e9b2c 100644 --- a/posthog/test/test_gevent_compat.py +++ b/posthog/test/test_gevent_compat.py @@ -10,6 +10,7 @@ from posthog.client import Client, _new_lane_queue from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import patch_capture_send class TestLaneQueueFallback(unittest.TestCase): @@ -80,7 +81,7 @@ def test_incompatible_queue_does_not_disable_queue_independent_capabilities(self with ( mock.patch("posthog.client.Queue", return_value=incompatible_queue), mock.patch.dict(sys.modules, {"gevent.monkey": None}), - mock.patch("posthog.client.batch_post") as mock_post, + patch_capture_send("client") as mock_post, mock.patch( "posthog.client.flags", return_value={"featureFlags": {"beta-feature": True}}, @@ -113,9 +114,9 @@ def test_capture_and_flush_after_monkey_patching(self): "is not being exercised" ) - from unittest import mock + from posthog.test.capture_helpers import patch_capture_send - with mock.patch("posthog.consumer.batch_post") as mock_post: + with patch_capture_send("consumer") as mock_post: from posthog.client import Client client = Client("phc_test", flush_at=1, flush_interval=60) @@ -137,7 +138,7 @@ def test_capture_and_flush_after_monkey_patching(self): client.flush(timeout_seconds=10) client.join() - assert mock_post.called, "batch_post was never called" + assert mock_post.called, "capture send was never called" assert client.queue.empty(), "flush did not drain the queue" """ ) diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index dc875e674..e9d634dc3 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -7,6 +7,7 @@ import posthog from posthog import Posthog +from posthog.test.capture_helpers import patch_capture_send, sent_batch class TestModule(unittest.TestCase): @@ -17,7 +18,7 @@ def _assert_enqueue_result(self, result): self.assertTrue(result) def setUp(self): - patcher = mock.patch("posthog.consumer.batch_post") + patcher = patch_capture_send("consumer") self.transport = patcher.start() self.addCleanup(patcher.stop) self.on_error = mock.Mock() @@ -32,7 +33,7 @@ def test_track(self): self._assert_enqueue_result(res) self.posthog.flush() self.transport.assert_called_once() - event = self.transport.call_args.kwargs["batch"][0] + event = sent_batch(self.transport)[0] self.assertEqual(event["event"], "python module event") self.assertEqual(event["distinct_id"], "distinct_id") self.assertEqual(event["uuid"], res) @@ -42,7 +43,7 @@ def test_alias(self): self._assert_enqueue_result(res) self.posthog.flush() self.transport.assert_called_once() - event = self.transport.call_args.kwargs["batch"][0] + event = sent_batch(self.transport)[0] self.assertEqual(event["event"], "$create_alias") self.assertEqual(event["distinct_id"], "previousId") self.assertEqual(event["properties"]["alias"], "distinct_id") @@ -57,6 +58,15 @@ def test_module_flush_forwards_timeout(self): proxy.assert_called_once_with("flush", timeout_seconds=1.5) + def test_module_capture_exception_forwards_level(self): + error = ValueError("boom") + with mock.patch.object(posthog, "_proxy") as proxy: + posthog.capture_exception(error, level="warning") + + proxy.assert_called_once_with( + "capture_exception", exception=error, level="warning" + ) + class TestModuleLevelSetup(unittest.TestCase): def setUp(self): @@ -64,6 +74,7 @@ def setUp(self): self._original_api_key = posthog.api_key self._original_project_api_key = posthog.project_api_key self._original_project_root = posthog.project_root + self._original_super_options = posthog.super_options self._original_privacy_mode = posthog.privacy_mode self._original_disabled = posthog.disabled self._original_send = posthog.send @@ -80,6 +91,7 @@ def tearDown(self): posthog.api_key = self._original_api_key posthog.project_api_key = self._original_project_api_key posthog.project_root = self._original_project_root + posthog.super_options = self._original_super_options posthog.privacy_mode = self._original_privacy_mode posthog.disabled = self._original_disabled posthog.send = self._original_send @@ -103,6 +115,23 @@ def test_setup_configures_disabled_client_when_api_key_is_blank( self.assertTrue(client.disabled) self.assertIsNone(posthog.capture("Module Python Event", distinct_id="john")) + @parameterized.expand( + [ + ("api_key", "api_key"), + ("project_api_key", "project_api_key"), + ] + ) + def test_setup_keeps_client_disabled_for_personal_api_key(self, _name, setting): + setattr(posthog, setting, " phx_not_a_real_key ") + + client = posthog.setup() + + # Module calls re-run setup(), which must not re-enable the client. + self.assertIsNone(posthog.capture("Module Python Event", distinct_id="john")) + self.assertIsNone(posthog.capture("Module Python Event", distinct_id="john")) + self.assertIs(client, posthog.default_client) + self.assertTrue(client.disabled) + def test_setup_uses_project_api_key_when_api_key_is_unset(self): posthog.project_api_key = " phc_project_key " @@ -128,13 +157,19 @@ def test_setup_uses_api_key_when_project_api_key_is_blank(self): self.assertEqual(client.api_key, "phc_api_key") self.assertFalse(client.disabled) - def test_setup_propagates_project_root(self): + @parameterized.expand( + [ + ("project_root", "/path/to/project"), + ("super_options", {"cookieless_mode": True}), + ] + ) + def test_setup_propagates_config(self, setting, value): posthog.api_key = "phc_test" - posthog.project_root = "/path/to/project" + setattr(posthog, setting, value) client = posthog.setup() - self.assertEqual(client.project_root, "/path/to/project") + self.assertEqual(getattr(client, setting), value) def test_setup_propagates_and_updates_privacy_mode(self): posthog.api_key = "phc_test" @@ -168,6 +203,7 @@ def test_group_identify_propagates_distinct_id(self): "company_123", {"name": "Awesome Inc."}, distinct_id="user_456", + options={"cookieless_mode": True}, ) self.mock_client.group_identify.assert_called_once_with( group_type="company", @@ -177,6 +213,7 @@ def test_group_identify_propagates_distinct_id(self): uuid=None, disable_geoip=None, distinct_id="user_456", + options={"cookieless_mode": True}, ) def test_group_identify_distinct_id_defaults_to_none(self): diff --git a/posthog/test/test_posthog_upgrade_workflow.py b/posthog/test/test_posthog_upgrade_workflow.py new file mode 100644 index 000000000..a8b91ecaa --- /dev/null +++ b/posthog/test/test_posthog_upgrade_workflow.py @@ -0,0 +1,388 @@ +"""Exercise the inline downstream-upgrade selector without GitHub writes.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import textwrap + +import pytest + + +WORKFLOW = Path(__file__).resolve().parents[2] / ".github/workflows/posthog-upgrade.yml" +TITLE_PREFIX = "chore(deps): update posthoganalytics to " +FALLBACK = "posthoganalytics-upgrade-7.64.1" +RUNNING = "Running tests on this pull request" +WAITING = "waiting to start tests" + + +def step_source(name): + return ( + WORKFLOW.read_text() + .split(f" - name: {name}\n", 1)[1] + .split(" - name:", 1)[0] + ) + + +def selector_script(): + return textwrap.dedent( + step_source("Generate pull request details").split(" run: |\n", 1)[1] + ) + + +def pr(number=1, version="7.63.0", branch="posthoganalytics-7.63.0", **kwargs): + return { + "number": number, + "title": TITLE_PREFIX + version, + "headRefName": branch, + "headRepositoryOwner": {"login": "PostHog"}, + "isCrossRepository": False, + "createdAt": number, + **kwargs, + } + + +def comments(*statuses): + return [[{"user": {"login": "trunk-io[bot]"}, "body": s} for s in statuses]] + + +# Execute the workflow's jq filters, not an alternate implementation of them. +# The fake provider sorts only when the request explicitly asks it to. +GH_MOCK = r""" +import json +import os +import subprocess +import sys + +args = sys.argv[1:] +with open(os.environ["MOCK_CALLS"], "a") as file: + file.write(json.dumps(args) + "\n") +fixture = json.loads(os.environ["MOCK_FIXTURE"]) +if args[:2] == ["pr", "list"]: + assert args[args.index("--repo") + 1] == "PostHog/posthog" + assert args[args.index("--state") + 1] == "open" + records = fixture["prs"] + if "--head" in args: + if fixture.get("fallback_fail"): + sys.exit(1) + head = args[args.index("--head") + 1] + records = [p for p in records if p["headRefName"] == head] + else: + if fixture.get("list_fail"): + sys.exit(1) + assert args.index("--search") < args.index("--limit") + search = args[args.index("--search") + 1] + assert '"chore(deps): update posthoganalytics to " in:title' in search + if "sort:created-desc" in search: + records = sorted(records, key=lambda p: p["createdAt"], reverse=True) + records = records[:int(args[args.index("--limit") + 1])] +elif args[0] == "api": + assert "--paginate" in args and "--slurp" in args + number = args[1].split("/")[4] + if int(number) in fixture.get("api_fail", []): + sys.exit(1) + records = fixture.get("comments", {}).get(number, [[]]) +else: + raise AssertionError("Unexpected GitHub call: " + repr(args)) +result = subprocess.run( + ["jq", "-r", args[args.index("--jq") + 1]], + input=json.dumps(records), text=True, capture_output=True, check=True, +) +sys.stdout.write(result.stdout) +""" + +UV_MOCK = """ +import os +import sys + +assert sys.argv[1:6] == ["run", "--no-project", "--with", "packaging==25.0", "python"] +os.execv(sys.executable, [sys.executable, *sys.argv[6:]]) +""" + + +@pytest.fixture +def select(tmp_path): + missing = [tool for tool in ("bash", "jq") if shutil.which(tool) is None] + if missing: + pytest.skip( + f"Install {', '.join(missing)} and make it available on PATH " + "to run the workflow shell-entry tests." + ) + + for name, source in [("gh", GH_MOCK), ("uv", UV_MOCK)]: + executable = tmp_path / name + executable.write_text(f"#!{sys.executable}\n" + source) + executable.chmod(0o755) + + def run(prs=(), version="7.64.1", **fixture): + output = tmp_path / "output" + output.write_text("") + calls = tmp_path / "calls" + calls.write_text("") + env = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "PACKAGE_NAME": "posthoganalytics", + "PACKAGE_VERSION": version, + "GITHUB_OUTPUT": str(output), + "MOCK_CALLS": str(calls), + "MOCK_FIXTURE": json.dumps({"prs": prs, **fixture}), + } + result = subprocess.run( + [ + "bash", + "--noprofile", + "--norc", + "-e", + "-o", + "pipefail", + "-c", + selector_script(), + ], + env=env, + text=True, + capture_output=True, + timeout=15, + ) + requests = [json.loads(line) for line in calls.read_text().splitlines()] + # Every scenario must use explicit chronological search, including skips. + assert "sort:created-desc" in requests[0][requests[0].index("--search") + 1] + return result, output.read_text(), requests + + return run + + +def assert_branch(result, branch, version="7.64.1"): + process, output, _ = result + assert process.returncode == 0, process.stderr + assert "skip=false\n" in output + assert f"branch_name={branch}\n" in output + assert f"title={TITLE_PREFIX}{version}\n" in output + assert f"PostHog Python SDK version {version} has been released." in output + assert "`posthoganalytics` and `posthog`" in output + + +def assert_skip(result): + process, output, _ = result + assert process.returncode == 0, process.stderr + assert output == "skip=true\n" + + +def assert_failure(result): + process, output, _ = result + assert process.returncode != 0 + assert output == "" + + +@pytest.mark.parametrize( + "prs,branch", + [ + ([], "posthoganalytics-upgrade"), + ([pr()], "posthoganalytics-7.63.0"), + ([pr(branch="posthoganalytics-upgrade")], "posthoganalytics-upgrade"), + ([pr(headRepositoryOwner={"login": "someone"})], "posthoganalytics-upgrade"), + ([pr(title="chore(deps): update other to 1.0.0")], "posthoganalytics-upgrade"), + ], + ids=["no-pr", "legacy-branch", "stable-branch", "fork", "other-package"], +) +def test_branch_selection(select, prs, branch): + assert_branch(select(prs), branch) + + +@pytest.mark.parametrize("status", [RUNNING, WAITING]) +def test_queued_selected_pr_uses_version_branch(select, status): + assert_branch(select([pr()], comments={"1": comments(status)}), FALLBACK) + + +def test_unknown_selected_queue_status_checks_fallback(select): + result = select([pr()], api_fail=[1]) + assert_branch(result, FALLBACK) + assert any("--head" in call for call in result[2]) + + +def test_pr_lookup_failure_fails_safely(select): + assert_failure(select(list_fail=True)) + + +@pytest.mark.parametrize( + "incoming,current,skip", + [ + ("7.10", "7.9", False), + ("7.9", "7.10", True), + ("7.64.1", "7.64.1", False), + ("7.64.1.0", "7.64.1", False), + ("7.64.1", "7.64.1rc1", False), + ("7.64.1rc1", "7.64.1", True), + ("7.64.1rc2", "7.64.1rc1", False), + ("7.64.1rc1", "7.64.1rc2", True), + ], +) +def test_pep440_version_ordering(select, incoming, current, skip): + result = select([pr(version=current)], version=incoming) + if skip: + assert_skip(result) + else: + assert_branch(result, "posthoganalytics-7.63.0", incoming) + + +@pytest.mark.parametrize( + "incoming,current", [("7.64.1", "unknown"), ("unknown", "7.64.1"), ("7.64.1", "")] +) +def test_unparseable_comparison_fails_safely(select, incoming, current): + assert_failure(select([pr(version=current)], version=incoming)) + + +def test_newer_run_then_older_rerun_preserves_shared_branch_version(select): + existing = pr(branch="posthoganalytics-upgrade") + assert_branch(select([existing]), existing["headRefName"]) + existing["title"] = TITLE_PREFIX + "7.64.1" # Metadata after the newer run. + assert_skip(select([existing], version="7.64.0")) + assert existing["title"] == TITLE_PREFIX + "7.64.1" + + +def test_different_versions_share_reusable_branch(select): + existing = pr(branch="posthoganalytics-upgrade") + for version in ["7.64.0", "7.64.1"]: + assert_branch( + select([existing], version=version), existing["headRefName"], version + ) + + +def test_older_run_skips_even_if_newer_selected_pr_is_queued(select): + assert_skip(select([pr(version="7.64.2")], comments={"1": comments(RUNNING)})) + + +@pytest.mark.parametrize("unknown", [False, True], ids=["queued", "unknown"]) +def test_same_version_fallback_is_protected(select, unknown): + result = select( + [pr(version="7.64.1", branch=FALLBACK)], + comments={"1": comments(RUNNING)}, + api_fail=[1] if unknown else [], + ) + assert_skip(result) + assert any("--head" in call for call in result[2]) + + +@pytest.mark.parametrize("unknown", [False, True], ids=["queued", "unknown"]) +def test_older_fallback_collision_is_independently_protected(select, unknown): + result = select( + [pr(branch=FALLBACK), pr(2, version="7.64.1", branch="newest")], + comments={"1": comments(RUNNING), "2": comments(WAITING)}, + api_fail=[1] if unknown else [], + ) + assert_skip(result) + apis = [call[1] for call in result[2] if call[0] == "api"] + assert apis == [ + "repos/PostHog/posthog/issues/2/comments?per_page=100", + "repos/PostHog/posthog/issues/1/comments?per_page=100", + ] + + +def test_fallback_lookup_failure_skips(select): + assert_skip(select([pr()], comments={"1": comments(RUNNING)}, fallback_fail=True)) + + +def test_unqueued_fallback_is_reused(select): + assert_branch( + select( + [pr(branch=FALLBACK), pr(2, version="7.64.1", branch="newest")], + comments={ + "1": comments(RUNNING, "Removed from queue"), + "2": comments(RUNNING), + }, + ), + FALLBACK, + ) + + +@pytest.mark.parametrize("current", ["7.64.2", "invalid", ""]) +def test_fallback_destination_version_is_checked(select, current): + result = select( + [pr(version=current, branch=FALLBACK), pr(2, branch="newest")], + comments={"2": comments(RUNNING)}, + ) + if current == "7.64.2": + assert_skip(result) + else: + assert_failure(result) + + +def test_fallback_with_unrecognized_title_fails_safely(select): + assert_failure( + select( + [pr(branch=FALLBACK, title="Another change"), pr(2, branch="newest")], + comments={"2": comments(RUNNING)}, + ) + ) + + +def test_fallback_lookup_ignores_cross_repository_pr(select): + assert_branch( + select( + [pr(branch=FALLBACK, isCrossRepository=True), pr(2, branch="newest")], + comments={"1": comments(RUNNING), "2": comments(RUNNING)}, + ), + FALLBACK, + ) + + +def test_search_orders_before_newest_only_queue_selection(select): + result = select([pr(1, branch="older"), pr(2, branch="newest")]) + assert_branch(result, "newest") + assert [call[1] for call in result[2] if call[0] == "api"] == [ + "repos/PostHog/posthog/issues/2/comments?per_page=100" + ] + + +@pytest.mark.parametrize("cleared", [False, True]) +def test_queue_comments_are_paginated_and_latest_trunk_status_wins(select, cleared): + pages = comments(RUNNING) + pages[0].extend([{"user": {"login": "someone"}, "body": RUNNING}] * 99) + pages += comments("Removed from queue" if cleared else WAITING) + assert_branch( + select([pr()], comments={"1": pages}), + "posthoganalytics-7.63.0" if cleared else FALLBACK, + ) + + +def test_other_commenters_do_not_determine_queue_status(select): + assert_branch( + select( + [pr()], comments={"1": [[{"user": {"login": "someone"}, "body": RUNNING}]]} + ), + "posthoganalytics-7.63.0", + ) + + +def test_writers_share_non_cancelling_package_concurrency(): + source = WORKFLOW.read_text() + concurrency = source.split("\nconcurrency:\n", 1)[1].split("\njobs:", 1)[0] + assert ( + "group: posthog-upgrade-${{ github.event.inputs.package_name }}" in concurrency + ) + assert "cancel-in-progress: false" in concurrency + + +def test_remote_mutations_are_gated_and_use_selector_outputs(): + for name in [ + "Create main repo pull request", + "Update pull request metadata", + "Assign reviewers", + ]: + assert "if: steps.generate-pr-details.outputs.skip == 'false'" in step_source( + name + ) + create = step_source("Create main repo pull request") + for field, output in [ + ("branch", "branch_name"), + ("title", "title"), + ("body", "body"), + ]: + assert ( + f"{field}: ${{{{ steps.generate-pr-details.outputs.{output} }}}}" in create + ) + metadata = step_source("Update pull request metadata") + assert "PR_TITLE: ${{ steps.generate-pr-details.outputs.title }}" in metadata + assert "PR_BODY: ${{ steps.generate-pr-details.outputs.body }}" in metadata diff --git a/posthog/test/test_release_id.py b/posthog/test/test_release_id.py index 12b8e7e24..8d72a12bf 100644 --- a/posthog/test/test_release_id.py +++ b/posthog/test/test_release_id.py @@ -10,6 +10,10 @@ from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client from posthog.release_id import RELEASE_ID_ENV_VAR, _resolve_release_id from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.test.capture_helpers import ( + patch_async_capture_send, + record_sync_capture_sends, +) # (name, call, expected event): one row per public event-producing method, shared # by the sync and async clients. Each call builds its own arguments, because a @@ -79,17 +83,10 @@ def test_env_var_resolution(self, _name, env_value, expected) -> None: class TestClientReleaseId(unittest.TestCase): def _client(self, env_value, **kwargs): - """Build a client under `env_value` and collect the events it would send.""" - events = [] - - def before_send(msg): - events.append(msg) - return msg - + """Build a client under `env_value` and collect the events it sends.""" + events = record_sync_capture_sends(self) with _release_id_env(env_value): - client = Client( - FAKE_TEST_API_KEY, send=False, before_send=before_send, **kwargs - ) + client = Client(FAKE_TEST_API_KEY, sync_mode=True, **kwargs) return client, events @parameterized.expand(EVENT_CALLS) @@ -155,10 +152,10 @@ async def _async_events(env_value, send_events): """Build an async client under `env_value`, run `send_events`, return the batch.""" batches = [] - async def batch_post(*args, **kwargs): - batches.append(kwargs["batch"]) + async def send_batch(api_key, host, batch, **kwargs): + batches.append(batch) - with mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post): + with patch_async_capture_send(side_effect=send_batch): with _release_id_env(env_value): client = AsyncPosthog("test-key", flush_interval=30) async with client: diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index d4ad7c9da..c16e08f47 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -1,7 +1,5 @@ -import gzip import json import unittest -import zlib from datetime import date, datetime, timedelta from unittest import mock @@ -18,7 +16,6 @@ KEEP_ALIVE_SOCKET_OPTIONS, QuotaLimitError, _mask_tokens_in_url, - batch_post, determine_server_host, disable_connection_reuse, enable_keep_alive, @@ -132,47 +129,6 @@ def test_message_only_debug_logs_include_posthog_prefix(): class TestRequests(unittest.TestCase): - def test_valid_request(self): - response = requests.Response() - response.status_code = 200 - session = mock.Mock() - session.post.return_value = response - batch = [ - {"distinct_id": "distinct_id", "event": "python event", "type": "track"} - ] - - res = batch_post(TEST_API_KEY, batch=batch, session=session) - - self.assertIs(res, response) - session.post.assert_called_once() - self.assertTrue(session.post.call_args.args[0].endswith("/batch/")) - body = json.loads(session.post.call_args.kwargs["data"]) - self.assertEqual(body["batch"], batch) - self.assertEqual(body["api_key"], TEST_API_KEY) - - def test_invalid_request_error(self): - response = requests.Response() - response.status_code = 400 - response._content = b'{"detail": "Invalid batch"}' - session = mock.Mock() - session.post.return_value = response - - with self.assertRaises(APIError) as raised: - batch_post("testsecret", batch=[], session=session) - - self.assertEqual(raised.exception.status, 400) - self.assertEqual(raised.exception.message, "Invalid batch") - session.post.assert_called_once() - - def test_invalid_host(self): - self.assertRaises( - requests.exceptions.MissingSchema, - batch_post, - "testsecret", - "t.posthog.com/", - batch=[], - ) - def test_post_without_path_preserves_type_error(self): mock_session = mock.MagicMock() @@ -185,7 +141,7 @@ def test_post_without_path_preserves_type_error(self): mock_session.post.assert_not_called() - def test_post_sends_string_payload_without_gzip(self): + def test_post_sends_string_payload(self): mock_response = requests.Response() mock_response.status_code = 200 mock_session = mock.MagicMock() @@ -205,54 +161,6 @@ def test_post_sends_string_payload_without_gzip(self): self.assertEqual(url, "https://test.posthog.com/batch/") self.assertIsInstance(data, str) - def test_post_sends_bytes_payload_with_gzip(self): - mock_response = requests.Response() - mock_response.status_code = 200 - mock_session = mock.MagicMock() - mock_session.post.return_value = mock_response - - request_module.post( - TEST_API_KEY, - host="https://test.posthog.com", - path="/batch/", - gzip=True, - session=mock_session, - batch=[], - ) - - data = mock_session.post.call_args.kwargs["data"] - headers = mock_session.post.call_args.kwargs["headers"] - self.assertIsInstance(data, bytes) - self.assertEqual(headers["Content-Encoding"], "gzip") - body = json.loads(gzip.decompress(data)) - self.assertEqual(body["batch"], []) - self.assertEqual(body["api_key"], TEST_API_KEY) - - def test_post_falls_back_to_uncompressed_payload_when_gzip_fails(self): - for compression_error in [OSError("boom"), zlib.error("boom")]: - with self.subTest(compression_error=type(compression_error)): - mock_response = requests.Response() - mock_response.status_code = 200 - mock_session = mock.MagicMock() - mock_session.post.return_value = mock_response - - with mock.patch.object( - request_module, "GzipFile", side_effect=compression_error - ): - request_module.post( - TEST_API_KEY, - host="https://test.posthog.com", - path="/batch/", - gzip=True, - session=mock_session, - batch=[], - ) - - data = mock_session.post.call_args.kwargs["data"] - headers = mock_session.post.call_args.kwargs["headers"] - self.assertIsInstance(data, str) - self.assertNotIn("Content-Encoding", headers) - def test_datetime_serialization(self): data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)} result = json.dumps(data, cls=DatetimeSerializer) @@ -265,30 +173,6 @@ def test_date_serialization(self): expected = '{"created": "%s"}' % today.isoformat() self.assertEqual(result, expected) - def test_should_not_timeout(self): - response = requests.Response() - response.status_code = 200 - session = mock.Mock() - session.post.return_value = response - - self.assertIs( - batch_post(TEST_API_KEY, batch=[], timeout=7, session=session), response - ) - session.post.assert_called_once() - self.assertEqual(session.post.call_args.kwargs["timeout"], 7) - - def test_should_timeout(self): - error = requests.ReadTimeout("response deadline exceeded") - session = mock.Mock() - session.post.side_effect = error - - with self.assertRaises(requests.ReadTimeout) as raised: - batch_post("key", batch=[], timeout=1, session=session) - - self.assertIs(raised.exception, error) - session.post.assert_called_once() - self.assertEqual(session.post.call_args.kwargs["timeout"], 1) - def test_quota_limited_flags_response(self): mock_response = requests.Response() mock_response.status_code = 200 diff --git a/posthog/test/test_server_payload_snapshots.py b/posthog/test/test_server_payload_snapshots.py index 2874e0761..71f437edd 100644 --- a/posthog/test/test_server_payload_snapshots.py +++ b/posthog/test/test_server_payload_snapshots.py @@ -29,6 +29,16 @@ def _successful_response() -> requests.Response: return response +def _capture_ok_response(url, data=None, **kwargs) -> requests.Response: + response = requests.Response() + response.status_code = 200 + uuids = [event["uuid"] for event in json.loads(data)["batch"]] + response._content = json.dumps( + {"results": {uuid: {"result": "ok"} for uuid in uuids}} + ).encode() + return response + + def _has_test_file_suffix(value: str) -> bool: return value.replace("\\", "/").endswith(_TEST_FILE_SUFFIX) @@ -43,8 +53,10 @@ def _normalize_snapshot_value(value): for key, item in value.items(): if key == "$lib_version": normalized[key] = "" + elif key == "PostHog-Request-Id": + normalized[key] = "" elif ( - key == "User-Agent" + key in ("User-Agent", "PostHog-Sdk-Info") and isinstance(item, str) and _USER_AGENT_PATTERN.fullmatch(item) ): @@ -99,19 +111,18 @@ def _assert_json_snapshot(name: str, value) -> None: assert actual == expected -def _legacy_event_family_request(): +def _event_family_request(): session = mock.MagicMock() - session.post.return_value = _successful_response() + session.post.side_effect = _capture_ok_response with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.request._get_session", return_value=session), + mock.patch("posthog.capture_send._get_session", return_value=session), mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), ): client = Client( "phc_snapshot_project", host="https://example.posthog.test", - capture_mode="v0", flush_at=100, flush_interval=100, ) @@ -167,18 +178,17 @@ def _raise_snapshot_exception() -> None: def _exception_request(): session = mock.MagicMock() - session.post.return_value = _successful_response() + session.post.side_effect = _capture_ok_response with ( freeze_time(_FIXED_TIME), - mock.patch("posthog.request._get_session", return_value=session), + mock.patch("posthog.capture_send._get_session", return_value=session), mock.patch("posthog.client.system_context", return_value=_RUNTIME_CONTEXT), mock.patch("posthog.client._get_current_otel_span_properties", return_value={}), ): client = Client( "phc_snapshot_project", host="https://example.posthog.test", - capture_mode="v0", sync_mode=True, project_root=str(Path(__file__).parents[2]), capture_exception_code_variables=False, @@ -257,8 +267,8 @@ def test_does_not_normalize_unexpected_user_agent(user_agent): } -def test_legacy_capture_identify_alias_and_group_identify_request_snapshot(): - _assert_json_snapshot("legacy_event_family", _legacy_event_family_request()) +def test_capture_identify_alias_and_group_identify_request_snapshot(): + _assert_json_snapshot("event_family", _event_family_request()) def test_complete_exception_request_snapshot(): diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index e655c8cd5..d73525ee2 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -261,6 +261,32 @@ def test_clean_fn(self): def test_remove_slash(self, input_url, expected_url): assert expected_url == utils.remove_trailing_slash(input_url) + @parameterized.expand( + [ + ("random_ones", b"\xff" * 10, "01234567-89ab-7fff-bfff-ffffffffffff"), + ("random_zeros", b"\x00" * 10, "01234567-89ab-7000-8000-000000000000"), + ( + "random_mixed", + bytes(range(0x10, 0x1A)), + "01234567-89ab-7011-9213-141516171819", + ), + ] + ) + def test_uuid7_layout(self, _name, random_bytes, expected): + # Bit 48 of the millisecond clock is set to check that it is masked off. + time_ns = ((1 << 48) + 0x0123456789AB) * 1_000_000 + 999_999 + with ( + mock.patch.object(utils.time, "time_ns", return_value=time_ns), + mock.patch.object( + utils.os, + "urandom", + side_effect=lambda n: (random_bytes + b"\xaa" * n)[:n], + ), + ): + value = utils._uuid7() + self.assertEqual(str(value), expected) + self.assertEqual(value.version, 7) + def test_clean_pydantic(self): class ModelV2(BaseModel): foo: str @@ -328,11 +354,104 @@ def clean_or_raise(value): ): assert utils._clean_dict({"ok": 1, "bad": bad_value}) == {"ok": 1} - warning.assert_called_once() - assert warning.call_args.args[0] == ( - 'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.' + warning.assert_called_once_with( + 'Dictionary value for key "%s" of type %s is not JSON serializable, dropping it.', + "bad", + type(bad_value), + ) + + @parameterized.expand( + [ + ("missing", None, "missing"), + ("non_string", 123, "non-string"), + ("bytes", b"event", "non-string"), + ("empty", "", "empty"), + ("valid", "signed_up", None), + ("whitespace_is_a_name", " ", None), + ] + ) + def test_invalid_event_name_reason(self, _, event, expected): + assert utils._invalid_event_name_reason(event) == expected + + def test_drop_invalid_event_name_logs_each_reason_once(self): + logger = mock.Mock() + with mock.patch.object(utils, "_warned_reasons", set()): + dropped = [ + utils._drop_invalid_event_name(logger, msg) + for msg in ({"event": None}, {}, {"event": ""}, {"event": "ok"}) + ] + + assert dropped == [True, True, True, False] + message = ( + "Dropping an event with a %s name: event names must be non-empty strings. " + "Later events dropped for the same reason are not logged." + ) + assert logger.warning.call_args_list == [ + mock.call(message, "missing"), + mock.call(message, "empty"), + ] + + def test_drop_invalid_distinct_id_logs_each_reason_once(self): + logger = mock.Mock() + with mock.patch.object(utils, "_warned_reasons", set()): + dropped = [ + utils._drop_invalid_distinct_id(logger, msg) + for msg in ( + {"distinct_id": None}, + {}, + {"distinct_id": 123}, + {"distinct_id": ["a"]}, + {"distinct_id": "user"}, + {"distinct_id": ""}, + ) + ] + + assert dropped == [True, True, True, True, False, False] + message = ( + "Dropping an event with a %s distinct_id: distinct IDs must be strings. " + "Later events dropped for the same reason are not logged." + ) + assert logger.warning.call_args_list == [ + mock.call(message, "missing"), + mock.call(message, "non-string"), + ] + + def test_warn_once_logs_each_reason_once(self): + logger = mock.Mock() + with mock.patch.object(utils, "_warned_reasons", set()): + utils._warn_once(logger, "a", "first %s", 1) + utils._warn_once(logger, "a", "again %s", 2) + utils._warn_once(logger, "b", "second") + + assert logger.warning.call_args_list == [ + mock.call("first %s", 1), + mock.call("second"), + ] + + @parameterized.expand( + [ + ("personal", "phx_abc", True), + ("project", "phc_abc", False), + ("prefix_elsewhere", "abc_phx_", False), + ("empty", "", False), + ] + ) + def test_is_personal_api_key(self, _, api_key, expected): + assert utils._is_personal_api_key(api_key) is expected + + def test_clean_drops_only_keys_json_cannot_write(self): + cleaned = utils.clean( + { + ("t",): "tuple", + "s": 1, + 2: "int", + 1.5: "float", + True: "bool", + None: "none", + } ) - assert warning.call_args.args[1:] == ("bad", bad_value, type(bad_value)) + assert cleaned == {"s": 1, 2: "int", 1.5: "float", True: "bool", None: "none"} + json.dumps(cleaned) def test_coerce_unicode(self): assert utils._coerce_unicode("already unicode") == "already unicode" diff --git a/posthog/test/tracing/test_client_traces.py b/posthog/test/tracing/test_client_traces.py index ab9d0ee0e..f3b2b48c9 100644 --- a/posthog/test/tracing/test_client_traces.py +++ b/posthog/test/tracing/test_client_traces.py @@ -15,6 +15,7 @@ from posthog.tracing._transport import OK from posthog.tracing._span import NOOP_SPAN, RecordingSpan, Span from posthog.version import VERSION +from posthog.test.capture_helpers import patch_capture_send, sent_batch FAKE_API_KEY = "phc_test_key" @@ -671,10 +672,10 @@ def test_the_span_exit_drain_leaves_sync_mode_events_alone(self): client.start_span("x").end() with mock.patch("posthog.client._atexit_deadline", None): client._atexit_spans() - with mock.patch("posthog.client.batch_post") as batch_post: + with patch_capture_send("client") as batch_post: client.capture("after-exit", distinct_id="d") batch_post.assert_called_once() - assert batch_post.call_args[1]["batch"][0]["event"] == "after-exit" + assert sent_batch(batch_post)[0]["event"] == "after-exit" client.shutdown() @pytest.mark.parametrize("traces", [{}, None]) diff --git a/posthog/tracing/_export.py b/posthog/tracing/_export.py index e68385f78..3a3e4b398 100644 --- a/posthog/tracing/_export.py +++ b/posthog/tracing/_export.py @@ -12,7 +12,7 @@ import time from typing import Any, Callable, List, Optional, Tuple -from ..capture_v1 import _MAX_BACKOFF_SECONDS +from ..capture_send import _MAX_BACKOFF_SECONDS from ._config import ResolvedTracesConfig from ._drops import DropLog from ._limits import truncate_attributes diff --git a/posthog/utils.py b/posthog/utils.py index 05608983f..4f1d25517 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -1,6 +1,7 @@ import json import logging import numbers +import os import re import threading import time @@ -51,6 +52,21 @@ def guess_timezone(dt: datetime) -> datetime: return dt.astimezone(timezone.utc) +def _uuid7() -> UUID: + """Return a time-ordered version 7 UUID (RFC 9562). + + Capture generates v7 server-side when a client omits the uuid, so + SDK-generated event uuids sort the same way. + """ + unix_ms = time.time_ns() // 1_000_000 + # Python 3.10 requires the byte order; from 3.11 "big" is the default. + random_bits = int.from_bytes(os.urandom(10), "big") # pragma: no mutate + value = ((unix_ms & ((1 << 48) - 1)) << 80) | random_bits + value = (value & ~(0xF << 76)) | (0x7 << 76) + value = (value & ~(0x3 << 62)) | (0x2 << 62) + return UUID(int=value) + + def _normalize_timestamp(timestamp: Union[datetime, str]) -> str: """Normalize a datetime or parseable ISO datetime string to UTC.""" parsed_timestamp: datetime @@ -120,16 +136,88 @@ def _clean_list(list_): return [clean(item) for item in list_] +_warned_reasons: set[str] = set() +_warned_reasons_lock = threading.Lock() + + +def _warn_once(logger: logging.Logger, reason: str, message: str, *args: Any) -> None: + """Log `message` the first time `reason` occurs in this process, then stay silent. + + For drops caused by caller input, which can repeat at event rate. + """ + with _warned_reasons_lock: + if reason in _warned_reasons: + return + _warned_reasons.add(reason) + logger.warning(message, *args) + + +def _is_personal_api_key(api_key: str) -> bool: + """A personal API key (phx_...) is never a project API key; capture rejects it.""" + return api_key.startswith("phx_") + + +def _invalid_event_name_reason(event: Any) -> Optional[str]: + """Why capture would reject `event` as an event name, or None if it is valid.""" + if event is None: + return "missing" + if not isinstance(event, str): + return "non-string" + if not event: + return "empty" + return None + + +def _drop_invalid_event_name(logger: logging.Logger, msg: dict) -> bool: + """True when `msg` has an event name capture would reject; logs once per reason.""" + reason = _invalid_event_name_reason(msg.get("event")) + if reason is None: + return False + _warn_once( + logger, + f"event_name_{reason}", # pragma: no mutate + "Dropping an event with a %s name: event names must be non-empty strings. " + "Later events dropped for the same reason are not logged.", + reason, + ) + return True + + +def _drop_invalid_distinct_id(logger: logging.Logger, msg: dict) -> bool: + """True when `msg` has no string distinct_id; logs once per reason. + + Runs after before_send, which may set any value; the SDK never converts it there. + """ + distinct_id = msg.get("distinct_id") + if isinstance(distinct_id, str): + return False + reason = "missing" if distinct_id is None else "non-string" + _warn_once( + logger, + f"distinct_id_{reason}", # pragma: no mutate + "Dropping an event with a %s distinct_id: distinct IDs must be strings. " + "Later events dropped for the same reason are not logged.", + reason, + ) + return True + + +# The key types json.dumps can write; it converts the non-string ones to strings. +_JSON_KEY_TYPES = (str, int, float, bool, type(None)) + + def _clean_dict(dict_): data = {} for k, v in dict_.items(): + # Drop only a key json cannot write, so one bad key never costs the event. + if not isinstance(k, _JSON_KEY_TYPES): + continue try: data[k] = clean(v) except TypeError: log.warning( - 'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.', + 'Dictionary value for key "%s" of type %s is not JSON serializable, dropping it.', k, - v, type(v), ) return data diff --git a/posthog/version.py b/posthog/version.py index 570836b74..af1970c85 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1 +1 @@ -VERSION = "7.64.1" +VERSION = "7.68.0" diff --git a/pyproject.toml b/pyproject.toml index 17052d31f..037c8b5fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "posthog" -version = "7.64.1" +version = "7.68.0" description = "Integrate PostHog into any python application." authors = [{ name = "PostHog", email = "engineering@posthog.com" }] maintainers = [{ name = "PostHog", email = "engineering@posthog.com" }] @@ -24,7 +24,6 @@ classifiers = [ ] dependencies = [ "requests>=2.7,<3.0", - "backoff>=1.10.0", "distro>=1.5.0", "typing-extensions>=4.2.0", ] @@ -69,6 +68,7 @@ dev = [ ] test = [ "freezegun==1.5.1", + "flask>=2.2", "python-dateutil>=2.9.0.post0", "tzdata", "coverage", @@ -77,6 +77,7 @@ test = [ "pytest-asyncio", "jsonschema>=4.0", "django>=5.2.15,<6.0", + "djangorestframework>=3.15,<4", "openai-agents>=0.18", "anthropic>=0.72", "langgraph>=1.0", diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md new file mode 100644 index 000000000..1612117d4 --- /dev/null +++ b/references/middleware_configuration.md @@ -0,0 +1,69 @@ +# Request integration configuration + +ASGI, Flask, and Django enrich events captured during an active request with request +properties and available identity/session context. Enrichment does not create a +request event or redirect manually captured events to a different client. + +## Shared options + +| ASGI / Flask option | Django setting | Meaning | +| --- | --- | --- | +| `client` | `POSTHOG_MW_CLIENT` | Client for automatic exceptions; defaults to the global client. | +| `capture_exceptions` | `POSTHOG_MW_CAPTURE_EXCEPTIONS` | `True`: capture automatically; `False`: context only; `None`: inherit the effective client's `enable_exception_autocapture`. | +| `request_filter` | `POSTHOG_MW_REQUEST_FILTER` | Callback returning `False` to skip both enrichment and automatic capture for a request. | +| `extra_properties` | `POSTHOG_MW_EXTRA_PROPERTIES` | Callback returning additional event properties. Explicit properties passed to event capture take precedence. | +| `trust_tracing_headers` | `POSTHOG_MW_TRUST_TRACING_HEADERS` | Accept `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` only when `True`; defaults to `False`. | + +ASGI callbacks receive the ASGI scope and can be synchronous or asynchronous. +Flask callbacks receive the Flask request. Django callbacks receive the Django +`HttpRequest`. + +ASGI and Flask default `capture_exceptions` to `None`, so installing an integration +alone does not enable error tracking on a client that has it disabled. Client +inheritance is resolved when an exception occurs, so client configuration performed +after middleware registration is respected. The flag +controls integration-level automatic capture, not manual `capture_exception()` +calls or other independently installed error-capture hooks. + +## Django compatibility + +Django retains its historical automatic exception capture when +`POSTHOG_MW_CAPTURE_EXCEPTIONS` is omitted. Set it to `None` to inherit the client's +configuration, or `False` for context-only behavior. + +`POSTHOG_MW_EXTRA_TAGS` remains an alias for `POSTHOG_MW_EXTRA_PROPERTIES`, and +`POSTHOG_MW_TAG_MAP` remains an alias for `POSTHOG_MW_PROPERTIES_MAP`. The latter is +a Django-specific callback that transforms the extracted properties. When both +old and new settings are present, the new setting takes precedence (including +when set to `None` to disable the callback). Existing tag-named middleware methods +and attributes remain compatibility aliases. + +Tracing headers now require opt-in in Django and Flask, matching ASGI's secure +default. Applications intentionally relying on browser-to-backend analytics +attribution must enable the trust option. Sanitization limits malformed input; +it does not establish authenticity. Never use these headers for authentication or +authorization. Django can still use its authenticated request user as identity +when tracing headers are ignored. + +## Django REST Framework + +DRF handles many exceptions before Django middleware can see them, so its +PostHog exception handler is configured separately. It does not establish request +context: use the Django middleware for enrichment, request properties, and header +policy. + +An explicit `create_exception_handler(capture_exceptions=True/False)` overrides the +Django setting. Otherwise the handler follows `POSTHOG_MW_CAPTURE_EXCEPTIONS`, +including `None` for client inheritance and the omitted legacy `True` default. +Its client precedence is explicit handler client, `POSTHOG_DRF_CLIENT` (legacy +handler-specific setting), `POSTHOG_MW_CLIENT`, then the global client. +The middleware request filter also applies to handled DRF errors. + +## Lifetime and isolation + +Context is read when an event is captured, before it is queued for transport. +It does not depend on a later flush occurring inside the request. Outside the +request scope it is restored. Background threads do not automatically inherit +request context; async tasks follow Python's context-variable propagation rules. +Flask isolates identity and request properties while retaining effective enclosing +exception code-variable privacy controls. diff --git a/references/posthog-python-references-7.65.0.json b/references/posthog-python-references-7.65.0.json new file mode 100644 index 000000000..12adf4e4a --- /dev/null +++ b/references/posthog-python-references-7.65.0.json @@ -0,0 +1,3548 @@ +{ + "id": "posthog-python", + "hogRef": "0.3", + "info": { + "version": "7.65.0", + "id": "posthog-python", + "title": "PostHog Python SDK", + "description": "Integrate PostHog into any python application.", + "slugPrefix": "posthog-python", + "specUrl": "https://github.com/PostHog/posthog-python" + }, + "types": [ + { + "id": "FeatureFlag", + "name": "FeatureFlag", + "path": "posthog.types.FeatureFlag", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "reason", + "type": "Optional[FlagReason]", + "description": "Field: reason" + }, + { + "name": "metadata", + "type": "Union[FlagMetadata, LegacyFlagMetadata]", + "description": "Field: metadata" + } + ], + "example": "" + }, + { + "id": "FeatureFlagResult", + "name": "FeatureFlagResult", + "path": "posthog.types.FeatureFlagResult", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "payload", + "type": "Optional[Any]", + "description": "Field: payload" + }, + { + "name": "reason", + "type": "Optional[str]", + "description": "Field: reason" + } + ], + "example": "" + }, + { + "id": "FlagMetadata", + "name": "FlagMetadata", + "path": "posthog.types.FlagMetadata", + "properties": [ + { + "name": "id", + "type": "int", + "description": "Field: id" + }, + { + "name": "payload", + "type": "Optional[str]", + "description": "Field: payload" + }, + { + "name": "version", + "type": "int", + "description": "Field: version" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + }, + { + "name": "has_experiment", + "type": "Optional[bool]", + "description": "Field: has_experiment" + } + ], + "example": "" + }, + { + "id": "FlagReason", + "name": "FlagReason", + "path": "posthog.types.FlagReason", + "properties": [ + { + "name": "code", + "type": "str", + "description": "Field: code" + }, + { + "name": "condition_index", + "type": "Optional[int]", + "description": "Field: condition_index" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + } + ], + "example": "" + }, + { + "id": "FlagsAndPayloads", + "name": "FlagsAndPayloads", + "path": "posthog.types.FlagsAndPayloads", + "properties": [ + { + "name": "featureFlags", + "type": "Optional[dict[str, Union[bool, str]]]", + "description": "Field: featureFlags" + }, + { + "name": "featureFlagPayloads", + "type": "Optional[dict[str, Any]]", + "description": "Field: featureFlagPayloads" + } + ], + "example": "" + }, + { + "id": "FlagsResponse", + "name": "FlagsResponse", + "path": "posthog.types.FlagsResponse", + "properties": [ + { + "name": "flags", + "type": "dict[str, FeatureFlag]", + "description": "Field: flags" + }, + { + "name": "errorsWhileComputingFlags", + "type": "bool", + "description": "Field: errorsWhileComputingFlags" + }, + { + "name": "requestId", + "type": "str", + "description": "Field: requestId" + }, + { + "name": "quotaLimit", + "type": "Optional[list[str]]", + "description": "Field: quotaLimit" + }, + { + "name": "evaluatedAt", + "type": "Optional[int]", + "description": "Field: evaluatedAt" + }, + { + "name": "minimalFlagCalledEvents", + "type": "bool", + "description": "Field: minimalFlagCalledEvents" + } + ], + "example": "" + }, + { + "id": "LegacyFlagMetadata", + "name": "LegacyFlagMetadata", + "path": "posthog.types.LegacyFlagMetadata", + "properties": [ + { + "name": "payload", + "type": "Any", + "description": "Field: payload" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + }, + { + "id": "OptionalCaptureArgs", + "name": "OptionalCaptureArgs", + "path": "posthog.args.OptionalCaptureArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "groups", + "type": "NotRequired[Optional[dict[str, str]]]", + "description": "Field: groups" + }, + { + "name": "flags", + "type": "NotRequired[Optional[ForwardRef('FeatureFlagEvaluations')]]", + "description": "Field: flags" + }, + { + "name": "send_feature_flags", + "type": "NotRequired[Union[bool, SendFeatureFlagsOptions, any]]", + "description": "Field: send_feature_flags" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + }, + { + "name": "_property_allowlist", + "type": "NotRequired[Optional[frozenset[str]]]", + "description": "Field: _property_allowlist" + } + ], + "example": "" + }, + { + "id": "OptionalSetArgs", + "name": "OptionalSetArgs", + "path": "posthog.args.OptionalSetArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + } + ], + "classes": [ + { + "id": "PostHog", + "title": "PostHog", + "description": "This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python). You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.", + "functions": [ + { + "id": "__init__", + "title": "Client", + "description": "Initialize a new PostHog client instance.", + "details": "", + "category": "Initialization", + "params": [ + { + "name": "project_api_key", + "description": "PostHog project API key/token.", + "isOptional": true, + "type": "str" + }, + { + "name": "host", + "description": "PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as ``https://us.posthog.com`` are mapped to the corresponding ingestion host.", + "isOptional": false, + "type": "any" + }, + { + "name": "debug", + "description": "Enable verbose SDK logging and re-raise errors from public API methods.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_queue_size", + "description": "Maximum number of events buffered before upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "send", + "description": "If False, queueing succeeds but events are not sent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "on_error", + "description": "Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback.", + "isOptional": false, + "type": "any" + }, + { + "name": "flush_at", + "description": "Number of queued events that triggers a batch upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "flush_interval", + "description": "Maximum seconds a background consumer waits before flushing a partial batch.", + "isOptional": false, + "type": "float" + }, + { + "name": "gzip", + "description": "Whether to gzip event upload payloads.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_retries", + "description": "Number of upload retries. Values below 0 are treated as 0.", + "isOptional": false, + "type": "int" + }, + { + "name": "sync_mode", + "description": "If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead.", + "isOptional": false, + "type": "bool" + }, + { + "name": "timeout", + "description": "HTTP request timeout in seconds for event uploads.", + "isOptional": false, + "type": "int" + }, + { + "name": "thread", + "description": "Number of background consumer threads.", + "isOptional": false, + "type": "int" + }, + { + "name": "poll_interval", + "description": "Seconds between local feature flag definition refreshes.", + "isOptional": false, + "type": "int" + }, + { + "name": "personal_api_key", + "description": "Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also accepts a Project Secret API Key.", + "isOptional": false, + "type": "any" + }, + { + "name": "disabled", + "description": "If True, disable captures and API requests. Useful in tests.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable server-side GeoIP enrichment. Defaults to True.", + "isOptional": false, + "type": "bool" + }, + { + "name": "is_server", + "description": "Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "historical_migration", + "description": "Mark events as historical migration imports.", + "isOptional": false, + "type": "bool" + }, + { + "name": "feature_flags_request_timeout_seconds", + "description": "Timeout in seconds for feature flag and remote config requests.", + "isOptional": false, + "type": "int" + }, + { + "name": "feature_flags_request_max_retries", + "description": "Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.", + "isOptional": false, + "type": "int" + }, + { + "name": "super_properties", + "description": "Properties merged into every captured event.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_exception_autocapture", + "description": "Automatically capture uncaught exceptions.", + "isOptional": false, + "type": "bool" + }, + { + "name": "log_captured_exceptions", + "description": "Also log exceptions captured by error tracking.", + "isOptional": false, + "type": "bool" + }, + { + "name": "project_root", + "description": "Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.", + "isOptional": false, + "type": "any" + }, + { + "name": "privacy_mode", + "description": "For AI observability, capture usage metadata without prompt inputs or outputs.", + "isOptional": false, + "type": "bool" + }, + { + "name": "before_send", + "description": "Optional callback that can modify or drop events before upload. Return ``None`` to drop an event.", + "isOptional": false, + "type": "any" + }, + { + "name": "flag_fallback_cache_url", + "description": "Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_local_evaluation", + "description": "Whether to poll feature flag definitions for local evaluation when a personal API key is configured.", + "isOptional": false, + "type": "bool" + }, + { + "name": "flag_definition_cache_provider", + "description": "Optional external cache provider for sharing feature flag definitions across workers.", + "isOptional": true, + "type": "FlagDefinitionCacheProvider" + }, + { + "name": "capture_exception_code_variables", + "description": "Capture local variable values on exception stack frames.", + "isOptional": false, + "type": "bool" + }, + { + "name": "code_variables_mask_patterns", + "description": "Variable-name patterns to mask when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_ignore_patterns", + "description": "Variable-name patterns to omit when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_mask_url_credentials", + "description": "Scrub credentials embedded in URLs/DSNs (e.g. ``user:pass@host``) from captured code variables, regardless of the surrounding variable name. Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_detect_secrets", + "description": "Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "in_app_modules", + "description": "Module/package prefixes treated as in-app frames in captured exceptions.", + "isOptional": false, + "type": "UnionType[list[str], any]" + }, + { + "name": "enable_exception_autocapture_rate_limiting", + "description": "Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.", + "isOptional": false, + "type": "bool" + }, + { + "name": "exception_autocapture_bucket_size", + "description": "Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_rate", + "description": "Tokens restored per refill interval for each exception type's bucket.", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_interval_seconds", + "description": "Seconds between token refills for autocaptured exception rate limiting.", + "isOptional": false, + "type": "int" + }, + { + "name": "capture_mode", + "description": "Capture wire protocol to use. Defaults to ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` (or pass the string ``\"v1\"``) to opt into ``/i/v1/analytics/events``. When omitted, the ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``.", + "isOptional": false, + "type": "CaptureMode" + }, + { + "name": "capture_compression", + "description": "Request-body compression for capture-v1 uploads (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the strings ``\"gzip\"``/``\"deflate\"``). When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then the legacy ``gzip`` flag, then no compression.", + "isOptional": false, + "type": "CaptureCompression" + }, + { + "name": "secret_key", + "description": "A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key=\"phx_...\")", + "isOptional": false, + "type": "any" + }, + { + "name": "metrics", + "description": "", + "isOptional": true, + "type": "dict" + }, + { + "name": "enable_full_ai_capture", + "description": "Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. ``privacy_mode`` always wins. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_trace_context", + "description": "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. Explicit ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "_use_ai_lane", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "_enable_multimodal_capture", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "traces", + "description": "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.", + "isOptional": true, + "type": "dict" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import Posthog\n\nposthog = Posthog('', host='')" + } + ] + }, + { + "id": "alias", + "title": "alias", + "description": "Create an alias between two distinct IDs.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.alias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "The event name to capture.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Anonymous event", + "code": "# Anonymous event\nposthog.capture('some-anon-event')" + }, + { + "id": "example_2", + "name": "Context usage", + "code": "# Context usage\nfrom posthog import identify_context, new_context\nwith new_context():\n identify_context('distinct_id_of_the_user')\n posthog.capture('user_signed_up')\n posthog.capture('user_logged_in')\n posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')" + }, + { + "id": "example_3", + "name": "Set event properties", + "code": "# Set event properties\nposthog.capture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + }, + { + "id": "example_4", + "name": "Page view event", + "code": "# Page view event\nposthog.capture('$pageview', distinct_id=\"distinct_id_of_the_user\", properties={'$current_url': 'https://example.com'})" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + } + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as ``$trace_id`` and ``$span_id`` event properties.", + "details": "", + "category": "Error Tracking", + "params": [ + { + "name": "exception", + "description": "The exception to capture.", + "isOptional": true, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "try:\n # Some code that might fail\n pass\nexcept Exception as e:\n posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` and reason ``\"Evaluated locally\"``.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If True, never fall back to remote evaluation \u2014 flags that can't be evaluated locally are simply omitted from the snapshot.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is True. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flags = posthog.evaluate_flags(\n \"user_123\",\n person_properties={\"plan\": \"enterprise\"},\n)\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\nposthog.capture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Check if a feature flag is enabled for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "flush", + "title": "flush", + "description": "Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.", + "details": "", + "category": null, + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely. Queued spans are sent at the same time, within the same", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.capture('event_name')\nposthog.flush() # Ensures the event is sent immediately" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all feature flags for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flags and their payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags_and_payloads('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get multivariate feature flag value for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nruntime = posthog.get_feature_flag_evaluation_runtime(\"my-flag\")\nif runtime is FeatureFlagEvaluationRuntime.SERVER:\n ..." + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate. A flag set to ``FeatureFlagEvaluationRuntime.ALL`` suits either runtime, so it is returned for ``CLIENT`` and for ``SERVER``, and asking for ``ALL`` returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "The runtime to match, as a ``FeatureFlagEvaluationRuntime`` or its string value.", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nclient_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(\n FeatureFlagEvaluationRuntime.CLIENT\n)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload for a feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "The specific flag value to get payload for.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Deprecated. Use get_feature_flag() instead if you need events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\n\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')\nif flag_result and flag_result.get_value() == 'variant-key':\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = flag_result.payload" + } + ] + }, + { + "id": "get_feature_flags_and_payloads", + "title": "get_feature_flags_and_payloads", + "description": "Get feature flags and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "result = posthog.get_feature_flags_and_payloads('')" + } + ] + }, + { + "id": "get_feature_payloads", + "title": "get_feature_payloads", + "description": "Get feature flag payloads for a user, preserving valid serialized JSON.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Optional[str]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "payloads = posthog.get_feature_payloads('')" + } + ] + }, + { + "id": "get_feature_variants", + "title": "get_feature_variants", + "description": "Get feature flag variants for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Union[bool, str]]" + } + }, + { + "id": "get_flags_decision", + "title": "get_flags_decision", + "description": "Get feature flags decision.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsResponse" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "decision = posthog.get_flags_decision('user123')" + } + ] + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The remote config feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context.", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Identify a group and set its properties.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "A dictionary of properties to set on the group.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": false, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user performing the action.", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.group_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and end the consumer threads. Do not use directly, call `shutdown()` instead. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.join()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flags for local evaluation.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.load_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "with client.new_context():\n client.identify_context('')\n client.capture('event_name')" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the wrapped function using this client.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a person profile.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set with distinct id", + "code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a person profile only if they haven't been set before.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})" + } + ] + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. 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 invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.shutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "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.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog = Posthog(\"\", traces={\"service_name\": \"checkout-api\"})\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value.", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + } + ] + }, + { + "id": "PostHogModule", + "title": "PostHog Module Functions", + "description": "Global functions available in the PostHog module", + "functions": [ + { + "id": "alias", + "title": "alias", + "description": "Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "details": "To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like \"Which marketing channels leads to users churning after a month?\" or \"What do users do on our website before signing up?\". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The unique ID of the user before", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The current unique id", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Alias user", + "code": "# Alias user\nfrom posthog import alias\nalias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Capture anything a user does within your system.", + "details": "Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name to specify the event **kwargs: Optional arguments including:", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Context and capture usage", + "code": "# Context and capture usage\nfrom posthog import new_context, identify_context, tag_context, capture\n# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)\nwith new_context():\n # Associate this context with some user, by distinct_id\n identify_context('some user')\n\n # Capture an event, associated with the context-level distinct ID ('some user')\n capture('movie started')\n\n # Capture an event associated with some other user (overriding the context-level distinct ID)\n capture('movie joined', distinct_id='some-other-user')\n\n # Capture an event with some properties\n capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})\n\n # Capture an event with some properties\n capture('purchase', properties={'product_id': '123', 'category': 'romcom'})\n # Capture an event with some associated group\n capture('purchase', groups={'company': 'id:5'})\n\n # Adding a tag to the current context will cause it to appear on all subsequent events\n tag_context('some-tag', 'some-value')\n\n capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict" + }, + { + "id": "example_2", + "name": "Set event properties", + "code": "# Set event properties\nfrom posthog import capture\ncapture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name, normally one of the `$ai_*` event names. **kwargs: Same optional arguments as `capture()`.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import capture_ai\n\nuuid = capture_ai(\n \"$ai_generation\",\n distinct_id=\"user_123\",\n properties={\"$ai_model\": \"gpt-5\"},\n)" + } + ] + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture exceptions that happen in your code.", + "details": "Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the \"traceback\"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError(\"Some Error\"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.", + "category": "Events", + "params": [ + { + "name": "exception", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", + "isOptional": false, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Capture exception", + "code": "# Capture exception\nfrom posthog import capture_exception\ntry:\n risky_operation()\nexcept Exception as e:\n capture_exception(e)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to ``capture()`` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If ``True``, never fall back to remote evaluation and omit flags that cannot be evaluated locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import evaluate_flags, capture\nflags = evaluate_flags(\"user_123\", person_properties={\"plan\": \"enterprise\"})\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\ncapture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Use feature flags to enable or disable features for users.", + "details": "You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Boolean feature flag", + "code": "# Boolean feature flag\nfrom posthog import feature_enabled, get_feature_flag_payload\nis_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Returns loaded feature flags.", + "details": "Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import feature_flag_definitions\ndefinitions = feature_flag_definitions()" + } + ] + }, + { + "id": "flush", + "title": "flush", + "description": "Tell the client to flush all queued events.", + "details": "", + "category": "Client management", + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely.", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import flush\nflush()" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all flags for a given user.", + "details": "Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate (evaluates all if None)", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "All flags for user", + "code": "# All flags for user\nfrom posthog import get_all_flags\nget_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flag values and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate. Evaluates all flags when omitted.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + } + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get feature flag variant for users. Used with experiments.", + "details": "`groups` are a mapping from group type to group key. So, if you have a group type of \"organization\" and a group key of \"5\", you would pass groups={\"organization\": \"5\"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type \"organization\" and the group key \"5\", with the properties name, and employee count, you'll send these as: group_properties={\"organization\": {\"name\": \"PostHog\", \"employees\": 11}}.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping from group type to group key", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties in format { group_type_name: { group_properties } }", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Multivariate feature flag", + "code": "# Multivariate feature flag\nfrom posthog import get_feature_flag, get_feature_flag_payload\nenabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key':\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "Reads the `evaluation_runtime` each flag definition carries, so no extra request is made. Returns `None` when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports `FeatureFlagEvaluationRuntime.ALL`, the default PostHog applies.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime\nruntime = get_feature_flag_evaluation_runtime(\"my-flag\")" + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate.", + "details": "A flag set to `FeatureFlagEvaluationRuntime.ALL` suits either runtime, so it is returned for `CLIENT` and for `SERVER`. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime\nclient_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload associated with a feature flag value. Deprecated for new code. Prefer ``evaluate_flags()`` and ``flags.get_flag_payload(key)`` so flag evaluation happens once per request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "Optional flag value to use when selecting a payload.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + } + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + } + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The key of the feature flag", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Set properties on a group.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "Type of your group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "Properties to set on the group", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "Optional distinct ID of the user performing the action", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Group identify", + "code": "# Group identify\nfrom posthog import group_identify\ngroup_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import identify_context\nidentify_context(\"user_123\")" + } + ] + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and stop the client's background workers. Use `shutdown()` directly in most cases. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import join\njoin()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flag definitions from PostHog.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import load_feature_flags\nload_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context scope that will be active for the duration of the with block.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + }, + { + "name": "client", + "description": "Optional Posthog client instance to use for this context (default: None)", + "isOptional": true, + "type": "Client" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import new_context, tag, capture\nwith new_context():\n tag(\"request_id\", \"123\")\n capture(\"event_name\", properties={\"property\": \"value\"})" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the function.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import scoped, tag, capture\n@scoped()\ndef process_payment(payment_id):\n tag(\"payment_id\", payment_id)\n capture(\"payment_started\")" + } + ] + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a user record.", + "details": "This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set person properties", + "code": "# Set person properties\nfrom posthog import set\nset(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_capture_exception_code_variables_context", + "title": "set_capture_exception_code_variables_context", + "description": "Override code-variable capture for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "Whether exceptions captured in this context should include local variable values from stack frames.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_detect_secrets_context", + "title": "set_code_variables_detect_secrets_context", + "description": "Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_ignore_patterns_context", + "title": "set_code_variables_ignore_patterns_context", + "description": "Override code-variable ignore patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "ignore_patterns", + "description": "Variable-name patterns that should be omitted entirely when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_patterns_context", + "title": "set_code_variables_mask_patterns_context", + "description": "Override code-variable mask patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "mask_patterns", + "description": "Variable-name patterns whose values should be replaced with ``***`` when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_url_credentials_context", + "title": "set_code_variables_mask_url_credentials_context", + "description": "Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.", + "details": "", + "category": null, + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_device_id\nset_context_device_id(\"device_123\")" + } + ] + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_session\nset_context_session(\"session_123\")" + } + ] + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a user record, only if they do not yet exist.", + "details": "This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set property once", + "code": "# Set property once\nfrom posthog import set_once\nset_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})" + } + ] + }, + { + "id": "setup", + "title": "setup", + "description": "Create or return the global PostHog client configured by module settings. Most applications should either instantiate ``Posthog`` directly or set ``posthog.api_key``/other module settings before calling top-level helpers. ``setup()`` is called automatically by global APIs such as ``capture()``. Returns: The global ``Client`` instance. If both ``api_key`` and ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops.", + "details": "", + "category": "Initialization", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Client" + } + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as ``on_error`` are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call ``shutdown()`` from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import shutdown\nshutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "import posthog\nposthog.traces = {\"service_name\": \"checkout-api\"}\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import tag\ntag(\"user_id\", \"123\")" + } + ] + } + ] + } + ], + "categories": [ + "Initialization", + "Identification", + "Capture", + "Error Tracking", + "Feature flags", + "Tracing", + "Contexts", + "Events", + "Client management" + ] +} \ No newline at end of file diff --git a/references/posthog-python-references-7.66.0.json b/references/posthog-python-references-7.66.0.json new file mode 100644 index 000000000..2f919329f --- /dev/null +++ b/references/posthog-python-references-7.66.0.json @@ -0,0 +1,3548 @@ +{ + "id": "posthog-python", + "hogRef": "0.3", + "info": { + "version": "7.66.0", + "id": "posthog-python", + "title": "PostHog Python SDK", + "description": "Integrate PostHog into any python application.", + "slugPrefix": "posthog-python", + "specUrl": "https://github.com/PostHog/posthog-python" + }, + "types": [ + { + "id": "FeatureFlag", + "name": "FeatureFlag", + "path": "posthog.types.FeatureFlag", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "reason", + "type": "Optional[FlagReason]", + "description": "Field: reason" + }, + { + "name": "metadata", + "type": "Union[FlagMetadata, LegacyFlagMetadata]", + "description": "Field: metadata" + } + ], + "example": "" + }, + { + "id": "FeatureFlagResult", + "name": "FeatureFlagResult", + "path": "posthog.types.FeatureFlagResult", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "payload", + "type": "Optional[Any]", + "description": "Field: payload" + }, + { + "name": "reason", + "type": "Optional[str]", + "description": "Field: reason" + } + ], + "example": "" + }, + { + "id": "FlagMetadata", + "name": "FlagMetadata", + "path": "posthog.types.FlagMetadata", + "properties": [ + { + "name": "id", + "type": "int", + "description": "Field: id" + }, + { + "name": "payload", + "type": "Optional[str]", + "description": "Field: payload" + }, + { + "name": "version", + "type": "int", + "description": "Field: version" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + }, + { + "name": "has_experiment", + "type": "Optional[bool]", + "description": "Field: has_experiment" + } + ], + "example": "" + }, + { + "id": "FlagReason", + "name": "FlagReason", + "path": "posthog.types.FlagReason", + "properties": [ + { + "name": "code", + "type": "str", + "description": "Field: code" + }, + { + "name": "condition_index", + "type": "Optional[int]", + "description": "Field: condition_index" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + } + ], + "example": "" + }, + { + "id": "FlagsAndPayloads", + "name": "FlagsAndPayloads", + "path": "posthog.types.FlagsAndPayloads", + "properties": [ + { + "name": "featureFlags", + "type": "Optional[dict[str, Union[bool, str]]]", + "description": "Field: featureFlags" + }, + { + "name": "featureFlagPayloads", + "type": "Optional[dict[str, Any]]", + "description": "Field: featureFlagPayloads" + } + ], + "example": "" + }, + { + "id": "FlagsResponse", + "name": "FlagsResponse", + "path": "posthog.types.FlagsResponse", + "properties": [ + { + "name": "flags", + "type": "dict[str, FeatureFlag]", + "description": "Field: flags" + }, + { + "name": "errorsWhileComputingFlags", + "type": "bool", + "description": "Field: errorsWhileComputingFlags" + }, + { + "name": "requestId", + "type": "str", + "description": "Field: requestId" + }, + { + "name": "quotaLimit", + "type": "Optional[list[str]]", + "description": "Field: quotaLimit" + }, + { + "name": "evaluatedAt", + "type": "Optional[int]", + "description": "Field: evaluatedAt" + }, + { + "name": "minimalFlagCalledEvents", + "type": "bool", + "description": "Field: minimalFlagCalledEvents" + } + ], + "example": "" + }, + { + "id": "LegacyFlagMetadata", + "name": "LegacyFlagMetadata", + "path": "posthog.types.LegacyFlagMetadata", + "properties": [ + { + "name": "payload", + "type": "Any", + "description": "Field: payload" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + }, + { + "id": "OptionalCaptureArgs", + "name": "OptionalCaptureArgs", + "path": "posthog.args.OptionalCaptureArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "groups", + "type": "NotRequired[Optional[dict[str, str]]]", + "description": "Field: groups" + }, + { + "name": "flags", + "type": "NotRequired[Optional[ForwardRef('FeatureFlagEvaluations')]]", + "description": "Field: flags" + }, + { + "name": "send_feature_flags", + "type": "NotRequired[Union[bool, SendFeatureFlagsOptions, any]]", + "description": "Field: send_feature_flags" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + }, + { + "name": "_property_allowlist", + "type": "NotRequired[Optional[frozenset[str]]]", + "description": "Field: _property_allowlist" + } + ], + "example": "" + }, + { + "id": "OptionalSetArgs", + "name": "OptionalSetArgs", + "path": "posthog.args.OptionalSetArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + } + ], + "classes": [ + { + "id": "PostHog", + "title": "PostHog", + "description": "This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python). You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.", + "functions": [ + { + "id": "__init__", + "title": "Client", + "description": "Initialize a new PostHog client instance.", + "details": "", + "category": "Initialization", + "params": [ + { + "name": "project_api_key", + "description": "PostHog project API key/token.", + "isOptional": true, + "type": "str" + }, + { + "name": "host", + "description": "PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as ``https://us.posthog.com`` are mapped to the corresponding ingestion host.", + "isOptional": false, + "type": "any" + }, + { + "name": "debug", + "description": "Enable verbose SDK logging and re-raise errors from public API methods.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_queue_size", + "description": "Maximum number of events buffered before upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "send", + "description": "If False, queueing succeeds but events are not sent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "on_error", + "description": "Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback.", + "isOptional": false, + "type": "any" + }, + { + "name": "flush_at", + "description": "Number of queued events that triggers a batch upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "flush_interval", + "description": "Maximum seconds a background consumer waits before flushing a partial batch.", + "isOptional": false, + "type": "float" + }, + { + "name": "gzip", + "description": "Whether to gzip event upload payloads.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_retries", + "description": "Number of upload retries. Values below 0 are treated as 0.", + "isOptional": false, + "type": "int" + }, + { + "name": "sync_mode", + "description": "If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead.", + "isOptional": false, + "type": "bool" + }, + { + "name": "timeout", + "description": "HTTP request timeout in seconds for event uploads.", + "isOptional": false, + "type": "int" + }, + { + "name": "thread", + "description": "Number of background consumer threads.", + "isOptional": false, + "type": "int" + }, + { + "name": "poll_interval", + "description": "Seconds between local feature flag definition refreshes.", + "isOptional": false, + "type": "int" + }, + { + "name": "personal_api_key", + "description": "Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also accepts a Project Secret API Key.", + "isOptional": false, + "type": "any" + }, + { + "name": "disabled", + "description": "If True, disable captures and API requests. Useful in tests.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable server-side GeoIP enrichment. Defaults to True.", + "isOptional": false, + "type": "bool" + }, + { + "name": "is_server", + "description": "Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "historical_migration", + "description": "Mark events as historical migration imports.", + "isOptional": false, + "type": "bool" + }, + { + "name": "feature_flags_request_timeout_seconds", + "description": "Timeout in seconds for feature flag and remote config requests.", + "isOptional": false, + "type": "int" + }, + { + "name": "feature_flags_request_max_retries", + "description": "Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.", + "isOptional": false, + "type": "int" + }, + { + "name": "super_properties", + "description": "Properties merged into every captured event.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_exception_autocapture", + "description": "Automatically capture uncaught exceptions.", + "isOptional": false, + "type": "bool" + }, + { + "name": "log_captured_exceptions", + "description": "Also log exceptions captured by error tracking.", + "isOptional": false, + "type": "bool" + }, + { + "name": "project_root", + "description": "Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.", + "isOptional": false, + "type": "any" + }, + { + "name": "privacy_mode", + "description": "For AI observability, capture usage metadata without prompt inputs or outputs.", + "isOptional": false, + "type": "bool" + }, + { + "name": "before_send", + "description": "Optional callback that can modify or drop events before upload. Return ``None`` to drop an event.", + "isOptional": false, + "type": "any" + }, + { + "name": "flag_fallback_cache_url", + "description": "Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_local_evaluation", + "description": "Whether to poll feature flag definitions for local evaluation when a personal API key is configured.", + "isOptional": false, + "type": "bool" + }, + { + "name": "flag_definition_cache_provider", + "description": "Optional external cache provider for sharing feature flag definitions across workers.", + "isOptional": true, + "type": "FlagDefinitionCacheProvider" + }, + { + "name": "capture_exception_code_variables", + "description": "Capture local variable values on exception stack frames.", + "isOptional": false, + "type": "bool" + }, + { + "name": "code_variables_mask_patterns", + "description": "Variable-name patterns to mask when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_ignore_patterns", + "description": "Variable-name patterns to omit when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_mask_url_credentials", + "description": "Scrub credentials embedded in URLs/DSNs (e.g. ``user:pass@host``) from captured code variables, regardless of the surrounding variable name. Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_detect_secrets", + "description": "Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "in_app_modules", + "description": "Module/package prefixes treated as in-app frames in captured exceptions.", + "isOptional": false, + "type": "UnionType[list[str], any]" + }, + { + "name": "enable_exception_autocapture_rate_limiting", + "description": "Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.", + "isOptional": false, + "type": "bool" + }, + { + "name": "exception_autocapture_bucket_size", + "description": "Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_rate", + "description": "Tokens restored per refill interval for each exception type's bucket.", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_interval_seconds", + "description": "Seconds between token refills for autocaptured exception rate limiting.", + "isOptional": false, + "type": "int" + }, + { + "name": "capture_mode", + "description": "Capture wire protocol to use. Defaults to ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` (or pass the string ``\"v1\"``) to opt into ``/i/v1/analytics/events``. When omitted, the ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``.", + "isOptional": false, + "type": "CaptureMode" + }, + { + "name": "capture_compression", + "description": "Request-body compression for capture-v1 uploads (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the strings ``\"gzip\"``/``\"deflate\"``). When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then the legacy ``gzip`` flag, then no compression.", + "isOptional": false, + "type": "CaptureCompression" + }, + { + "name": "secret_key", + "description": "A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key=\"phx_...\")", + "isOptional": false, + "type": "any" + }, + { + "name": "metrics", + "description": "", + "isOptional": true, + "type": "dict" + }, + { + "name": "enable_full_ai_capture", + "description": "Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. ``privacy_mode`` always wins. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_trace_context", + "description": "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. Explicit ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "_use_ai_lane", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "_enable_multimodal_capture", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "traces", + "description": "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.", + "isOptional": true, + "type": "dict" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import Posthog\n\nposthog = Posthog('', host='')" + } + ] + }, + { + "id": "alias", + "title": "alias", + "description": "Create an alias between two distinct IDs.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.alias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "The event name to capture.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Anonymous event", + "code": "# Anonymous event\nposthog.capture('some-anon-event')" + }, + { + "id": "example_2", + "name": "Context usage", + "code": "# Context usage\nfrom posthog import identify_context, new_context\nwith new_context():\n identify_context('distinct_id_of_the_user')\n posthog.capture('user_signed_up')\n posthog.capture('user_logged_in')\n posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')" + }, + { + "id": "example_3", + "name": "Set event properties", + "code": "# Set event properties\nposthog.capture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + }, + { + "id": "example_4", + "name": "Page view event", + "code": "# Page view event\nposthog.capture('$pageview', distinct_id=\"distinct_id_of_the_user\", properties={'$current_url': 'https://example.com'})" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + } + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as ``$trace_id`` and ``$span_id`` event properties.", + "details": "", + "category": "Error Tracking", + "params": [ + { + "name": "exception", + "description": "The exception to capture.", + "isOptional": true, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "try:\n # Some code that might fail\n pass\nexcept Exception as e:\n posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` and reason ``\"Evaluated locally\"``.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If True, never fall back to remote evaluation \u2014 flags that can't be evaluated locally are simply omitted from the snapshot.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is True. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flags = posthog.evaluate_flags(\n \"user_123\",\n person_properties={\"plan\": \"enterprise\"},\n)\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\nposthog.capture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Check if a feature flag is enabled for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "flush", + "title": "flush", + "description": "Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.", + "details": "", + "category": null, + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely. Queued spans are sent at the same time, within the same", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.capture('event_name')\nposthog.flush() # Ensures the event is sent immediately" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all feature flags for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flags and their payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags_and_payloads('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get multivariate feature flag value for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nruntime = posthog.get_feature_flag_evaluation_runtime(\"my-flag\")\nif runtime is FeatureFlagEvaluationRuntime.SERVER:\n ..." + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate. A flag set to ``FeatureFlagEvaluationRuntime.ALL`` suits either runtime, so it is returned for ``CLIENT`` and for ``SERVER``, and asking for ``ALL`` returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "The runtime to match, as a ``FeatureFlagEvaluationRuntime`` or its string value.", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nclient_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(\n FeatureFlagEvaluationRuntime.CLIENT\n)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload for a feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "The specific flag value to get payload for.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Deprecated. Use get_feature_flag() instead if you need events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\n\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')\nif flag_result and flag_result.get_value() == 'variant-key':\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = flag_result.payload" + } + ] + }, + { + "id": "get_feature_flags_and_payloads", + "title": "get_feature_flags_and_payloads", + "description": "Get feature flags and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "result = posthog.get_feature_flags_and_payloads('')" + } + ] + }, + { + "id": "get_feature_payloads", + "title": "get_feature_payloads", + "description": "Get feature flag payloads for a user, preserving valid serialized JSON.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Optional[str]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "payloads = posthog.get_feature_payloads('')" + } + ] + }, + { + "id": "get_feature_variants", + "title": "get_feature_variants", + "description": "Get feature flag variants for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Union[bool, str]]" + } + }, + { + "id": "get_flags_decision", + "title": "get_flags_decision", + "description": "Get feature flags decision.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsResponse" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "decision = posthog.get_flags_decision('user123')" + } + ] + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The remote config feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context.", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Identify a group and set its properties.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "A dictionary of properties to set on the group.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": false, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user performing the action.", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.group_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and end the consumer threads. Do not use directly, call `shutdown()` instead. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.join()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flags for local evaluation.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.load_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "with client.new_context():\n client.identify_context('')\n client.capture('event_name')" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the wrapped function using this client.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a person profile.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set with distinct id", + "code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a person profile only if they haven't been set before.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})" + } + ] + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. 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 invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.shutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "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.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog = Posthog(\"\", traces={\"service_name\": \"checkout-api\"})\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value.", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + } + ] + }, + { + "id": "PostHogModule", + "title": "PostHog Module Functions", + "description": "Global functions available in the PostHog module", + "functions": [ + { + "id": "alias", + "title": "alias", + "description": "Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "details": "To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like \"Which marketing channels leads to users churning after a month?\" or \"What do users do on our website before signing up?\". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The unique ID of the user before", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The current unique id", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Alias user", + "code": "# Alias user\nfrom posthog import alias\nalias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Capture anything a user does within your system.", + "details": "Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name to specify the event **kwargs: Optional arguments including:", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Context and capture usage", + "code": "# Context and capture usage\nfrom posthog import new_context, identify_context, tag_context, capture\n# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)\nwith new_context():\n # Associate this context with some user, by distinct_id\n identify_context('some user')\n\n # Capture an event, associated with the context-level distinct ID ('some user')\n capture('movie started')\n\n # Capture an event associated with some other user (overriding the context-level distinct ID)\n capture('movie joined', distinct_id='some-other-user')\n\n # Capture an event with some properties\n capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})\n\n # Capture an event with some properties\n capture('purchase', properties={'product_id': '123', 'category': 'romcom'})\n # Capture an event with some associated group\n capture('purchase', groups={'company': 'id:5'})\n\n # Adding a tag to the current context will cause it to appear on all subsequent events\n tag_context('some-tag', 'some-value')\n\n capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict" + }, + { + "id": "example_2", + "name": "Set event properties", + "code": "# Set event properties\nfrom posthog import capture\ncapture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name, normally one of the `$ai_*` event names. **kwargs: Same optional arguments as `capture()`.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import capture_ai\n\nuuid = capture_ai(\n \"$ai_generation\",\n distinct_id=\"user_123\",\n properties={\"$ai_model\": \"gpt-5\"},\n)" + } + ] + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture exceptions that happen in your code.", + "details": "Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the \"traceback\"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError(\"Some Error\"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.", + "category": "Events", + "params": [ + { + "name": "exception", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", + "isOptional": false, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Capture exception", + "code": "# Capture exception\nfrom posthog import capture_exception\ntry:\n risky_operation()\nexcept Exception as e:\n capture_exception(e)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to ``capture()`` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If ``True``, never fall back to remote evaluation and omit flags that cannot be evaluated locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import evaluate_flags, capture\nflags = evaluate_flags(\"user_123\", person_properties={\"plan\": \"enterprise\"})\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\ncapture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Use feature flags to enable or disable features for users.", + "details": "You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Boolean feature flag", + "code": "# Boolean feature flag\nfrom posthog import feature_enabled, get_feature_flag_payload\nis_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Returns loaded feature flags.", + "details": "Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import feature_flag_definitions\ndefinitions = feature_flag_definitions()" + } + ] + }, + { + "id": "flush", + "title": "flush", + "description": "Tell the client to flush all queued events.", + "details": "", + "category": "Client management", + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely.", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import flush\nflush()" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all flags for a given user.", + "details": "Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate (evaluates all if None)", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "All flags for user", + "code": "# All flags for user\nfrom posthog import get_all_flags\nget_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flag values and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate. Evaluates all flags when omitted.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + } + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get feature flag variant for users. Used with experiments.", + "details": "`groups` are a mapping from group type to group key. So, if you have a group type of \"organization\" and a group key of \"5\", you would pass groups={\"organization\": \"5\"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type \"organization\" and the group key \"5\", with the properties name, and employee count, you'll send these as: group_properties={\"organization\": {\"name\": \"PostHog\", \"employees\": 11}}.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping from group type to group key", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties in format { group_type_name: { group_properties } }", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Multivariate feature flag", + "code": "# Multivariate feature flag\nfrom posthog import get_feature_flag, get_feature_flag_payload\nenabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key':\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "Reads the `evaluation_runtime` each flag definition carries, so no extra request is made. Returns `None` when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports `FeatureFlagEvaluationRuntime.ALL`, the default PostHog applies.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime\nruntime = get_feature_flag_evaluation_runtime(\"my-flag\")" + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate.", + "details": "A flag set to `FeatureFlagEvaluationRuntime.ALL` suits either runtime, so it is returned for `CLIENT` and for `SERVER`. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime\nclient_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload associated with a feature flag value. Deprecated for new code. Prefer ``evaluate_flags()`` and ``flags.get_flag_payload(key)`` so flag evaluation happens once per request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "Optional flag value to use when selecting a payload.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + } + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + } + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The key of the feature flag", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Set properties on a group.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "Type of your group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "Properties to set on the group", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "Optional distinct ID of the user performing the action", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Group identify", + "code": "# Group identify\nfrom posthog import group_identify\ngroup_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import identify_context\nidentify_context(\"user_123\")" + } + ] + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and stop the client's background workers. Use `shutdown()` directly in most cases. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import join\njoin()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flag definitions from PostHog.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import load_feature_flags\nload_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context scope that will be active for the duration of the with block.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + }, + { + "name": "client", + "description": "Optional Posthog client instance to use for this context (default: None)", + "isOptional": true, + "type": "Client" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import new_context, tag, capture\nwith new_context():\n tag(\"request_id\", \"123\")\n capture(\"event_name\", properties={\"property\": \"value\"})" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the function.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import scoped, tag, capture\n@scoped()\ndef process_payment(payment_id):\n tag(\"payment_id\", payment_id)\n capture(\"payment_started\")" + } + ] + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a user record.", + "details": "This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set person properties", + "code": "# Set person properties\nfrom posthog import set\nset(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_capture_exception_code_variables_context", + "title": "set_capture_exception_code_variables_context", + "description": "Override code-variable capture for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "Whether exceptions captured in this context should include local variable values from stack frames.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_detect_secrets_context", + "title": "set_code_variables_detect_secrets_context", + "description": "Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_ignore_patterns_context", + "title": "set_code_variables_ignore_patterns_context", + "description": "Override code-variable ignore patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "ignore_patterns", + "description": "Variable-name patterns that should be omitted entirely when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_patterns_context", + "title": "set_code_variables_mask_patterns_context", + "description": "Override code-variable mask patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "mask_patterns", + "description": "Variable-name patterns whose values should be replaced with ``***`` when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_url_credentials_context", + "title": "set_code_variables_mask_url_credentials_context", + "description": "Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.", + "details": "", + "category": null, + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_device_id\nset_context_device_id(\"device_123\")" + } + ] + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_session\nset_context_session(\"session_123\")" + } + ] + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a user record, only if they do not yet exist.", + "details": "This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set property once", + "code": "# Set property once\nfrom posthog import set_once\nset_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})" + } + ] + }, + { + "id": "setup", + "title": "setup", + "description": "Create or return the global PostHog client configured by module settings. Most applications should either instantiate ``Posthog`` directly or set ``posthog.api_key``/other module settings before calling top-level helpers. ``setup()`` is called automatically by global APIs such as ``capture()``. Returns: The global ``Client`` instance. If both ``api_key`` and ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops.", + "details": "", + "category": "Initialization", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Client" + } + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as ``on_error`` are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call ``shutdown()`` from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import shutdown\nshutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "import posthog\nposthog.traces = {\"service_name\": \"checkout-api\"}\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import tag\ntag(\"user_id\", \"123\")" + } + ] + } + ] + } + ], + "categories": [ + "Initialization", + "Identification", + "Capture", + "Error Tracking", + "Feature flags", + "Tracing", + "Contexts", + "Events", + "Client management" + ] +} \ No newline at end of file diff --git a/references/posthog-python-references-7.67.0.json b/references/posthog-python-references-7.67.0.json new file mode 100644 index 000000000..600acb877 --- /dev/null +++ b/references/posthog-python-references-7.67.0.json @@ -0,0 +1,3548 @@ +{ + "id": "posthog-python", + "hogRef": "0.3", + "info": { + "version": "7.67.0", + "id": "posthog-python", + "title": "PostHog Python SDK", + "description": "Integrate PostHog into any python application.", + "slugPrefix": "posthog-python", + "specUrl": "https://github.com/PostHog/posthog-python" + }, + "types": [ + { + "id": "FeatureFlag", + "name": "FeatureFlag", + "path": "posthog.types.FeatureFlag", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "reason", + "type": "Optional[FlagReason]", + "description": "Field: reason" + }, + { + "name": "metadata", + "type": "Union[FlagMetadata, LegacyFlagMetadata]", + "description": "Field: metadata" + } + ], + "example": "" + }, + { + "id": "FeatureFlagResult", + "name": "FeatureFlagResult", + "path": "posthog.types.FeatureFlagResult", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "payload", + "type": "Optional[Any]", + "description": "Field: payload" + }, + { + "name": "reason", + "type": "Optional[str]", + "description": "Field: reason" + } + ], + "example": "" + }, + { + "id": "FlagMetadata", + "name": "FlagMetadata", + "path": "posthog.types.FlagMetadata", + "properties": [ + { + "name": "id", + "type": "int", + "description": "Field: id" + }, + { + "name": "payload", + "type": "Optional[str]", + "description": "Field: payload" + }, + { + "name": "version", + "type": "int", + "description": "Field: version" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + }, + { + "name": "has_experiment", + "type": "Optional[bool]", + "description": "Field: has_experiment" + } + ], + "example": "" + }, + { + "id": "FlagReason", + "name": "FlagReason", + "path": "posthog.types.FlagReason", + "properties": [ + { + "name": "code", + "type": "str", + "description": "Field: code" + }, + { + "name": "condition_index", + "type": "Optional[int]", + "description": "Field: condition_index" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + } + ], + "example": "" + }, + { + "id": "FlagsAndPayloads", + "name": "FlagsAndPayloads", + "path": "posthog.types.FlagsAndPayloads", + "properties": [ + { + "name": "featureFlags", + "type": "Optional[dict[str, Union[bool, str]]]", + "description": "Field: featureFlags" + }, + { + "name": "featureFlagPayloads", + "type": "Optional[dict[str, Any]]", + "description": "Field: featureFlagPayloads" + } + ], + "example": "" + }, + { + "id": "FlagsResponse", + "name": "FlagsResponse", + "path": "posthog.types.FlagsResponse", + "properties": [ + { + "name": "flags", + "type": "dict[str, FeatureFlag]", + "description": "Field: flags" + }, + { + "name": "errorsWhileComputingFlags", + "type": "bool", + "description": "Field: errorsWhileComputingFlags" + }, + { + "name": "requestId", + "type": "str", + "description": "Field: requestId" + }, + { + "name": "quotaLimit", + "type": "Optional[list[str]]", + "description": "Field: quotaLimit" + }, + { + "name": "evaluatedAt", + "type": "Optional[int]", + "description": "Field: evaluatedAt" + }, + { + "name": "minimalFlagCalledEvents", + "type": "bool", + "description": "Field: minimalFlagCalledEvents" + } + ], + "example": "" + }, + { + "id": "LegacyFlagMetadata", + "name": "LegacyFlagMetadata", + "path": "posthog.types.LegacyFlagMetadata", + "properties": [ + { + "name": "payload", + "type": "Any", + "description": "Field: payload" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + }, + { + "id": "OptionalCaptureArgs", + "name": "OptionalCaptureArgs", + "path": "posthog.args.OptionalCaptureArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "groups", + "type": "NotRequired[Optional[dict[str, str]]]", + "description": "Field: groups" + }, + { + "name": "flags", + "type": "NotRequired[Optional[ForwardRef('FeatureFlagEvaluations')]]", + "description": "Field: flags" + }, + { + "name": "send_feature_flags", + "type": "NotRequired[Union[bool, SendFeatureFlagsOptions, any]]", + "description": "Field: send_feature_flags" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + }, + { + "name": "_property_allowlist", + "type": "NotRequired[Optional[frozenset[str]]]", + "description": "Field: _property_allowlist" + } + ], + "example": "" + }, + { + "id": "OptionalSetArgs", + "name": "OptionalSetArgs", + "path": "posthog.args.OptionalSetArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + } + ], + "classes": [ + { + "id": "PostHog", + "title": "PostHog", + "description": "This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python). You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.", + "functions": [ + { + "id": "__init__", + "title": "Client", + "description": "Initialize a new PostHog client instance.", + "details": "", + "category": "Initialization", + "params": [ + { + "name": "project_api_key", + "description": "PostHog project API key/token.", + "isOptional": true, + "type": "str" + }, + { + "name": "host", + "description": "PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as ``https://us.posthog.com`` are mapped to the corresponding ingestion host.", + "isOptional": false, + "type": "any" + }, + { + "name": "debug", + "description": "Enable verbose SDK logging and re-raise errors from public API methods.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_queue_size", + "description": "Maximum number of events buffered before upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "send", + "description": "If False, queueing succeeds but events are not sent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "on_error", + "description": "Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback.", + "isOptional": false, + "type": "any" + }, + { + "name": "flush_at", + "description": "Number of queued events that triggers a batch upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "flush_interval", + "description": "Maximum seconds a background consumer waits before flushing a partial batch.", + "isOptional": false, + "type": "float" + }, + { + "name": "gzip", + "description": "Whether to gzip event upload payloads.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_retries", + "description": "Number of upload retries. Values below 0 are treated as 0.", + "isOptional": false, + "type": "int" + }, + { + "name": "sync_mode", + "description": "If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead.", + "isOptional": false, + "type": "bool" + }, + { + "name": "timeout", + "description": "HTTP request timeout in seconds for event uploads.", + "isOptional": false, + "type": "int" + }, + { + "name": "thread", + "description": "Number of background consumer threads.", + "isOptional": false, + "type": "int" + }, + { + "name": "poll_interval", + "description": "Seconds between local feature flag definition refreshes.", + "isOptional": false, + "type": "int" + }, + { + "name": "personal_api_key", + "description": "Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also accepts a Project Secret API Key.", + "isOptional": false, + "type": "any" + }, + { + "name": "disabled", + "description": "If True, disable captures and API requests. Useful in tests.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable server-side GeoIP enrichment. Defaults to True.", + "isOptional": false, + "type": "bool" + }, + { + "name": "is_server", + "description": "Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "historical_migration", + "description": "Mark events as historical migration imports.", + "isOptional": false, + "type": "bool" + }, + { + "name": "feature_flags_request_timeout_seconds", + "description": "Timeout in seconds for feature flag and remote config requests.", + "isOptional": false, + "type": "int" + }, + { + "name": "feature_flags_request_max_retries", + "description": "Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.", + "isOptional": false, + "type": "int" + }, + { + "name": "super_properties", + "description": "Properties merged into every captured event.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_exception_autocapture", + "description": "Automatically capture uncaught exceptions.", + "isOptional": false, + "type": "bool" + }, + { + "name": "log_captured_exceptions", + "description": "Also log exceptions captured by error tracking.", + "isOptional": false, + "type": "bool" + }, + { + "name": "project_root", + "description": "Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.", + "isOptional": false, + "type": "any" + }, + { + "name": "privacy_mode", + "description": "For AI observability, capture usage metadata without prompt inputs or outputs.", + "isOptional": false, + "type": "bool" + }, + { + "name": "before_send", + "description": "Optional callback that can modify or drop events before upload. Return ``None`` to drop an event.", + "isOptional": false, + "type": "any" + }, + { + "name": "flag_fallback_cache_url", + "description": "Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_local_evaluation", + "description": "Whether to poll feature flag definitions for local evaluation when a personal API key is configured.", + "isOptional": false, + "type": "bool" + }, + { + "name": "flag_definition_cache_provider", + "description": "Optional external cache provider for sharing feature flag definitions across workers.", + "isOptional": true, + "type": "FlagDefinitionCacheProvider" + }, + { + "name": "capture_exception_code_variables", + "description": "Capture local variable values on exception stack frames.", + "isOptional": false, + "type": "bool" + }, + { + "name": "code_variables_mask_patterns", + "description": "Variable-name patterns to mask when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_ignore_patterns", + "description": "Variable-name patterns to omit when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_mask_url_credentials", + "description": "Scrub credentials embedded in URLs/DSNs (e.g. ``user:pass@host``) from captured code variables, regardless of the surrounding variable name. Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_detect_secrets", + "description": "Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "in_app_modules", + "description": "Module/package prefixes treated as in-app frames in captured exceptions.", + "isOptional": false, + "type": "UnionType[list[str], any]" + }, + { + "name": "enable_exception_autocapture_rate_limiting", + "description": "Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.", + "isOptional": false, + "type": "bool" + }, + { + "name": "exception_autocapture_bucket_size", + "description": "Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_rate", + "description": "Tokens restored per refill interval for each exception type's bucket.", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_interval_seconds", + "description": "Seconds between token refills for autocaptured exception rate limiting.", + "isOptional": false, + "type": "int" + }, + { + "name": "capture_mode", + "description": "Capture wire protocol to use. Defaults to ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` (or pass the string ``\"v1\"``) to opt into ``/i/v1/analytics/events``. When omitted, the ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``.", + "isOptional": false, + "type": "CaptureMode" + }, + { + "name": "capture_compression", + "description": "Request-body compression for capture-v1 uploads (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the strings ``\"gzip\"``/``\"deflate\"``). When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then the legacy ``gzip`` flag, then no compression.", + "isOptional": false, + "type": "CaptureCompression" + }, + { + "name": "secret_key", + "description": "A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key=\"phx_...\")", + "isOptional": false, + "type": "any" + }, + { + "name": "metrics", + "description": "", + "isOptional": true, + "type": "dict" + }, + { + "name": "enable_full_ai_capture", + "description": "Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. ``privacy_mode`` always wins. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_trace_context", + "description": "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. Explicit ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "_use_ai_lane", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "_enable_multimodal_capture", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "traces", + "description": "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.", + "isOptional": true, + "type": "dict" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import Posthog\n\nposthog = Posthog('', host='')" + } + ] + }, + { + "id": "alias", + "title": "alias", + "description": "Create an alias between two distinct IDs.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.alias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "The event name to capture.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Anonymous event", + "code": "# Anonymous event\nposthog.capture('some-anon-event')" + }, + { + "id": "example_2", + "name": "Context usage", + "code": "# Context usage\nfrom posthog import identify_context, new_context\nwith new_context():\n identify_context('distinct_id_of_the_user')\n posthog.capture('user_signed_up')\n posthog.capture('user_logged_in')\n posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')" + }, + { + "id": "example_3", + "name": "Set event properties", + "code": "# Set event properties\nposthog.capture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + }, + { + "id": "example_4", + "name": "Page view event", + "code": "# Page view event\nposthog.capture('$pageview', distinct_id=\"distinct_id_of_the_user\", properties={'$current_url': 'https://example.com'})" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + } + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as ``$trace_id`` and ``$span_id`` event properties.", + "details": "", + "category": "Error Tracking", + "params": [ + { + "name": "exception", + "description": "The exception to capture.", + "isOptional": true, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "try:\n # Some code that might fail\n pass\nexcept Exception as e:\n posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` and reason ``\"Evaluated locally\"``.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If True, never fall back to remote evaluation \u2014 flags that can't be evaluated locally are simply omitted from the snapshot.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is True. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flags = posthog.evaluate_flags(\n \"user_123\",\n person_properties={\"plan\": \"enterprise\"},\n)\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\nposthog.capture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Check if a feature flag is enabled for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "flush", + "title": "flush", + "description": "Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.", + "details": "", + "category": null, + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely. Queued spans are sent at the same time, within the same", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.capture('event_name')\nposthog.flush() # Ensures the event is sent immediately" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all feature flags for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flags and their payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags_and_payloads('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get multivariate feature flag value for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nruntime = posthog.get_feature_flag_evaluation_runtime(\"my-flag\")\nif runtime is FeatureFlagEvaluationRuntime.SERVER:\n ..." + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate. A flag set to ``FeatureFlagEvaluationRuntime.ALL`` suits either runtime, so it is returned for ``CLIENT`` and for ``SERVER``, and asking for ``ALL`` returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "The runtime to match, as a ``FeatureFlagEvaluationRuntime`` or its string value.", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nclient_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(\n FeatureFlagEvaluationRuntime.CLIENT\n)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload for a feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "The specific flag value to get payload for.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Deprecated. Use get_feature_flag() instead if you need events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\n\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')\nif flag_result and flag_result.get_value() == 'variant-key':\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = flag_result.payload" + } + ] + }, + { + "id": "get_feature_flags_and_payloads", + "title": "get_feature_flags_and_payloads", + "description": "Get feature flags and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "result = posthog.get_feature_flags_and_payloads('')" + } + ] + }, + { + "id": "get_feature_payloads", + "title": "get_feature_payloads", + "description": "Get feature flag payloads for a user, preserving valid serialized JSON.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Optional[str]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "payloads = posthog.get_feature_payloads('')" + } + ] + }, + { + "id": "get_feature_variants", + "title": "get_feature_variants", + "description": "Get feature flag variants for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Union[bool, str]]" + } + }, + { + "id": "get_flags_decision", + "title": "get_flags_decision", + "description": "Get feature flags decision.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsResponse" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "decision = posthog.get_flags_decision('user123')" + } + ] + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The remote config feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context.", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Identify a group and set its properties.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "A dictionary of properties to set on the group.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": false, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user performing the action.", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.group_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and end the consumer threads. Do not use directly, call `shutdown()` instead. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.join()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flags for local evaluation.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.load_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "with client.new_context():\n client.identify_context('')\n client.capture('event_name')" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the wrapped function using this client.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a person profile.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set with distinct id", + "code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a person profile only if they haven't been set before.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})" + } + ] + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. 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 invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.shutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "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.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog = Posthog(\"\", traces={\"service_name\": \"checkout-api\"})\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value.", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + } + ] + }, + { + "id": "PostHogModule", + "title": "PostHog Module Functions", + "description": "Global functions available in the PostHog module", + "functions": [ + { + "id": "alias", + "title": "alias", + "description": "Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "details": "To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like \"Which marketing channels leads to users churning after a month?\" or \"What do users do on our website before signing up?\". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The unique ID of the user before", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The current unique id", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Alias user", + "code": "# Alias user\nfrom posthog import alias\nalias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Capture anything a user does within your system.", + "details": "Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name to specify the event **kwargs: Optional arguments including:", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Context and capture usage", + "code": "# Context and capture usage\nfrom posthog import new_context, identify_context, tag_context, capture\n# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)\nwith new_context():\n # Associate this context with some user, by distinct_id\n identify_context('some user')\n\n # Capture an event, associated with the context-level distinct ID ('some user')\n capture('movie started')\n\n # Capture an event associated with some other user (overriding the context-level distinct ID)\n capture('movie joined', distinct_id='some-other-user')\n\n # Capture an event with some properties\n capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})\n\n # Capture an event with some properties\n capture('purchase', properties={'product_id': '123', 'category': 'romcom'})\n # Capture an event with some associated group\n capture('purchase', groups={'company': 'id:5'})\n\n # Adding a tag to the current context will cause it to appear on all subsequent events\n tag_context('some-tag', 'some-value')\n\n capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict" + }, + { + "id": "example_2", + "name": "Set event properties", + "code": "# Set event properties\nfrom posthog import capture\ncapture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name, normally one of the `$ai_*` event names. **kwargs: Same optional arguments as `capture()`.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import capture_ai\n\nuuid = capture_ai(\n \"$ai_generation\",\n distinct_id=\"user_123\",\n properties={\"$ai_model\": \"gpt-5\"},\n)" + } + ] + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture exceptions that happen in your code.", + "details": "Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the \"traceback\"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError(\"Some Error\"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.", + "category": "Events", + "params": [ + { + "name": "exception", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", + "isOptional": false, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Capture exception", + "code": "# Capture exception\nfrom posthog import capture_exception\ntry:\n risky_operation()\nexcept Exception as e:\n capture_exception(e)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to ``capture()`` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If ``True``, never fall back to remote evaluation and omit flags that cannot be evaluated locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import evaluate_flags, capture\nflags = evaluate_flags(\"user_123\", person_properties={\"plan\": \"enterprise\"})\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\ncapture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Use feature flags to enable or disable features for users.", + "details": "You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Boolean feature flag", + "code": "# Boolean feature flag\nfrom posthog import feature_enabled, get_feature_flag_payload\nis_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Returns loaded feature flags.", + "details": "Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import feature_flag_definitions\ndefinitions = feature_flag_definitions()" + } + ] + }, + { + "id": "flush", + "title": "flush", + "description": "Tell the client to flush all queued events.", + "details": "", + "category": "Client management", + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely.", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import flush\nflush()" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all flags for a given user.", + "details": "Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate (evaluates all if None)", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "All flags for user", + "code": "# All flags for user\nfrom posthog import get_all_flags\nget_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flag values and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate. Evaluates all flags when omitted.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + } + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get feature flag variant for users. Used with experiments.", + "details": "`groups` are a mapping from group type to group key. So, if you have a group type of \"organization\" and a group key of \"5\", you would pass groups={\"organization\": \"5\"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type \"organization\" and the group key \"5\", with the properties name, and employee count, you'll send these as: group_properties={\"organization\": {\"name\": \"PostHog\", \"employees\": 11}}.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping from group type to group key", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties in format { group_type_name: { group_properties } }", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Multivariate feature flag", + "code": "# Multivariate feature flag\nfrom posthog import get_feature_flag, get_feature_flag_payload\nenabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key':\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "Reads the `evaluation_runtime` each flag definition carries, so no extra request is made. Returns `None` when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports `FeatureFlagEvaluationRuntime.ALL`, the default PostHog applies.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime\nruntime = get_feature_flag_evaluation_runtime(\"my-flag\")" + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate.", + "details": "A flag set to `FeatureFlagEvaluationRuntime.ALL` suits either runtime, so it is returned for `CLIENT` and for `SERVER`. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime\nclient_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload associated with a feature flag value. Deprecated for new code. Prefer ``evaluate_flags()`` and ``flags.get_flag_payload(key)`` so flag evaluation happens once per request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "Optional flag value to use when selecting a payload.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + } + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + } + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The key of the feature flag", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Set properties on a group.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "Type of your group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "Properties to set on the group", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "Optional distinct ID of the user performing the action", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Group identify", + "code": "# Group identify\nfrom posthog import group_identify\ngroup_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import identify_context\nidentify_context(\"user_123\")" + } + ] + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and stop the client's background workers. Use `shutdown()` directly in most cases. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import join\njoin()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flag definitions from PostHog.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import load_feature_flags\nload_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context scope that will be active for the duration of the with block.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + }, + { + "name": "client", + "description": "Optional Posthog client instance to use for this context (default: None)", + "isOptional": true, + "type": "Client" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import new_context, tag, capture\nwith new_context():\n tag(\"request_id\", \"123\")\n capture(\"event_name\", properties={\"property\": \"value\"})" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the function.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import scoped, tag, capture\n@scoped()\ndef process_payment(payment_id):\n tag(\"payment_id\", payment_id)\n capture(\"payment_started\")" + } + ] + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a user record.", + "details": "This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set person properties", + "code": "# Set person properties\nfrom posthog import set\nset(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_capture_exception_code_variables_context", + "title": "set_capture_exception_code_variables_context", + "description": "Override code-variable capture for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "Whether exceptions captured in this context should include local variable values from stack frames.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_detect_secrets_context", + "title": "set_code_variables_detect_secrets_context", + "description": "Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_ignore_patterns_context", + "title": "set_code_variables_ignore_patterns_context", + "description": "Override code-variable ignore patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "ignore_patterns", + "description": "Variable-name patterns that should be omitted entirely when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_patterns_context", + "title": "set_code_variables_mask_patterns_context", + "description": "Override code-variable mask patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "mask_patterns", + "description": "Variable-name patterns whose values should be replaced with ``***`` when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_url_credentials_context", + "title": "set_code_variables_mask_url_credentials_context", + "description": "Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.", + "details": "", + "category": null, + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_device_id\nset_context_device_id(\"device_123\")" + } + ] + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_session\nset_context_session(\"session_123\")" + } + ] + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a user record, only if they do not yet exist.", + "details": "This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set property once", + "code": "# Set property once\nfrom posthog import set_once\nset_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})" + } + ] + }, + { + "id": "setup", + "title": "setup", + "description": "Create or return the global PostHog client configured by module settings. Most applications should either instantiate ``Posthog`` directly or set ``posthog.api_key``/other module settings before calling top-level helpers. ``setup()`` is called automatically by global APIs such as ``capture()``. Returns: The global ``Client`` instance. If both ``api_key`` and ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops.", + "details": "", + "category": "Initialization", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Client" + } + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as ``on_error`` are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call ``shutdown()`` from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import shutdown\nshutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "import posthog\nposthog.traces = {\"service_name\": \"checkout-api\"}\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import tag\ntag(\"user_id\", \"123\")" + } + ] + } + ] + } + ], + "categories": [ + "Initialization", + "Identification", + "Capture", + "Error Tracking", + "Feature flags", + "Tracing", + "Contexts", + "Events", + "Client management" + ] +} \ No newline at end of file diff --git a/references/posthog-python-references-7.67.1.json b/references/posthog-python-references-7.67.1.json new file mode 100644 index 000000000..69955559b --- /dev/null +++ b/references/posthog-python-references-7.67.1.json @@ -0,0 +1,3548 @@ +{ + "id": "posthog-python", + "hogRef": "0.3", + "info": { + "version": "7.67.1", + "id": "posthog-python", + "title": "PostHog Python SDK", + "description": "Integrate PostHog into any python application.", + "slugPrefix": "posthog-python", + "specUrl": "https://github.com/PostHog/posthog-python" + }, + "types": [ + { + "id": "FeatureFlag", + "name": "FeatureFlag", + "path": "posthog.types.FeatureFlag", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "reason", + "type": "Optional[FlagReason]", + "description": "Field: reason" + }, + { + "name": "metadata", + "type": "Union[FlagMetadata, LegacyFlagMetadata]", + "description": "Field: metadata" + } + ], + "example": "" + }, + { + "id": "FeatureFlagResult", + "name": "FeatureFlagResult", + "path": "posthog.types.FeatureFlagResult", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "payload", + "type": "Optional[Any]", + "description": "Field: payload" + }, + { + "name": "reason", + "type": "Optional[str]", + "description": "Field: reason" + } + ], + "example": "" + }, + { + "id": "FlagMetadata", + "name": "FlagMetadata", + "path": "posthog.types.FlagMetadata", + "properties": [ + { + "name": "id", + "type": "int", + "description": "Field: id" + }, + { + "name": "payload", + "type": "Optional[str]", + "description": "Field: payload" + }, + { + "name": "version", + "type": "int", + "description": "Field: version" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + }, + { + "name": "has_experiment", + "type": "Optional[bool]", + "description": "Field: has_experiment" + } + ], + "example": "" + }, + { + "id": "FlagReason", + "name": "FlagReason", + "path": "posthog.types.FlagReason", + "properties": [ + { + "name": "code", + "type": "str", + "description": "Field: code" + }, + { + "name": "condition_index", + "type": "Optional[int]", + "description": "Field: condition_index" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + } + ], + "example": "" + }, + { + "id": "FlagsAndPayloads", + "name": "FlagsAndPayloads", + "path": "posthog.types.FlagsAndPayloads", + "properties": [ + { + "name": "featureFlags", + "type": "Optional[dict[str, Union[bool, str]]]", + "description": "Field: featureFlags" + }, + { + "name": "featureFlagPayloads", + "type": "Optional[dict[str, Any]]", + "description": "Field: featureFlagPayloads" + } + ], + "example": "" + }, + { + "id": "FlagsResponse", + "name": "FlagsResponse", + "path": "posthog.types.FlagsResponse", + "properties": [ + { + "name": "flags", + "type": "dict[str, FeatureFlag]", + "description": "Field: flags" + }, + { + "name": "errorsWhileComputingFlags", + "type": "bool", + "description": "Field: errorsWhileComputingFlags" + }, + { + "name": "requestId", + "type": "str", + "description": "Field: requestId" + }, + { + "name": "quotaLimit", + "type": "Optional[list[str]]", + "description": "Field: quotaLimit" + }, + { + "name": "evaluatedAt", + "type": "Optional[int]", + "description": "Field: evaluatedAt" + }, + { + "name": "minimalFlagCalledEvents", + "type": "bool", + "description": "Field: minimalFlagCalledEvents" + } + ], + "example": "" + }, + { + "id": "LegacyFlagMetadata", + "name": "LegacyFlagMetadata", + "path": "posthog.types.LegacyFlagMetadata", + "properties": [ + { + "name": "payload", + "type": "Any", + "description": "Field: payload" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + }, + { + "id": "OptionalCaptureArgs", + "name": "OptionalCaptureArgs", + "path": "posthog.args.OptionalCaptureArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "groups", + "type": "NotRequired[Optional[dict[str, str]]]", + "description": "Field: groups" + }, + { + "name": "flags", + "type": "NotRequired[Optional[ForwardRef('FeatureFlagEvaluations')]]", + "description": "Field: flags" + }, + { + "name": "send_feature_flags", + "type": "NotRequired[Union[bool, SendFeatureFlagsOptions, any]]", + "description": "Field: send_feature_flags" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + }, + { + "name": "_property_allowlist", + "type": "NotRequired[Optional[frozenset[str]]]", + "description": "Field: _property_allowlist" + } + ], + "example": "" + }, + { + "id": "OptionalSetArgs", + "name": "OptionalSetArgs", + "path": "posthog.args.OptionalSetArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + } + ], + "classes": [ + { + "id": "PostHog", + "title": "PostHog", + "description": "This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python). You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.", + "functions": [ + { + "id": "__init__", + "title": "Client", + "description": "Initialize a new PostHog client instance.", + "details": "", + "category": "Initialization", + "params": [ + { + "name": "project_api_key", + "description": "PostHog project API key/token.", + "isOptional": true, + "type": "str" + }, + { + "name": "host", + "description": "PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as ``https://us.posthog.com`` are mapped to the corresponding ingestion host.", + "isOptional": false, + "type": "any" + }, + { + "name": "debug", + "description": "Enable verbose SDK logging and re-raise errors from public API methods.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_queue_size", + "description": "Maximum number of events buffered before upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "send", + "description": "If False, queueing succeeds but events are not sent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "on_error", + "description": "Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback.", + "isOptional": false, + "type": "any" + }, + { + "name": "flush_at", + "description": "Number of queued events that triggers a batch upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "flush_interval", + "description": "Maximum seconds a background consumer waits before flushing a partial batch.", + "isOptional": false, + "type": "float" + }, + { + "name": "gzip", + "description": "Whether to gzip event upload payloads.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_retries", + "description": "Number of upload retries. Values below 0 are treated as 0.", + "isOptional": false, + "type": "int" + }, + { + "name": "sync_mode", + "description": "If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead.", + "isOptional": false, + "type": "bool" + }, + { + "name": "timeout", + "description": "HTTP request timeout in seconds for event uploads.", + "isOptional": false, + "type": "int" + }, + { + "name": "thread", + "description": "Number of background consumer threads.", + "isOptional": false, + "type": "int" + }, + { + "name": "poll_interval", + "description": "Seconds between local feature flag definition refreshes.", + "isOptional": false, + "type": "int" + }, + { + "name": "personal_api_key", + "description": "Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also accepts a Project Secret API Key.", + "isOptional": false, + "type": "any" + }, + { + "name": "disabled", + "description": "If True, disable captures and API requests. Useful in tests.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable server-side GeoIP enrichment. Defaults to True.", + "isOptional": false, + "type": "bool" + }, + { + "name": "is_server", + "description": "Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "historical_migration", + "description": "Mark events as historical migration imports.", + "isOptional": false, + "type": "bool" + }, + { + "name": "feature_flags_request_timeout_seconds", + "description": "Timeout in seconds for feature flag and remote config requests.", + "isOptional": false, + "type": "int" + }, + { + "name": "feature_flags_request_max_retries", + "description": "Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.", + "isOptional": false, + "type": "int" + }, + { + "name": "super_properties", + "description": "Properties merged into every captured event.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_exception_autocapture", + "description": "Automatically capture uncaught exceptions.", + "isOptional": false, + "type": "bool" + }, + { + "name": "log_captured_exceptions", + "description": "Also log exceptions captured by error tracking.", + "isOptional": false, + "type": "bool" + }, + { + "name": "project_root", + "description": "Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.", + "isOptional": false, + "type": "any" + }, + { + "name": "privacy_mode", + "description": "For AI observability, capture usage metadata without prompt inputs or outputs.", + "isOptional": false, + "type": "bool" + }, + { + "name": "before_send", + "description": "Optional callback that can modify or drop events before upload. Return ``None`` to drop an event.", + "isOptional": false, + "type": "any" + }, + { + "name": "flag_fallback_cache_url", + "description": "Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_local_evaluation", + "description": "Whether to poll feature flag definitions for local evaluation when a personal API key is configured.", + "isOptional": false, + "type": "bool" + }, + { + "name": "flag_definition_cache_provider", + "description": "Optional external cache provider for sharing feature flag definitions across workers.", + "isOptional": true, + "type": "FlagDefinitionCacheProvider" + }, + { + "name": "capture_exception_code_variables", + "description": "Capture local variable values on exception stack frames.", + "isOptional": false, + "type": "bool" + }, + { + "name": "code_variables_mask_patterns", + "description": "Variable-name patterns to mask when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_ignore_patterns", + "description": "Variable-name patterns to omit when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_mask_url_credentials", + "description": "Scrub credentials embedded in URLs/DSNs (e.g. ``user:pass@host``) from captured code variables, regardless of the surrounding variable name. Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_detect_secrets", + "description": "Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "in_app_modules", + "description": "Module/package prefixes treated as in-app frames in captured exceptions.", + "isOptional": false, + "type": "UnionType[list[str], any]" + }, + { + "name": "enable_exception_autocapture_rate_limiting", + "description": "Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.", + "isOptional": false, + "type": "bool" + }, + { + "name": "exception_autocapture_bucket_size", + "description": "Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_rate", + "description": "Tokens restored per refill interval for each exception type's bucket.", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_interval_seconds", + "description": "Seconds between token refills for autocaptured exception rate limiting.", + "isOptional": false, + "type": "int" + }, + { + "name": "capture_mode", + "description": "Capture wire protocol to use. Defaults to ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` (or pass the string ``\"v1\"``) to opt into ``/i/v1/analytics/events``. When omitted, the ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``.", + "isOptional": false, + "type": "CaptureMode" + }, + { + "name": "capture_compression", + "description": "Request-body compression for capture-v1 uploads (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the strings ``\"gzip\"``/``\"deflate\"``). When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then the legacy ``gzip`` flag, then no compression.", + "isOptional": false, + "type": "CaptureCompression" + }, + { + "name": "secret_key", + "description": "A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key=\"phx_...\")", + "isOptional": false, + "type": "any" + }, + { + "name": "metrics", + "description": "", + "isOptional": true, + "type": "dict" + }, + { + "name": "enable_full_ai_capture", + "description": "Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. ``privacy_mode`` always wins. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_trace_context", + "description": "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. Explicit ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "_use_ai_lane", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "_enable_multimodal_capture", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "traces", + "description": "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.", + "isOptional": true, + "type": "dict" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import Posthog\n\nposthog = Posthog('', host='')" + } + ] + }, + { + "id": "alias", + "title": "alias", + "description": "Create an alias between two distinct IDs.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.alias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "The event name to capture.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Anonymous event", + "code": "# Anonymous event\nposthog.capture('some-anon-event')" + }, + { + "id": "example_2", + "name": "Context usage", + "code": "# Context usage\nfrom posthog import identify_context, new_context\nwith new_context():\n identify_context('distinct_id_of_the_user')\n posthog.capture('user_signed_up')\n posthog.capture('user_logged_in')\n posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')" + }, + { + "id": "example_3", + "name": "Set event properties", + "code": "# Set event properties\nposthog.capture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + }, + { + "id": "example_4", + "name": "Page view event", + "code": "# Page view event\nposthog.capture('$pageview', distinct_id=\"distinct_id_of_the_user\", properties={'$current_url': 'https://example.com'})" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + } + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as ``$trace_id`` and ``$span_id`` event properties.", + "details": "", + "category": "Error Tracking", + "params": [ + { + "name": "exception", + "description": "The exception to capture.", + "isOptional": true, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "try:\n # Some code that might fail\n pass\nexcept Exception as e:\n posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` and reason ``\"Evaluated locally\"``.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If True, never fall back to remote evaluation \u2014 flags that can't be evaluated locally are simply omitted from the snapshot.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is True. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flags = posthog.evaluate_flags(\n \"user_123\",\n person_properties={\"plan\": \"enterprise\"},\n)\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\nposthog.capture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Check if a feature flag is enabled for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "flush", + "title": "flush", + "description": "Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.", + "details": "", + "category": null, + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely. Queued spans are sent at the same time, within the same", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.capture('event_name')\nposthog.flush() # Ensures the event is sent immediately" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all feature flags for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flags and their payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags_and_payloads('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get multivariate feature flag value for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nruntime = posthog.get_feature_flag_evaluation_runtime(\"my-flag\")\nif runtime is FeatureFlagEvaluationRuntime.SERVER:\n ..." + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate. A flag set to ``FeatureFlagEvaluationRuntime.ALL`` suits either runtime, so it is returned for ``CLIENT`` and for ``SERVER``, and asking for ``ALL`` returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "The runtime to match, as a ``FeatureFlagEvaluationRuntime`` or its string value.", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nclient_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(\n FeatureFlagEvaluationRuntime.CLIENT\n)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload for a feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "The specific flag value to get payload for.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Deprecated. Use get_feature_flag() instead if you need events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\n\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')\nif flag_result and flag_result.get_value() == 'variant-key':\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = flag_result.payload" + } + ] + }, + { + "id": "get_feature_flags_and_payloads", + "title": "get_feature_flags_and_payloads", + "description": "Get feature flags and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "result = posthog.get_feature_flags_and_payloads('')" + } + ] + }, + { + "id": "get_feature_payloads", + "title": "get_feature_payloads", + "description": "Get feature flag payloads for a user, preserving valid serialized JSON.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Optional[str]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "payloads = posthog.get_feature_payloads('')" + } + ] + }, + { + "id": "get_feature_variants", + "title": "get_feature_variants", + "description": "Get feature flag variants for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Union[bool, str]]" + } + }, + { + "id": "get_flags_decision", + "title": "get_flags_decision", + "description": "Get feature flags decision.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsResponse" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "decision = posthog.get_flags_decision('user123')" + } + ] + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The remote config feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context.", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Identify a group and set its properties.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "A dictionary of properties to set on the group.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": false, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user performing the action.", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.group_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and end the consumer threads. Do not use directly, call `shutdown()` instead. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.join()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flags for local evaluation.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.load_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "with client.new_context():\n client.identify_context('')\n client.capture('event_name')" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the wrapped function using this client.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a person profile.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set with distinct id", + "code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a person profile only if they haven't been set before.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})" + } + ] + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. 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 invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.shutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "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.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog = Posthog(\"\", traces={\"service_name\": \"checkout-api\"})\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value.", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + } + ] + }, + { + "id": "PostHogModule", + "title": "PostHog Module Functions", + "description": "Global functions available in the PostHog module", + "functions": [ + { + "id": "alias", + "title": "alias", + "description": "Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "details": "To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like \"Which marketing channels leads to users churning after a month?\" or \"What do users do on our website before signing up?\". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The unique ID of the user before", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The current unique id", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Alias user", + "code": "# Alias user\nfrom posthog import alias\nalias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Capture anything a user does within your system.", + "details": "Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name to specify the event **kwargs: Optional arguments including:", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Context and capture usage", + "code": "# Context and capture usage\nfrom posthog import new_context, identify_context, tag_context, capture\n# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)\nwith new_context():\n # Associate this context with some user, by distinct_id\n identify_context('some user')\n\n # Capture an event, associated with the context-level distinct ID ('some user')\n capture('movie started')\n\n # Capture an event associated with some other user (overriding the context-level distinct ID)\n capture('movie joined', distinct_id='some-other-user')\n\n # Capture an event with some properties\n capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})\n\n # Capture an event with some properties\n capture('purchase', properties={'product_id': '123', 'category': 'romcom'})\n # Capture an event with some associated group\n capture('purchase', groups={'company': 'id:5'})\n\n # Adding a tag to the current context will cause it to appear on all subsequent events\n tag_context('some-tag', 'some-value')\n\n capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict" + }, + { + "id": "example_2", + "name": "Set event properties", + "code": "# Set event properties\nfrom posthog import capture\ncapture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name, normally one of the `$ai_*` event names. **kwargs: Same optional arguments as `capture()`.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import capture_ai\n\nuuid = capture_ai(\n \"$ai_generation\",\n distinct_id=\"user_123\",\n properties={\"$ai_model\": \"gpt-5\"},\n)" + } + ] + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture exceptions that happen in your code.", + "details": "Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the \"traceback\"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError(\"Some Error\"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.", + "category": "Events", + "params": [ + { + "name": "exception", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", + "isOptional": false, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Capture exception", + "code": "# Capture exception\nfrom posthog import capture_exception\ntry:\n risky_operation()\nexcept Exception as e:\n capture_exception(e)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to ``capture()`` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If ``True``, never fall back to remote evaluation and omit flags that cannot be evaluated locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import evaluate_flags, capture\nflags = evaluate_flags(\"user_123\", person_properties={\"plan\": \"enterprise\"})\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\ncapture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Use feature flags to enable or disable features for users.", + "details": "You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Boolean feature flag", + "code": "# Boolean feature flag\nfrom posthog import feature_enabled, get_feature_flag_payload\nis_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Returns loaded feature flags.", + "details": "Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import feature_flag_definitions\ndefinitions = feature_flag_definitions()" + } + ] + }, + { + "id": "flush", + "title": "flush", + "description": "Tell the client to flush all queued events.", + "details": "", + "category": "Client management", + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely.", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import flush\nflush()" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all flags for a given user.", + "details": "Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate (evaluates all if None)", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "All flags for user", + "code": "# All flags for user\nfrom posthog import get_all_flags\nget_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flag values and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate. Evaluates all flags when omitted.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + } + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get feature flag variant for users. Used with experiments.", + "details": "`groups` are a mapping from group type to group key. So, if you have a group type of \"organization\" and a group key of \"5\", you would pass groups={\"organization\": \"5\"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type \"organization\" and the group key \"5\", with the properties name, and employee count, you'll send these as: group_properties={\"organization\": {\"name\": \"PostHog\", \"employees\": 11}}.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping from group type to group key", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties in format { group_type_name: { group_properties } }", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Multivariate feature flag", + "code": "# Multivariate feature flag\nfrom posthog import get_feature_flag, get_feature_flag_payload\nenabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key':\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "Reads the `evaluation_runtime` each flag definition carries, so no extra request is made. Returns `None` when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports `FeatureFlagEvaluationRuntime.ALL`, the default PostHog applies.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime\nruntime = get_feature_flag_evaluation_runtime(\"my-flag\")" + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate.", + "details": "A flag set to `FeatureFlagEvaluationRuntime.ALL` suits either runtime, so it is returned for `CLIENT` and for `SERVER`. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime\nclient_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload associated with a feature flag value. Deprecated for new code. Prefer ``evaluate_flags()`` and ``flags.get_flag_payload(key)`` so flag evaluation happens once per request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "Optional flag value to use when selecting a payload.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + } + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + } + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The key of the feature flag", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Set properties on a group.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "Type of your group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "Properties to set on the group", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "Optional distinct ID of the user performing the action", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Group identify", + "code": "# Group identify\nfrom posthog import group_identify\ngroup_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import identify_context\nidentify_context(\"user_123\")" + } + ] + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and stop the client's background workers. Use `shutdown()` directly in most cases. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import join\njoin()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flag definitions from PostHog.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import load_feature_flags\nload_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context scope that will be active for the duration of the with block.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + }, + { + "name": "client", + "description": "Optional Posthog client instance to use for this context (default: None)", + "isOptional": true, + "type": "Client" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import new_context, tag, capture\nwith new_context():\n tag(\"request_id\", \"123\")\n capture(\"event_name\", properties={\"property\": \"value\"})" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the function.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import scoped, tag, capture\n@scoped()\ndef process_payment(payment_id):\n tag(\"payment_id\", payment_id)\n capture(\"payment_started\")" + } + ] + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a user record.", + "details": "This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set person properties", + "code": "# Set person properties\nfrom posthog import set\nset(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_capture_exception_code_variables_context", + "title": "set_capture_exception_code_variables_context", + "description": "Override code-variable capture for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "Whether exceptions captured in this context should include local variable values from stack frames.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_detect_secrets_context", + "title": "set_code_variables_detect_secrets_context", + "description": "Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_ignore_patterns_context", + "title": "set_code_variables_ignore_patterns_context", + "description": "Override code-variable ignore patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "ignore_patterns", + "description": "Variable-name patterns that should be omitted entirely when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_patterns_context", + "title": "set_code_variables_mask_patterns_context", + "description": "Override code-variable mask patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "mask_patterns", + "description": "Variable-name patterns whose values should be replaced with ``***`` when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_url_credentials_context", + "title": "set_code_variables_mask_url_credentials_context", + "description": "Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.", + "details": "", + "category": null, + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_device_id\nset_context_device_id(\"device_123\")" + } + ] + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_session\nset_context_session(\"session_123\")" + } + ] + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a user record, only if they do not yet exist.", + "details": "This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set property once", + "code": "# Set property once\nfrom posthog import set_once\nset_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})" + } + ] + }, + { + "id": "setup", + "title": "setup", + "description": "Create or return the global PostHog client configured by module settings. Most applications should either instantiate ``Posthog`` directly or set ``posthog.api_key``/other module settings before calling top-level helpers. ``setup()`` is called automatically by global APIs such as ``capture()``. Returns: The global ``Client`` instance. If both ``api_key`` and ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops.", + "details": "", + "category": "Initialization", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Client" + } + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as ``on_error`` are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call ``shutdown()`` from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import shutdown\nshutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "import posthog\nposthog.traces = {\"service_name\": \"checkout-api\"}\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import tag\ntag(\"user_id\", \"123\")" + } + ] + } + ] + } + ], + "categories": [ + "Initialization", + "Identification", + "Capture", + "Error Tracking", + "Feature flags", + "Tracing", + "Contexts", + "Events", + "Client management" + ] +} \ No newline at end of file diff --git a/references/posthog-python-references-7.68.0.json b/references/posthog-python-references-7.68.0.json new file mode 100644 index 000000000..ef12885a8 --- /dev/null +++ b/references/posthog-python-references-7.68.0.json @@ -0,0 +1,3548 @@ +{ + "id": "posthog-python", + "hogRef": "0.3", + "info": { + "version": "7.68.0", + "id": "posthog-python", + "title": "PostHog Python SDK", + "description": "Integrate PostHog into any python application.", + "slugPrefix": "posthog-python", + "specUrl": "https://github.com/PostHog/posthog-python" + }, + "types": [ + { + "id": "FeatureFlag", + "name": "FeatureFlag", + "path": "posthog.types.FeatureFlag", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "reason", + "type": "Optional[FlagReason]", + "description": "Field: reason" + }, + { + "name": "metadata", + "type": "Union[FlagMetadata, LegacyFlagMetadata]", + "description": "Field: metadata" + } + ], + "example": "" + }, + { + "id": "FeatureFlagResult", + "name": "FeatureFlagResult", + "path": "posthog.types.FeatureFlagResult", + "properties": [ + { + "name": "key", + "type": "str", + "description": "Field: key" + }, + { + "name": "enabled", + "type": "bool", + "description": "Field: enabled" + }, + { + "name": "variant", + "type": "Optional[str]", + "description": "Field: variant" + }, + { + "name": "payload", + "type": "Optional[Any]", + "description": "Field: payload" + }, + { + "name": "reason", + "type": "Optional[str]", + "description": "Field: reason" + } + ], + "example": "" + }, + { + "id": "FlagMetadata", + "name": "FlagMetadata", + "path": "posthog.types.FlagMetadata", + "properties": [ + { + "name": "id", + "type": "int", + "description": "Field: id" + }, + { + "name": "payload", + "type": "Optional[str]", + "description": "Field: payload" + }, + { + "name": "version", + "type": "int", + "description": "Field: version" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + }, + { + "name": "has_experiment", + "type": "Optional[bool]", + "description": "Field: has_experiment" + } + ], + "example": "" + }, + { + "id": "FlagReason", + "name": "FlagReason", + "path": "posthog.types.FlagReason", + "properties": [ + { + "name": "code", + "type": "str", + "description": "Field: code" + }, + { + "name": "condition_index", + "type": "Optional[int]", + "description": "Field: condition_index" + }, + { + "name": "description", + "type": "str", + "description": "Field: description" + } + ], + "example": "" + }, + { + "id": "FlagsAndPayloads", + "name": "FlagsAndPayloads", + "path": "posthog.types.FlagsAndPayloads", + "properties": [ + { + "name": "featureFlags", + "type": "Optional[dict[str, Union[bool, str]]]", + "description": "Field: featureFlags" + }, + { + "name": "featureFlagPayloads", + "type": "Optional[dict[str, Any]]", + "description": "Field: featureFlagPayloads" + } + ], + "example": "" + }, + { + "id": "FlagsResponse", + "name": "FlagsResponse", + "path": "posthog.types.FlagsResponse", + "properties": [ + { + "name": "flags", + "type": "dict[str, FeatureFlag]", + "description": "Field: flags" + }, + { + "name": "errorsWhileComputingFlags", + "type": "bool", + "description": "Field: errorsWhileComputingFlags" + }, + { + "name": "requestId", + "type": "str", + "description": "Field: requestId" + }, + { + "name": "quotaLimit", + "type": "Optional[list[str]]", + "description": "Field: quotaLimit" + }, + { + "name": "evaluatedAt", + "type": "Optional[int]", + "description": "Field: evaluatedAt" + }, + { + "name": "minimalFlagCalledEvents", + "type": "bool", + "description": "Field: minimalFlagCalledEvents" + } + ], + "example": "" + }, + { + "id": "LegacyFlagMetadata", + "name": "LegacyFlagMetadata", + "path": "posthog.types.LegacyFlagMetadata", + "properties": [ + { + "name": "payload", + "type": "Any", + "description": "Field: payload" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + }, + { + "id": "OptionalCaptureArgs", + "name": "OptionalCaptureArgs", + "path": "posthog.args.OptionalCaptureArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "groups", + "type": "NotRequired[Optional[dict[str, str]]]", + "description": "Field: groups" + }, + { + "name": "flags", + "type": "NotRequired[Optional[ForwardRef('FeatureFlagEvaluations')]]", + "description": "Field: flags" + }, + { + "name": "send_feature_flags", + "type": "NotRequired[Union[bool, SendFeatureFlagsOptions, any]]", + "description": "Field: send_feature_flags" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + }, + { + "name": "_property_allowlist", + "type": "NotRequired[Optional[frozenset[str]]]", + "description": "Field: _property_allowlist" + } + ], + "example": "" + }, + { + "id": "OptionalSetArgs", + "name": "OptionalSetArgs", + "path": "posthog.args.OptionalSetArgs", + "properties": [ + { + "name": "distinct_id", + "type": "NotRequired[Union[Number, str, UUID, int, any]]", + "description": "Field: distinct_id" + }, + { + "name": "properties", + "type": "NotRequired[Optional[dict[str, Any]]]", + "description": "Field: properties" + }, + { + "name": "timestamp", + "type": "NotRequired[Union[datetime, str, any]]", + "description": "Field: timestamp" + }, + { + "name": "uuid", + "type": "NotRequired[Union[str, UUID, any]]", + "description": "Field: uuid" + }, + { + "name": "disable_geoip", + "type": "NotRequired[Optional[bool]]", + "description": "Field: disable_geoip" + } + ], + "example": "" + }, + { + "id": "SendFeatureFlagsOptions", + "name": "SendFeatureFlagsOptions", + "path": "posthog.types.SendFeatureFlagsOptions", + "properties": [ + { + "name": "should_send", + "type": "bool", + "description": "Field: should_send" + }, + { + "name": "only_evaluate_locally", + "type": "Optional[bool]", + "description": "Field: only_evaluate_locally" + }, + { + "name": "person_properties", + "type": "Optional[dict[str, Any]]", + "description": "Field: person_properties" + }, + { + "name": "group_properties", + "type": "Optional[dict[str, dict[str, Any]]]", + "description": "Field: group_properties" + }, + { + "name": "flag_keys_filter", + "type": "Optional[list[str]]", + "description": "Field: flag_keys_filter" + } + ], + "example": "" + } + ], + "classes": [ + { + "id": "PostHog", + "title": "PostHog", + "description": "This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python). You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.", + "functions": [ + { + "id": "__init__", + "title": "Client", + "description": "Initialize a new PostHog client instance.", + "details": "", + "category": "Initialization", + "params": [ + { + "name": "project_api_key", + "description": "PostHog project API key/token.", + "isOptional": true, + "type": "str" + }, + { + "name": "host", + "description": "PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as ``https://us.posthog.com`` are mapped to the corresponding ingestion host.", + "isOptional": false, + "type": "any" + }, + { + "name": "debug", + "description": "Enable verbose SDK logging and re-raise errors from public API methods.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_queue_size", + "description": "Maximum number of events buffered before upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "send", + "description": "If False, queueing succeeds but events are not sent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "on_error", + "description": "Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls ``flush()``, ``join()``, or ``shutdown()`` and then wait for it from the callback.", + "isOptional": false, + "type": "any" + }, + { + "name": "flush_at", + "description": "Number of queued events that triggers a batch upload.", + "isOptional": false, + "type": "int" + }, + { + "name": "flush_interval", + "description": "Maximum seconds a background consumer waits before flushing a partial batch.", + "isOptional": false, + "type": "float" + }, + { + "name": "gzip", + "description": "Whether to gzip event upload payloads.", + "isOptional": false, + "type": "bool" + }, + { + "name": "max_retries", + "description": "Number of upload retries. Values below 0 are treated as 0.", + "isOptional": false, + "type": "int" + }, + { + "name": "sync_mode", + "description": "If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead.", + "isOptional": false, + "type": "bool" + }, + { + "name": "timeout", + "description": "HTTP request timeout in seconds for event uploads.", + "isOptional": false, + "type": "int" + }, + { + "name": "thread", + "description": "Number of background consumer threads.", + "isOptional": false, + "type": "int" + }, + { + "name": "poll_interval", + "description": "Seconds between local feature flag definition refreshes.", + "isOptional": false, + "type": "int" + }, + { + "name": "personal_api_key", + "description": "Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also accepts a Project Secret API Key.", + "isOptional": false, + "type": "any" + }, + { + "name": "disabled", + "description": "If True, disable captures and API requests. Useful in tests.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable server-side GeoIP enrichment. Defaults to True.", + "isOptional": false, + "type": "bool" + }, + { + "name": "is_server", + "description": "Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "historical_migration", + "description": "Mark events as historical migration imports.", + "isOptional": false, + "type": "bool" + }, + { + "name": "feature_flags_request_timeout_seconds", + "description": "Timeout in seconds for feature flag and remote config requests.", + "isOptional": false, + "type": "int" + }, + { + "name": "feature_flags_request_max_retries", + "description": "Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.", + "isOptional": false, + "type": "int" + }, + { + "name": "super_properties", + "description": "Properties merged into every captured event.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_exception_autocapture", + "description": "Automatically capture uncaught exceptions.", + "isOptional": false, + "type": "bool" + }, + { + "name": "log_captured_exceptions", + "description": "Also log exceptions captured by error tracking.", + "isOptional": false, + "type": "bool" + }, + { + "name": "project_root", + "description": "Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.", + "isOptional": false, + "type": "any" + }, + { + "name": "privacy_mode", + "description": "For AI observability, capture usage metadata without prompt inputs or outputs.", + "isOptional": false, + "type": "bool" + }, + { + "name": "before_send", + "description": "Optional callback that can modify or drop events before upload. Return ``None`` to drop an event.", + "isOptional": false, + "type": "any" + }, + { + "name": "flag_fallback_cache_url", + "description": "Optional feature flag fallback cache URL, such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.", + "isOptional": false, + "type": "any" + }, + { + "name": "enable_local_evaluation", + "description": "Whether to poll feature flag definitions for local evaluation when a personal API key is configured.", + "isOptional": false, + "type": "bool" + }, + { + "name": "flag_definition_cache_provider", + "description": "Optional external cache provider for sharing feature flag definitions across workers.", + "isOptional": true, + "type": "FlagDefinitionCacheProvider" + }, + { + "name": "capture_exception_code_variables", + "description": "Capture local variable values on exception stack frames.", + "isOptional": false, + "type": "bool" + }, + { + "name": "code_variables_mask_patterns", + "description": "Variable-name patterns to mask when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_ignore_patterns", + "description": "Variable-name patterns to omit when capturing code variables.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_mask_url_credentials", + "description": "Scrub credentials embedded in URLs/DSNs (e.g. ``user:pass@host``) from captured code variables, regardless of the surrounding variable name. Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "code_variables_detect_secrets", + "description": "Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.", + "isOptional": false, + "type": "any" + }, + { + "name": "in_app_modules", + "description": "Module/package prefixes treated as in-app frames in captured exceptions.", + "isOptional": false, + "type": "UnionType[list[str], any]" + }, + { + "name": "enable_exception_autocapture_rate_limiting", + "description": "Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.", + "isOptional": false, + "type": "bool" + }, + { + "name": "exception_autocapture_bucket_size", + "description": "Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_rate", + "description": "Tokens restored per refill interval for each exception type's bucket.", + "isOptional": false, + "type": "int" + }, + { + "name": "exception_autocapture_refill_interval_seconds", + "description": "Seconds between token refills for autocaptured exception rate limiting.", + "isOptional": false, + "type": "int" + }, + { + "name": "capture_mode", + "description": "Capture wire protocol to use. Defaults to ``CaptureMode.V0`` (legacy ``/batch/``). Set ``CaptureMode.V1`` (or pass the string ``\"v1\"``) to opt into ``/i/v1/analytics/events``. When omitted, the ``POSTHOG_CAPTURE_MODE`` env var is consulted, then ``V0``.", + "isOptional": false, + "type": "CaptureMode" + }, + { + "name": "capture_compression", + "description": "Request-body compression for capture-v1 uploads (ignored in V0, which uses ``gzip``). ``CaptureCompression.GZIP`` or ``DEFLATE`` (or the strings ``\"gzip\"``/``\"deflate\"``). When omitted, the ``POSTHOG_CAPTURE_COMPRESSION`` env var is consulted, then the legacy ``gzip`` flag, then no compression.", + "isOptional": false, + "type": "CaptureCompression" + }, + { + "name": "secret_key", + "description": "A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key=\"phx_...\")", + "isOptional": false, + "type": "any" + }, + { + "name": "metrics", + "description": "", + "isOptional": true, + "type": "dict" + }, + { + "name": "enable_full_ai_capture", + "description": "Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. ``privacy_mode`` always wins. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_trace_context", + "description": "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. Explicit ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception events (``capture_exception``) always attach these IDs regardless of this setting. Defaults to False.", + "isOptional": false, + "type": "bool" + }, + { + "name": "_use_ai_lane", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "_enable_multimodal_capture", + "description": "", + "isOptional": false, + "type": "bool" + }, + { + "name": "traces", + "description": "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.", + "isOptional": true, + "type": "dict" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import Posthog\n\nposthog = Posthog('', host='')" + } + ] + }, + { + "id": "alias", + "title": "alias", + "description": "Create an alias between two distinct IDs.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.alias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "The event name to capture.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Anonymous event", + "code": "# Anonymous event\nposthog.capture('some-anon-event')" + }, + { + "id": "example_2", + "name": "Context usage", + "code": "# Context usage\nfrom posthog import identify_context, new_context\nwith new_context():\n identify_context('distinct_id_of_the_user')\n posthog.capture('user_signed_up')\n posthog.capture('user_logged_in')\n posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')" + }, + { + "id": "example_3", + "name": "Set event properties", + "code": "# Set event properties\nposthog.capture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + }, + { + "id": "example_4", + "name": "Page view event", + "code": "# Page view event\nposthog.capture('$pageview', distinct_id=\"distinct_id_of_the_user\", properties={'$current_url': 'https://example.com'})" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Capture", + "params": [ + { + "name": "event", + "description": "", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + } + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as ``$trace_id`` and ``$span_id`` event properties.", + "details": "", + "category": "Error Tracking", + "params": [ + { + "name": "exception", + "description": "The exception to capture.", + "isOptional": true, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "try:\n # Some code that might fail\n pass\nexcept Exception as e:\n posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` and reason ``\"Evaluated locally\"``.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If True, never fall back to remote evaluation \u2014 flags that can't be evaluated locally are simply omitted from the snapshot.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is True. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flags = posthog.evaluate_flags(\n \"user_123\",\n person_properties={\"plan\": \"enterprise\"},\n)\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\nposthog.capture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Check if a feature flag is enabled for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "flush", + "title": "flush", + "description": "Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.", + "details": "", + "category": null, + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely. Queued spans are sent at the same time, within the same", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.capture('event_name')\nposthog.flush() # Ensures the event is sent immediately" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all feature flags for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flags and their payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.get_all_flags_and_payloads('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get multivariate feature flag value for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nruntime = posthog.get_feature_flag_evaluation_runtime(\"my-flag\")\nif runtime is FeatureFlagEvaluationRuntime.SERVER:\n ..." + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate. A flag set to ``FeatureFlagEvaluationRuntime.ALL`` suits either runtime, so it is returned for ``CLIENT`` and for ``SERVER``, and asking for ``ALL`` returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "The runtime to match, as a ``FeatureFlagEvaluationRuntime`` or its string value.", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime\n\nclient_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(\n FeatureFlagEvaluationRuntime.CLIENT\n)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload for a feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "The specific flag value to get payload for.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Deprecated. Use get_feature_flag() instead if you need events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')\n\nif is_my_flag_enabled:\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to only evaluate locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')\nif flag_result and flag_result.get_value() == 'variant-key':\n # Do something differently for this user\n # Optional: fetch the payload\n matched_flag_payload = flag_result.payload" + } + ] + }, + { + "id": "get_feature_flags_and_payloads", + "title": "get_feature_flags_and_payloads", + "description": "Get feature flags and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "result = posthog.get_feature_flags_and_payloads('')" + } + ] + }, + { + "id": "get_feature_payloads", + "title": "get_feature_payloads", + "description": "Get feature flag payloads for a user, preserving valid serialized JSON.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Optional[str]]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "payloads = posthog.get_feature_payloads('')" + } + ] + }, + { + "id": "get_feature_variants", + "title": "get_feature_variants", + "description": "Get feature flag variants for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Union[bool, str]]" + } + }, + { + "id": "get_flags_decision", + "title": "get_flags_decision", + "description": "Get feature flags decision.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID of the user.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "A dictionary of group information.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "A dictionary of person properties.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "A dictionary of group properties.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this request.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys_to_evaluate", + "description": "A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "The device ID for this request.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsResponse" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "decision = posthog.get_flags_decision('user123')" + } + ] + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The remote config feature flag key.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context.", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Identify a group and set its properties.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "A dictionary of properties to set on the group.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.", + "isOptional": false, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP for this event.", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "The distinct ID of the user performing the action.", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.group_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and end the consumer threads. Do not use directly, call `shutdown()` instead. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.join()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flags for local evaluation.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.load_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "with client.new_context():\n client.identify_context('')\n client.capture('event_name')" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the wrapped function using this client.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to create a fresh context that doesn't inherit from parent.", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a person profile.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set with distinct id", + "code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a person profile only if they haven't been set before.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})" + } + ] + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. 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 invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.", + "details": "", + "category": null, + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog.shutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "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.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "posthog = Posthog(\"\", traces={\"service_name\": \"checkout-api\"})\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value.", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + } + } + ] + }, + { + "id": "PostHogModule", + "title": "PostHog Module Functions", + "description": "Global functions available in the PostHog module", + "functions": [ + { + "id": "alias", + "title": "alias", + "description": "Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "details": "To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like \"Which marketing channels leads to users churning after a month?\" or \"What do users do on our website before signing up?\". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.", + "category": "Identification", + "params": [ + { + "name": "previous_id", + "description": "The unique ID of the user before", + "isOptional": true, + "type": "Number" + }, + { + "name": "distinct_id", + "description": "The current unique id", + "isOptional": true, + "type": "str" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Alias user", + "code": "# Alias user\nfrom posthog import alias\nalias(previous_id='distinct_id', distinct_id='alias_id')" + } + ] + }, + { + "id": "capture", + "title": "capture", + "description": "Capture anything a user does within your system.", + "details": "Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name to specify the event **kwargs: Optional arguments including:", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Context and capture usage", + "code": "# Context and capture usage\nfrom posthog import new_context, identify_context, tag_context, capture\n# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)\nwith new_context():\n # Associate this context with some user, by distinct_id\n identify_context('some user')\n\n # Capture an event, associated with the context-level distinct ID ('some user')\n capture('movie started')\n\n # Capture an event associated with some other user (overriding the context-level distinct ID)\n capture('movie joined', distinct_id='some-other-user')\n\n # Capture an event with some properties\n capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})\n\n # Capture an event with some properties\n capture('purchase', properties={'product_id': '123', 'category': 'romcom'})\n # Capture an event with some associated group\n capture('purchase', groups={'company': 'id:5'})\n\n # Adding a tag to the current context will cause it to appear on all subsequent events\n tag_context('some-tag', 'some-value')\n\n capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict" + }, + { + "id": "example_2", + "name": "Set event properties", + "code": "# Set event properties\nfrom posthog import capture\ncapture(\n \"user_signed_up\",\n distinct_id=\"distinct_id_of_the_user\",\n properties={\n \"login_type\": \"email\",\n \"is_free_trial\": \"true\"\n }\n)" + } + ] + }, + { + "id": "capture_ai", + "title": "capture_ai", + "description": "Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as `capture()`: the event UUID, or None when the event was not admitted (disabled client, or dropped by `before_send`). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given \u2014 no redaction or truncation is applied here.", + "details": "", + "category": "Events", + "params": [ + { + "name": "event", + "description": "The event name, normally one of the `$ai_*` event names. **kwargs: Same optional arguments as `capture()`.", + "isOptional": true, + "type": "str" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import capture_ai\n\nuuid = capture_ai(\n \"$ai_generation\",\n distinct_id=\"user_123\",\n properties={\"$ai_model\": \"gpt-5\"},\n)" + } + ] + }, + { + "id": "capture_exception", + "title": "capture_exception", + "description": "Capture exceptions that happen in your code.", + "details": "Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the \"traceback\"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError(\"Some Error\"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.", + "category": "Events", + "params": [ + { + "name": "exception", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", + "isOptional": false, + "type": "BaseException" + }, + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalCaptureArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Capture exception", + "code": "# Capture exception\nfrom posthog import capture_exception\ntry:\n risky_operation()\nexcept Exception as e:\n capture_exception(e)" + } + ] + }, + { + "id": "evaluate_flags", + "title": "evaluate_flags", + "description": "Evaluate all feature flags for a user in a single call and return a :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / ``.get_flag()`` and pass the same snapshot to ``capture()`` via the ``flags`` option so events carry the exact flag values the code branched on. Prefer this over repeated ``get_feature_flag()`` calls and over ``capture(send_feature_flags=True)`` \u2014 it consolidates flag evaluation into a single ``/flags`` request per incoming request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID. If ``None``, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.", + "isOptional": false, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "If ``True``, never fall back to remote evaluation and omit flags that cannot be evaluated locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "flag_keys", + "description": "Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per ``evaluate_flags`` call unless ``only_evaluate_locally`` is ``True``. If the server also does not know the key, it is omitted from the snapshot.", + "isOptional": true, + "type": "list[str]" + }, + { + "name": "device_id", + "description": "Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FeatureFlagEvaluations" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import evaluate_flags, capture\nflags = evaluate_flags(\"user_123\", person_properties={\"plan\": \"enterprise\"})\nif flags.is_enabled(\"new-dashboard\"):\n render_new_dashboard()\ncapture(\"page_viewed\", distinct_id=\"user_123\", flags=flags)" + } + ] + }, + { + "id": "feature_enabled", + "title": "feature_enabled", + "description": "Use feature flags to enable or disable features for users.", + "details": "You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[bool]" + }, + "examples": [ + { + "id": "example_1", + "name": "Boolean feature flag", + "code": "# Boolean feature flag\nfrom posthog import feature_enabled, get_feature_flag_payload\nis_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')\nif is_my_flag_enabled:\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "feature_flag_definitions", + "title": "feature_flag_definitions", + "description": "Returns loaded feature flags.", + "details": "Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import feature_flag_definitions\ndefinitions = feature_flag_definitions()" + } + ] + }, + { + "id": "flush", + "title": "flush", + "description": "Tell the client to flush all queued events.", + "details": "", + "category": "Client management", + "params": [ + { + "name": "timeout_seconds", + "description": "Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass ``None`` to wait indefinitely.", + "isOptional": true, + "type": "float" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import flush\nflush()" + } + ] + }, + { + "id": "get_active_span", + "title": "get_active_span", + "description": "The span that is active in the current context, or ``None``. Alpha. Only entering a span (``with posthog.start_span(...) as span:``) makes it active; a span started manually is not. Use it to propagate the trace to the next service: ``span.traceparent()`` is the header value.", + "details": "", + "category": "Tracing", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[Span]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "span = posthog.get_active_span()\nif span is not None:\n headers[\"traceparent\"] = span.traceparent()" + } + ] + }, + { + "id": "get_all_flags", + "title": "get_all_flags", + "description": "Get all flags for a given user.", + "details": "Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate (evaluates all if None)", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[dict[str, Union[bool, str]]]" + }, + "examples": [ + { + "id": "example_1", + "name": "All flags for user", + "code": "# All flags for user\nfrom posthog import get_all_flags\nget_all_flags('distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_all_flags_and_payloads", + "title": "get_all_flags_and_payloads", + "description": "Get all feature flag values and payloads for a user.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + }, + { + "name": "flag_keys_to_evaluate", + "description": "Optional list of flag keys to evaluate. Evaluates all flags when omitted.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "FlagsAndPayloads" + } + }, + { + "id": "get_feature_flag", + "title": "get_feature_flag", + "description": "Get feature flag variant for users. Used with experiments.", + "details": "`groups` are a mapping from group type to group key. So, if you have a group type of \"organization\" and a group key of \"5\", you would pass groups={\"organization\": \"5\"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type \"organization\" and the group key \"5\", with the properties name, and employee count, you'll send these as: group_properties={\"organization\": {\"name\": \"PostHog\", \"employees\": 11}}.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Groups mapping from group type to group key", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties in format { group_type_name: { group_properties } }", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send feature flag events", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Union[bool, str, any]" + }, + "examples": [ + { + "id": "example_1", + "name": "Multivariate feature flag", + "code": "# Multivariate feature flag\nfrom posthog import get_feature_flag, get_feature_flag_payload\nenabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')\nif enabled_variant == 'variant-key':\n matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')" + } + ] + }, + { + "id": "get_feature_flag_evaluation_runtime", + "title": "get_feature_flag_evaluation_runtime", + "description": "Return where a locally loaded feature flag is meant to be evaluated.", + "details": "Reads the `evaluation_runtime` each flag definition carries, so no extra request is made. Returns `None` when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports `FeatureFlagEvaluationRuntime.ALL`, the default PostHog applies.", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagEvaluationRuntime]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime\nruntime = get_feature_flag_evaluation_runtime(\"my-flag\")" + } + ] + }, + { + "id": "get_feature_flag_keys_by_evaluation_runtime", + "title": "get_feature_flag_keys_by_evaluation_runtime", + "description": "Return the keys of locally loaded flags that a runtime can evaluate.", + "details": "A flag set to `FeatureFlagEvaluationRuntime.ALL` suits either runtime, so it is returned for `CLIENT` and for `SERVER`. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.", + "category": "Feature flags", + "params": [ + { + "name": "evaluation_runtime", + "description": "", + "isOptional": true, + "type": "FeatureFlagEvaluationRuntime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "list[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime\nclient_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)" + } + ] + }, + { + "id": "get_feature_flag_payload", + "title": "get_feature_flag_payload", + "description": "Get the payload associated with a feature flag value. Deprecated for new code. Prefer ``evaluate_flags()`` and ``flags.get_flag_payload(key)`` so flag evaluation happens once per request.", + "details": "", + "category": "Feature flags", + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "match_value", + "description": "Optional flag value to use when selecting a payload.", + "isOptional": false, + "type": "bool" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[object]" + } + }, + { + "id": "get_feature_flag_result", + "title": "get_feature_flag_result", + "description": "Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The feature flag key.", + "isOptional": true, + "type": "str" + }, + { + "name": "distinct_id", + "description": "The user's distinct ID.", + "isOptional": true, + "type": "Number" + }, + { + "name": "groups", + "description": "Mapping of group type to group key.", + "isOptional": true, + "type": "Mapping[str, Union[str, int]]" + }, + { + "name": "person_properties", + "description": "Person properties to use for evaluation.", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "group_properties", + "description": "Group properties keyed by group type.", + "isOptional": true, + "type": "dict[str, dict[str, Any]]" + }, + { + "name": "only_evaluate_locally", + "description": "Whether to evaluate only locally.", + "isOptional": false, + "type": "bool" + }, + { + "name": "send_feature_flag_events", + "description": "Whether to send a $feature_flag_called event.", + "isOptional": false, + "type": "bool" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup.", + "isOptional": true, + "type": "bool" + }, + { + "name": "device_id", + "description": "Optional device ID override for experience-continuity flags.", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[FeatureFlagResult]" + } + }, + { + "id": "get_remote_config_payload", + "title": "get_remote_config_payload", + "description": "Get the payload for a remote config feature flag.", + "details": "", + "category": null, + "params": [ + { + "name": "key", + "description": "The key of the feature flag", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "get_tags", + "title": "get_tags", + "description": "Get all tags from the current context. Returns: Dict of all tags in the current context", + "details": "", + "category": "Contexts", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "dict[str, Any]" + } + }, + { + "id": "group_identify", + "title": "group_identify", + "description": "Set properties on a group.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "group_type", + "description": "Type of your group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "group_key", + "description": "Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.", + "isOptional": true, + "type": "str" + }, + { + "name": "properties", + "description": "Properties to set on the group", + "isOptional": true, + "type": "dict[str, Any]" + }, + { + "name": "timestamp", + "description": "Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.", + "isOptional": false, + "type": "datetime" + }, + { + "name": "uuid", + "description": "Optional UUID for the event", + "isOptional": true, + "type": "str" + }, + { + "name": "disable_geoip", + "description": "Whether to disable GeoIP lookup", + "isOptional": true, + "type": "bool" + }, + { + "name": "distinct_id", + "description": "Optional distinct ID of the user performing the action", + "isOptional": false, + "type": "Number" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Group identify", + "code": "# Group identify\nfrom posthog import group_identify\ngroup_identify('company', 'company_id_in_your_db', {\n 'name': 'Awesome Inc.',\n 'employees': 11\n})" + } + ] + }, + { + "id": "identify_context", + "title": "identify_context", + "description": "Identify the current context with a distinct ID.", + "details": "", + "category": "Identification", + "params": [ + { + "name": "distinct_id", + "description": "The distinct ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import identify_context\nidentify_context(\"user_123\")" + } + ] + }, + { + "id": "join", + "title": "join", + "description": "Attempt to process queued events and stop the client's background workers. Use `shutdown()` directly in most cases. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import join\njoin()" + } + ] + }, + { + "id": "load_feature_flags", + "title": "load_feature_flags", + "description": "Load feature flag definitions from PostHog.", + "details": "", + "category": "Feature flags", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import load_feature_flags\nload_feature_flags()" + } + ] + }, + { + "id": "new_context", + "title": "new_context", + "description": "Create a new context scope that will be active for the duration of the with block.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.", + "isOptional": true, + "type": "bool" + }, + { + "name": "client", + "description": "Optional Posthog client instance to use for this context (default: None)", + "isOptional": true, + "type": "Client" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import new_context, tag, capture\nwith new_context():\n tag(\"request_id\", \"123\")\n capture(\"event_name\", properties={\"property\": \"value\"})" + } + ] + }, + { + "id": "scoped", + "title": "scoped", + "description": "Decorator that creates a new context for the function.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "fresh", + "description": "Whether to start with a fresh context (default: False)", + "isOptional": false, + "type": "bool" + }, + { + "name": "capture_exceptions", + "description": "Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import scoped, tag, capture\n@scoped()\ndef process_payment(payment_id):\n tag(\"payment_id\", payment_id)\n capture(\"payment_started\")" + } + ] + }, + { + "id": "set", + "title": "set", + "description": "Set properties on a user record.", + "details": "This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set person properties", + "code": "# Set person properties\nfrom posthog import set\nset(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})" + } + ] + }, + { + "id": "set_capture_exception_code_variables_context", + "title": "set_capture_exception_code_variables_context", + "description": "Override code-variable capture for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "Whether exceptions captured in this context should include local variable values from stack frames.", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_detect_secrets_context", + "title": "set_code_variables_detect_secrets_context", + "description": "Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_ignore_patterns_context", + "title": "set_code_variables_ignore_patterns_context", + "description": "Override code-variable ignore patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "ignore_patterns", + "description": "Variable-name patterns that should be omitted entirely when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_patterns_context", + "title": "set_code_variables_mask_patterns_context", + "description": "Override code-variable mask patterns for exceptions in the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "mask_patterns", + "description": "Variable-name patterns whose values should be replaced with ``***`` when code variables are captured.", + "isOptional": true, + "type": "list[str]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_code_variables_mask_url_credentials_context", + "title": "set_code_variables_mask_url_credentials_context", + "description": "Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.", + "details": "", + "category": null, + "params": [ + { + "name": "enabled", + "description": "", + "isOptional": true, + "type": "bool" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + } + }, + { + "id": "set_context_device_id", + "title": "set_context_device_id", + "description": "Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "device_id", + "description": "The device ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_device_id\nset_context_device_id(\"device_123\")" + } + ] + }, + { + "id": "set_context_session", + "title": "set_context_session", + "description": "Set the session ID for the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "session_id", + "description": "The session ID to associate with the current context and its children", + "isOptional": true, + "type": "str" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import set_context_session\nset_context_session(\"session_123\")" + } + ] + }, + { + "id": "set_once", + "title": "set_once", + "description": "Set properties on a user record, only if they do not yet exist.", + "details": "This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.", + "category": "Identification", + "params": [ + { + "name": "kwargs", + "description": "", + "isOptional": true, + "type": "Unpack[OptionalSetArgs]" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Optional[str]" + }, + "examples": [ + { + "id": "example_1", + "name": "Set property once", + "code": "# Set property once\nfrom posthog import set_once\nset_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})" + } + ] + }, + { + "id": "setup", + "title": "setup", + "description": "Create or return the global PostHog client configured by module settings. Most applications should either instantiate ``Posthog`` directly or set ``posthog.api_key``/other module settings before calling top-level helpers. ``setup()`` is called automatically by global APIs such as ``capture()``. Returns: The global ``Client`` instance. If both ``api_key`` and ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops.", + "details": "", + "category": "Initialization", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Client" + } + }, + { + "id": "shutdown", + "title": "shutdown", + "description": "Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or ``on_error``; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as ``on_error`` are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call ``shutdown()`` from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.", + "details": "", + "category": "Client management", + "params": [], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "any" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import shutdown\nshutdown()" + } + ] + }, + { + "id": "start_span", + "title": "start_span", + "description": "Start a span for distributed tracing. Alpha. 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.", + "details": "", + "category": "Tracing", + "params": [ + { + "name": "name", + "description": "A low-cardinality operation name, e.g. ``GET /users/:id``. Variable values belong in attributes, not the name.", + "isOptional": true, + "type": "str" + }, + { + "name": "kind", + "description": "``internal`` (default), ``server``, ``client``, ``producer`` or ``consumer``.", + "isOptional": true, + "type": "str" + }, + { + "name": "attributes", + "description": "Initial attributes.", + "isOptional": true, + "type": "Mapping[str, Any]" + }, + { + "name": "parent", + "description": "A span handle, or an inbound W3C ``traceparent`` header value to continue a remote trace. Defaults to the active span.", + "isOptional": false, + "type": "Span" + }, + { + "name": "tracestate", + "description": "The inbound ``tracestate`` header accompanying a ``traceparent`` string ``parent``; preserved and propagated.", + "isOptional": true, + "type": "str" + }, + { + "name": "start_time", + "description": "A ``datetime`` or epoch seconds, to backdate the span.", + "isOptional": false, + "type": "datetime" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "Span" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "import posthog\nposthog.traces = {\"service_name\": \"checkout-api\"}\n\nwith posthog.start_span(\"POST /checkout\", parent=request.headers.get(\"traceparent\")) as span:\n span.set_attribute(\"plan\", user.plan)\n with posthog.start_span(\"db.query\", kind=\"client\"):\n ...\n outgoing_headers = {\"traceparent\": span.traceparent()}" + } + ] + }, + { + "id": "tag", + "title": "tag", + "description": "Add a tag to the current context.", + "details": "", + "category": "Contexts", + "params": [ + { + "name": "name", + "description": "The tag key", + "isOptional": true, + "type": "str" + }, + { + "name": "value", + "description": "The tag value", + "isOptional": true, + "type": "Any" + } + ], + "showDocs": true, + "releaseTag": "public", + "returnType": { + "id": "return_type", + "name": "None" + }, + "examples": [ + { + "id": "example_1", + "name": "Example 1", + "code": "from posthog import tag\ntag(\"user_id\", \"123\")" + } + ] + } + ] + } + ], + "categories": [ + "Initialization", + "Identification", + "Capture", + "Error Tracking", + "Feature flags", + "Tracing", + "Contexts", + "Events", + "Client management" + ] +} \ No newline at end of file diff --git a/references/posthog-python-references-latest.json b/references/posthog-python-references-latest.json index a3912a89e..ef12885a8 100644 --- a/references/posthog-python-references-latest.json +++ b/references/posthog-python-references-latest.json @@ -2,7 +2,7 @@ "id": "posthog-python", "hogRef": "0.3", "info": { - "version": "7.64.1", + "version": "7.68.0", "id": "posthog-python", "title": "PostHog Python SDK", "description": "Integrate PostHog into any python application.", @@ -2294,7 +2294,7 @@ "params": [ { "name": "exception", - "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.", + "description": "The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. Overriding reserved exception properties through ``properties`` is deprecated and will stop working in the next major version.", "isOptional": false, "type": "BaseException" }, diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9d..ac48257d0 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -7,7 +7,7 @@ alias posthog.AsyncClient -> posthog.async_client.AsyncClient alias posthog.AsyncPosthog -> posthog.async_client.AsyncPosthog alias posthog.BeforeSendCallback -> posthog.types.BeforeSendCallback alias posthog.CaptureCompression -> posthog.capture_compression.CaptureCompression -alias posthog.CaptureMode -> posthog.capture_mode.CaptureMode +alias posthog.CaptureError -> posthog.capture_send.CaptureError alias posthog.Client -> posthog.client.Client alias posthog.DEFAULT_CODE_VARIABLES_DETECT_SECRETS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_DETECT_SECRETS alias posthog.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -51,7 +51,6 @@ alias posthog.ai.anthropic.anthropic.handle_anthropic_content_block_start -> pos alias posthog.ai.anthropic.anthropic.handle_anthropic_text_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_text_delta alias posthog.ai.anthropic.anthropic.handle_anthropic_tool_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_tool_delta alias posthog.ai.anthropic.anthropic.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.anthropic.anthropic.setup -> posthog.setup alias posthog.ai.anthropic.anthropic_async.AsyncStreamWrapper -> posthog.ai.stream.AsyncStreamWrapper alias posthog.ai.anthropic.anthropic_async.PostHogClient -> posthog.client.Client alias posthog.ai.anthropic.anthropic_async.StreamingContentBlock -> posthog.ai.types.StreamingContentBlock @@ -64,7 +63,6 @@ alias posthog.ai.anthropic.anthropic_async.handle_anthropic_content_block_start alias posthog.ai.anthropic.anthropic_async.handle_anthropic_text_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_text_delta alias posthog.ai.anthropic.anthropic_async.handle_anthropic_tool_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_tool_delta alias posthog.ai.anthropic.anthropic_async.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.anthropic.anthropic_async.setup -> posthog.setup alias posthog.ai.anthropic.anthropic_converter.FormattedContentItem -> posthog.ai.types.FormattedContentItem alias posthog.ai.anthropic.anthropic_converter.FormattedFunctionCall -> posthog.ai.types.FormattedFunctionCall alias posthog.ai.anthropic.anthropic_converter.FormattedMessage -> posthog.ai.types.FormattedMessage @@ -77,7 +75,6 @@ alias posthog.ai.anthropic.anthropic_converter.to_plain -> posthog.ai.media.to_p alias posthog.ai.anthropic.anthropic_providers.AsyncWrappedMessages -> posthog.ai.anthropic.anthropic_async.AsyncWrappedMessages alias posthog.ai.anthropic.anthropic_providers.PostHogClient -> posthog.client.Client alias posthog.ai.anthropic.anthropic_providers.WrappedMessages -> posthog.ai.anthropic.anthropic.WrappedMessages -alias posthog.ai.anthropic.anthropic_providers.setup -> posthog.setup alias posthog.ai.anthropic.extract_anthropic_tools -> posthog.ai.anthropic.anthropic_converter.extract_anthropic_tools alias posthog.ai.anthropic.format_anthropic_input -> posthog.ai.anthropic.anthropic_converter.format_anthropic_input alias posthog.ai.anthropic.format_anthropic_response -> posthog.ai.anthropic.anthropic_converter.format_anthropic_response @@ -94,7 +91,6 @@ alias posthog.ai.claude_agent_sdk.processor.Client -> posthog.client.Client alias posthog.ai.claude_agent_sdk.processor.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.claude_agent_sdk.processor.format_assistant_blocks -> posthog.ai.claude_agent_sdk.formatting.format_assistant_blocks alias posthog.ai.claude_agent_sdk.processor.format_tool_result_content -> posthog.ai.claude_agent_sdk.formatting.format_tool_result_content -alias posthog.ai.claude_agent_sdk.processor.setup -> posthog.setup alias posthog.ai.gemini.AsyncClient -> posthog.ai.gemini.gemini_async.AsyncClient alias posthog.ai.gemini.Client -> posthog.ai.gemini.gemini.Client alias posthog.ai.gemini.extract_gemini_tools -> posthog.ai.gemini.gemini_converter.extract_gemini_tools @@ -143,7 +139,6 @@ alias posthog.ai.langchain.CallbackHandler -> posthog.ai.langchain.callbacks.Cal alias posthog.ai.langchain.callbacks.Client -> posthog.client.Client alias posthog.ai.langchain.callbacks.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.langchain.callbacks.get_model_params -> posthog.ai.utils.get_model_params -alias posthog.ai.langchain.callbacks.setup -> posthog.setup alias posthog.ai.langchain.callbacks.warn_if_posthog_ai_gateway -> posthog.ai.gateway.warn_if_posthog_ai_gateway alias posthog.ai.langchain.callbacks.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.AsyncAzureOpenAI -> posthog.ai.openai.openai_providers.AsyncAzureOpenAI @@ -165,7 +160,6 @@ alias posthog.ai.openai.openai.extract_openai_usage_from_chunk -> posthog.ai.ope alias posthog.ai.openai.openai.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.openai.openai.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.openai.openai.setup -> posthog.setup alias posthog.ai.openai.openai.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.openai_async.AsyncStreamWrapper -> posthog.ai.stream.AsyncStreamWrapper alias posthog.ai.openai.openai_async.PostHogClient -> posthog.client.Client @@ -182,7 +176,6 @@ alias posthog.ai.openai.openai_async.format_openai_streaming_output -> posthog.a alias posthog.ai.openai.openai_async.get_model_params -> posthog.ai.utils.get_model_params alias posthog.ai.openai.openai_async.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai_async.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.openai.openai_async.setup -> posthog.setup alias posthog.ai.openai.openai_async.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.openai_converter.FormattedContentItem -> posthog.ai.types.FormattedContentItem alias posthog.ai.openai.openai_converter.FormattedFunctionCall -> posthog.ai.types.FormattedFunctionCall @@ -201,11 +194,9 @@ alias posthog.ai.openai.openai_providers.WrappedBeta -> posthog.ai.openai.openai alias posthog.ai.openai.openai_providers.WrappedChat -> posthog.ai.openai.openai.WrappedChat alias posthog.ai.openai.openai_providers.WrappedEmbeddings -> posthog.ai.openai.openai.WrappedEmbeddings alias posthog.ai.openai.openai_providers.WrappedResponses -> posthog.ai.openai.openai.WrappedResponses -alias posthog.ai.openai.openai_providers.setup -> posthog.setup alias posthog.ai.openai_agents.PostHogTracingProcessor -> posthog.ai.openai_agents.processor.PostHogTracingProcessor alias posthog.ai.openai_agents.processor.Client -> posthog.client.Client alias posthog.ai.openai_agents.processor.finalize_ai_content -> posthog.ai.utils.finalize_ai_content -alias posthog.ai.openai_agents.processor.setup -> posthog.setup alias posthog.ai.otel.PostHogSpanProcessor -> posthog.ai.otel.processor.PostHogSpanProcessor alias posthog.ai.otel.PostHogTraceExporter -> posthog.ai.otel.exporter.PostHogTraceExporter alias posthog.ai.otel.exporter.DEFAULT_HOST -> posthog.ai.otel.spans.DEFAULT_HOST @@ -228,21 +219,19 @@ alias posthog.ai.utils.identify_context -> posthog.identify_context alias posthog.ai.utils.new_context -> posthog.new_context alias posthog.ai.utils.redact_media -> posthog.ai.sanitization.redact_media alias posthog.ai.utils.sanitize_messages -> posthog.ai.sanitization.sanitize_messages +alias posthog.ai.utils.setup -> posthog.setup alias posthog.ai.utils.tag -> posthog.tag alias posthog.ai.utils.warn_if_posthog_ai_gateway -> posthog.ai.gateway.warn_if_posthog_ai_gateway alias posthog.args.FeatureFlagEvaluations -> posthog.feature_flag_evaluations.FeatureFlagEvaluations alias posthog.args.SendFeatureFlagsOptions -> posthog.types.SendFeatureFlagsOptions -alias posthog.client.AI_EVENTS_ENDPOINT -> posthog.request.AI_EVENTS_ENDPOINT alias posthog.client.AI_MAX_MSG_SIZE -> posthog.consumer.AI_MAX_MSG_SIZE alias posthog.client.APIError -> posthog.request.APIError alias posthog.client.CaptureCompression -> posthog.capture_compression.CaptureCompression -alias posthog.client.CaptureMode -> posthog.capture_mode.CaptureMode alias posthog.client.Consumer -> posthog.consumer.Consumer alias posthog.client.DEFAULT_CODE_VARIABLES_DETECT_SECRETS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_DETECT_SECRETS alias posthog.client.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS alias posthog.client.DEFAULT_CODE_VARIABLES_MASK_PATTERNS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_PATTERNS alias posthog.client.DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -> posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -alias posthog.client.EVENTS_ENDPOINT -> posthog.request.EVENTS_ENDPOINT alias posthog.client.ExceptionArg -> posthog.args.ExceptionArg alias posthog.client.ExceptionCapture -> posthog.exception_capture.ExceptionCapture alias posthog.client.FeatureFlag -> posthog.types.FeatureFlag @@ -273,7 +262,6 @@ alias posthog.client.SendFeatureFlagsOptions -> posthog.types.SendFeatureFlagsOp alias posthog.client.SizeLimitedDict -> posthog.utils.SizeLimitedDict alias posthog.client.Span -> posthog.tracing.span.Span alias posthog.client.VERSION -> posthog.version.VERSION -alias posthog.client.batch_post -> posthog.request.batch_post alias posthog.client.clean -> posthog.utils.clean alias posthog.client.determine_server_host -> posthog.request.determine_server_host alias posthog.client.exc_info_from_error -> posthog.exception_utils.exc_info_from_error @@ -304,12 +292,8 @@ alias posthog.client.to_flags_and_payloads -> posthog.types.to_flags_and_payload alias posthog.client.to_payloads -> posthog.types.to_payloads alias posthog.client.to_values -> posthog.types.to_values alias posthog.client.try_attach_code_variables_to_frames -> posthog.exception_utils.try_attach_code_variables_to_frames -alias posthog.consumer.APIError -> posthog.request.APIError alias posthog.consumer.CaptureCompression -> posthog.capture_compression.CaptureCompression -alias posthog.consumer.CaptureMode -> posthog.capture_mode.CaptureMode alias posthog.consumer.DatetimeSerializer -> posthog.request.DatetimeSerializer -alias posthog.consumer.EVENTS_ENDPOINT -> posthog.request.EVENTS_ENDPOINT -alias posthog.consumer.batch_post -> posthog.request.batch_post alias posthog.contexts.Client -> posthog.client.Client alias posthog.disable_connection_reuse -> posthog.request.disable_connection_reuse alias posthog.enable_keep_alive -> posthog.request.enable_keep_alive @@ -322,6 +306,7 @@ alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue alias posthog.feature_flags.convert_to_datetime_aware -> posthog.utils.convert_to_datetime_aware alias posthog.feature_flags.is_valid_regex -> posthog.utils.is_valid_regex alias posthog.feature_flags.utils -> posthog.utils +alias posthog.inner_get_context_options -> posthog.contexts.get_context_options alias posthog.inner_get_tags -> posthog.contexts.get_tags alias posthog.inner_identify_context -> posthog.contexts.identify_context alias posthog.inner_new_context -> posthog.contexts.new_context @@ -332,10 +317,14 @@ alias posthog.inner_set_code_variables_ignore_patterns_context -> posthog.contex alias posthog.inner_set_code_variables_mask_patterns_context -> posthog.contexts.set_code_variables_mask_patterns_context alias posthog.inner_set_code_variables_mask_url_credentials_context -> posthog.contexts.set_code_variables_mask_url_credentials_context alias posthog.inner_set_context_device_id -> posthog.contexts.set_context_device_id +alias posthog.inner_set_context_option -> posthog.contexts.set_context_option alias posthog.inner_set_context_session -> posthog.contexts.set_context_session alias posthog.inner_tag -> posthog.contexts.tag +alias posthog.integrations.asgi.Client -> posthog.client.Client +alias posthog.integrations.asgi.contexts -> posthog.contexts alias posthog.integrations.django.Client -> posthog.client.Client alias posthog.integrations.django.contexts -> posthog.contexts +alias posthog.integrations.drf.Client -> posthog.client.Client alias posthog.mcp.CaptureEventData -> posthog.mcp.types.CaptureEventData alias posthog.mcp.CollectFeedbackOptions -> posthog.mcp.types.CollectFeedbackOptions alias posthog.mcp.FeedbackReport -> posthog.mcp.types.FeedbackReport @@ -649,20 +638,22 @@ attribute posthog.args.OptionalCaptureArgs.disable_geoip: NotRequired[Optional[b attribute posthog.args.OptionalCaptureArgs.distinct_id: NotRequired[Optional[ID_TYPES]] attribute posthog.args.OptionalCaptureArgs.flags: NotRequired[Optional[FeatureFlagEvaluations]] attribute posthog.args.OptionalCaptureArgs.groups: NotRequired[Optional[Dict[str, str]]] +attribute posthog.args.OptionalCaptureArgs.options: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalCaptureArgs.properties: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalCaptureArgs.send_feature_flags: NotRequired[Optional[Union[bool, SendFeatureFlagsOptions]]] attribute posthog.args.OptionalCaptureArgs.timestamp: NotRequired[Optional[Union[datetime, str]]] attribute posthog.args.OptionalCaptureArgs.uuid: NotRequired[Optional[Union[str, UUID]]] attribute posthog.args.OptionalSetArgs.disable_geoip: NotRequired[Optional[bool]] attribute posthog.args.OptionalSetArgs.distinct_id: NotRequired[Optional[ID_TYPES]] +attribute posthog.args.OptionalSetArgs.options: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalSetArgs.properties: NotRequired[Optional[Dict[str, Any]]] attribute posthog.args.OptionalSetArgs.timestamp: NotRequired[Optional[Union[datetime, str]]] attribute posthog.args.OptionalSetArgs.uuid: NotRequired[Optional[Union[str, UUID]]] attribute posthog.async_client.AsyncClient.api_key = (project_api_key or '').strip() attribute posthog.async_client.AsyncClient.before_send = before_send -attribute posthog.async_client.AsyncClient.capture_compression = _resolve_capture_compression(capture_compression, gzip_fallback=gzip) +attribute posthog.async_client.AsyncClient.capture_ai_compression = _resolve_capture_ai_compression(capture_ai_compression) +attribute posthog.async_client.AsyncClient.capture_compression = _resolve_capture_compression(capture_compression) attribute posthog.async_client.AsyncClient.capture_exception_code_variables = capture_exception_code_variables -attribute posthog.async_client.AsyncClient.capture_mode = _resolve_capture_mode(capture_mode) attribute posthog.async_client.AsyncClient.capture_trace_context = capture_trace_context attribute posthog.async_client.AsyncClient.code_variables_detect_secrets = code_variables_detect_secrets if code_variables_detect_secrets is not None else DEFAULT_CODE_VARIABLES_DETECT_SECRETS attribute posthog.async_client.AsyncClient.code_variables_ignore_patterns = code_variables_ignore_patterns if code_variables_ignore_patterns is not None else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -670,11 +661,11 @@ attribute posthog.async_client.AsyncClient.code_variables_mask_patterns = code_v attribute posthog.async_client.AsyncClient.code_variables_mask_url_credentials = code_variables_mask_url_credentials if code_variables_mask_url_credentials is not None else DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS attribute posthog.async_client.AsyncClient.debug = debug attribute posthog.async_client.AsyncClient.disable_geoip = disable_geoip -attribute posthog.async_client.AsyncClient.disabled = disabled or not self.api_key +attribute posthog.async_client.AsyncClient.disabled = disabled or not self.api_key or _is_personal_api_key(self.api_key) attribute posthog.async_client.AsyncClient.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) +attribute posthog.async_client.AsyncClient.enable_full_ai_capture = enable_full_ai_capture is True attribute posthog.async_client.AsyncClient.feature_flags_request_max_retries = max(0, feature_flags_request_max_retries) attribute posthog.async_client.AsyncClient.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds -attribute posthog.async_client.AsyncClient.gzip = gzip attribute posthog.async_client.AsyncClient.historical_migration = historical_migration attribute posthog.async_client.AsyncClient.host = determine_server_host(host) attribute posthog.async_client.AsyncClient.in_app_modules = in_app_modules @@ -684,10 +675,12 @@ attribute posthog.async_client.AsyncClient.log_captured_exceptions = log_capture attribute posthog.async_client.AsyncClient.max_retries = max(0, max_retries) attribute posthog.async_client.AsyncClient.on_error = on_error attribute posthog.async_client.AsyncClient.personal_api_key = self.secret_key +attribute posthog.async_client.AsyncClient.privacy_mode = privacy_mode attribute posthog.async_client.AsyncClient.project_root = os.getcwd() attribute posthog.async_client.AsyncClient.raw_host = normalize_host(host) attribute posthog.async_client.AsyncClient.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.async_client.AsyncClient.send = send +attribute posthog.async_client.AsyncClient.super_options = super_options attribute posthog.async_client.AsyncClient.super_properties = super_properties attribute posthog.async_client.AsyncClient.timeout = timeout attribute posthog.before_send = None @@ -699,19 +692,21 @@ attribute posthog.capture_compression.CaptureCompression.DEFLATE = 'deflate' attribute posthog.capture_compression.CaptureCompression.GZIP = 'gzip' attribute posthog.capture_compression.CaptureCompression.NONE = 'none' attribute posthog.capture_compression.CaptureCompression.ZSTD = 'zstd' +attribute posthog.capture_event.log = logging.getLogger('posthog') attribute posthog.capture_exception_code_variables = False -attribute posthog.capture_mode.CAPTURE_MODE_ENV_VAR = 'POSTHOG_CAPTURE_MODE' -attribute posthog.capture_mode.CaptureMode.V0 = 'v0' -attribute posthog.capture_mode.CaptureMode.V1 = 'v1' +attribute posthog.capture_send.CaptureError.attempts = attempts +attribute posthog.capture_send.CaptureError.drops = drops or [] +attribute posthog.capture_send.CaptureError.endpoint = endpoint +attribute posthog.capture_send.CaptureError.event_results = event_results or {} +attribute posthog.capture_send.CaptureError.request_id = request_id +attribute posthog.capture_send.CaptureError.retry_exhausted = retry_exhausted or [] +attribute posthog.capture_send.CaptureEventResult.details: Optional[str] = None +attribute posthog.capture_send.CaptureEventResult.result: Optional[str] attribute posthog.capture_trace_context = False -attribute posthog.capture_v1.CaptureV1Error.attempts = attempts -attribute posthog.capture_v1.CaptureV1Error.drops = drops or [] -attribute posthog.capture_v1.CaptureV1Error.request_id = request_id -attribute posthog.capture_v1.CaptureV1Error.retry_exhausted = retry_exhausted or [] attribute posthog.client.Client.api_key = (project_api_key or '').strip() -attribute posthog.client.Client.capture_compression = _resolve_capture_compression(capture_compression, gzip_fallback=gzip) +attribute posthog.client.Client.capture_ai_compression = _resolve_capture_ai_compression(capture_ai_compression) +attribute posthog.client.Client.capture_compression = _resolve_capture_compression(capture_compression) attribute posthog.client.Client.capture_exception_code_variables = capture_exception_code_variables -attribute posthog.client.Client.capture_mode = _resolve_capture_mode(capture_mode) attribute posthog.client.Client.capture_trace_context = capture_trace_context attribute posthog.client.Client.code_variables_detect_secrets = code_variables_detect_secrets if code_variables_detect_secrets is not None else DEFAULT_CODE_VARIABLES_DETECT_SECRETS attribute posthog.client.Client.code_variables_ignore_patterns = code_variables_ignore_patterns if code_variables_ignore_patterns is not None else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -721,7 +716,7 @@ attribute posthog.client.Client.cohorts: Optional[dict[str, Any]] = None attribute posthog.client.Client.consumers: Optional[List[Consumer]] attribute posthog.client.Client.debug = debug attribute posthog.client.Client.disable_geoip = disable_geoip -attribute posthog.client.Client.disabled = disabled or not self.api_key +attribute posthog.client.Client.disabled = disabled or not self.api_key or _is_personal_api_key(self.api_key) attribute posthog.client.Client.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) attribute posthog.client.Client.enable_exception_autocapture = enable_exception_autocapture attribute posthog.client.Client.enable_exception_autocapture_rate_limiting = enable_exception_autocapture_rate_limiting @@ -739,7 +734,6 @@ attribute posthog.client.Client.flag_cache = self._initialize_flag_cache(flag_fa attribute posthog.client.Client.flag_definition_version = 0 attribute posthog.client.Client.flag_fallback_cache_url = flag_fallback_cache_url attribute posthog.client.Client.group_type_mapping: Optional[dict[str, str]] = None -attribute posthog.client.Client.gzip = gzip attribute posthog.client.Client.historical_migration = historical_migration attribute posthog.client.Client.host = determine_server_host(host) attribute posthog.client.Client.in_app_modules = in_app_modules @@ -758,6 +752,7 @@ attribute posthog.client.Client.queue: Queue attribute posthog.client.Client.raw_host = normalize_host(host) attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send +attribute posthog.client.Client.super_options = super_options attribute posthog.client.Client.super_properties = super_properties attribute posthog.client.Client.sync_mode = sync_mode attribute posthog.client.Client.timeout = timeout @@ -766,16 +761,16 @@ attribute posthog.code_variables_detect_secrets = DEFAULT_CODE_VARIABLES_DETECT_ attribute posthog.code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS attribute posthog.code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS attribute posthog.code_variables_mask_url_credentials = DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS -attribute posthog.consumer.AI_MAX_MSG_SIZE = 8 * 1024 * 1024 +attribute posthog.consumer.AI_ENVELOPE_HEADROOM = 64 * 1024 +attribute posthog.consumer.AI_MAX_MSG_SIZE = AI_MAX_PROPERTIES_SIZE + AI_ENVELOPE_HEADROOM +attribute posthog.consumer.AI_MAX_PROPERTIES_SIZE = 8 * 1024 * 1024 attribute posthog.consumer.BATCH_SIZE_LIMIT = 5 * 1024 * 1024 attribute posthog.consumer.Consumer.api_key = api_key attribute posthog.consumer.Consumer.capture_compression = capture_compression -attribute posthog.consumer.Consumer.capture_mode = capture_mode attribute posthog.consumer.Consumer.daemon = True attribute posthog.consumer.Consumer.endpoint = endpoint attribute posthog.consumer.Consumer.flush_at = flush_at attribute posthog.consumer.Consumer.flush_interval = flush_interval -attribute posthog.consumer.Consumer.gzip = gzip attribute posthog.consumer.Consumer.historical_migration = historical_migration attribute posthog.consumer.Consumer.host = host attribute posthog.consumer.Consumer.log = logging.getLogger('posthog') @@ -785,7 +780,7 @@ attribute posthog.consumer.Consumer.queue = queue attribute posthog.consumer.Consumer.retries = max(0, retries) attribute posthog.consumer.Consumer.running = True attribute posthog.consumer.Consumer.timeout = timeout -attribute posthog.consumer.MAX_MSG_SIZE = 900 * 1024 +attribute posthog.consumer.MAX_MSG_SIZE = 10 * 1024 * 1024 attribute posthog.contexts.ContextScope.capture_exception_code_variables: Optional[bool] = None attribute posthog.contexts.ContextScope.capture_exceptions = capture_exceptions attribute posthog.contexts.ContextScope.client: Optional[Client] = client @@ -796,6 +791,7 @@ attribute posthog.contexts.ContextScope.code_variables_mask_url_credentials: Opt attribute posthog.contexts.ContextScope.device_id: Optional[str] = None attribute posthog.contexts.ContextScope.distinct_id: Optional[str] = None attribute posthog.contexts.ContextScope.fresh = fresh +attribute posthog.contexts.ContextScope.options: Dict[str, Any] = {} attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} @@ -861,19 +857,33 @@ attribute posthog.flag_definition_cache.FlagDefinitionCacheData.property_matchin attribute posthog.flag_definition_cache_provider = None attribute posthog.host = None attribute posthog.in_app_modules = None +attribute posthog.integrations.asgi.PosthogASGIMiddleware.app = app +attribute posthog.integrations.asgi.PosthogASGIMiddleware.capture_exceptions = capture_exceptions +attribute posthog.integrations.asgi.PosthogASGIMiddleware.client = client +attribute posthog.integrations.asgi.PosthogASGIMiddleware.extra_properties = extra_properties +attribute posthog.integrations.asgi.PosthogASGIMiddleware.request_filter = request_filter +attribute posthog.integrations.asgi.PosthogASGIMiddleware.trust_tracing_headers = trust_tracing_headers attribute posthog.integrations.celery.PosthogCeleryIntegration.capture_exceptions = capture_exceptions attribute posthog.integrations.celery.PosthogCeleryIntegration.capture_task_lifecycle_events = capture_task_lifecycle_events attribute posthog.integrations.celery.PosthogCeleryIntegration.client = client attribute posthog.integrations.celery.PosthogCeleryIntegration.propagate_context = propagate_context attribute posthog.integrations.celery.PosthogCeleryIntegration.task_filter = task_filter attribute posthog.integrations.django.PosthogContextMiddleware.async_capable = True -attribute posthog.integrations.django.PosthogContextMiddleware.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS +attribute posthog.integrations.django.PosthogContextMiddleware.capture_exceptions: Optional[bool] = capture_exceptions if (capture_exceptions is None or isinstance(capture_exceptions, bool)) else True attribute posthog.integrations.django.PosthogContextMiddleware.client = cast('Optional[Client]', settings.POSTHOG_MW_CLIENT) -attribute posthog.integrations.django.PosthogContextMiddleware.extra_tags = cast('Optional[Callable[[HttpRequest], Dict[str, Any]]]', settings.POSTHOG_MW_EXTRA_TAGS) +attribute posthog.integrations.django.PosthogContextMiddleware.extra_properties = cast('Callable[[HttpRequest], Dict[str, Any]]', extra_properties) if callable(extra_properties) else None +attribute posthog.integrations.django.PosthogContextMiddleware.extra_tags attribute posthog.integrations.django.PosthogContextMiddleware.get_response = get_response +attribute posthog.integrations.django.PosthogContextMiddleware.properties_map = cast('Callable[[Dict[str, Any]], Dict[str, Any]]', properties_map) if callable(properties_map) else None attribute posthog.integrations.django.PosthogContextMiddleware.request_filter = cast('Optional[Callable[[HttpRequest], bool]]', settings.POSTHOG_MW_REQUEST_FILTER) attribute posthog.integrations.django.PosthogContextMiddleware.sync_capable = True -attribute posthog.integrations.django.PosthogContextMiddleware.tag_map = cast('Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]', settings.POSTHOG_MW_TAG_MAP) +attribute posthog.integrations.django.PosthogContextMiddleware.tag_map +attribute posthog.integrations.django.PosthogContextMiddleware.trust_tracing_headers = trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False +attribute posthog.integrations.flask.PosthogFlaskIntegration.capture_exceptions = capture_exceptions +attribute posthog.integrations.flask.PosthogFlaskIntegration.client = client +attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_properties = extra_properties +attribute posthog.integrations.flask.PosthogFlaskIntegration.request_filter = request_filter +attribute posthog.integrations.flask.PosthogFlaskIntegration.trust_tracing_headers = trust_tracing_headers attribute posthog.is_server = True attribute posthog.log_captured_exceptions = False attribute posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware.app = app @@ -909,6 +919,7 @@ attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.INPUT_ALIASES_USED = attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.INPUT_KEYS = '$mcp_input_keys' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.INTENT = '$mcp_intent' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.INTENT_SOURCE = '$mcp_intent_source' +attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.INTERFACE = '$mcp_interface' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.IS_ERROR = '$mcp_is_error' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.LISTED_TOOL_NAMES = '$mcp_listed_tool_names' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.LLM_MODEL = '$mcp_llm_model' @@ -969,6 +980,7 @@ attribute posthog.mcp.types.MCPAnalyticsOptions.logger: Optional[LoggerFn] = Non attribute posthog.mcp.types.MCPAnalyticsOptions.missing_capability_tool_name: Optional[str] = None attribute posthog.mcp.types.MCPAnalyticsOptions.report_missing: bool = False attribute posthog.mcp.types.MCPAnalyticsOptions.resolve_input_aliases: Optional[ResolveInputAliasesFn] = None +attribute posthog.mcp.types.MCPAnalyticsOptions.resolve_original_tool: Optional[Callable[[str], Any]] = None attribute posthog.mcp.types.MCPAnalyticsOptions.server_build: Optional[str] = None attribute posthog.mcp.types.MCPAnalyticsOptions.should_record_input_key: Optional[ShouldRecordInputKeyFn] = None attribute posthog.mcp.types.PreparedToolCall.args: Optional[JsonRecord] = None @@ -1005,13 +1017,11 @@ attribute posthog.privacy_mode = False attribute posthog.project_api_key = None attribute posthog.project_root = None attribute posthog.release_id.RELEASE_ID_ENV_VAR = 'POSTHOG_RELEASE_ID' -attribute posthog.request.AI_EVENTS_ENDPOINT = '/i/v0/ai/batch/' attribute posthog.request.APIError.message = message attribute posthog.request.APIError.retry_after = retry_after attribute posthog.request.APIError.status = status attribute posthog.request.DEFAULT_HOST = US_INGESTION_ENDPOINT attribute posthog.request.EU_INGESTION_ENDPOINT = 'https://eu.i.posthog.com' -attribute posthog.request.EVENTS_ENDPOINT = '/batch/' attribute posthog.request.GetResponse.data: Any attribute posthog.request.GetResponse.etag: Optional[str] = None attribute posthog.request.GetResponse.not_modified: bool = False @@ -1027,6 +1037,7 @@ attribute posthog.request.USER_AGENT = 'posthog-python/' + VERSION attribute posthog.request.US_INGESTION_ENDPOINT = 'https://us.i.posthog.com' attribute posthog.secret_key = None attribute posthog.send = True +attribute posthog.super_options = None attribute posthog.super_properties = None attribute posthog.sync_mode = False attribute posthog.traces = None @@ -1179,14 +1190,14 @@ class posthog.ai.types.TokenUsage class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs -class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) +class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, *, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, super_options: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_max_queue_size: int = 1000, capture_ai_timeout: float = 30, capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, privacy_mode: bool = False, enable_full_ai_capture: bool = False, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1) class posthog.async_client.AsyncPosthog class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) class posthog.capture_compression.CaptureCompression -class posthog.capture_mode.CaptureMode -class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) -class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) +class posthog.capture_send.CaptureError(status: int | str, message: str, *, endpoint: str, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None, event_results: Optional[dict[str, CaptureEventResult]] = None) +class posthog.capture_send.CaptureEventResult(result: Optional[str], details: Optional[str] = None) +class posthog.client.Client(project_api_key: str, host=None, *, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, super_options=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_compression: Optional[Union[CaptureCompression, str]] = None, capture_ai_max_queue_size: int = 1000, capture_ai_timeout: float = 30, capture_ai_max_event_bytes: int = AI_MAX_MSG_SIZE, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) +class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, retries=3, timeout=15, historical_migration=False, endpoint=_CAPTURE_V1_PATH, max_msg_size=MAX_MSG_SIZE, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) @@ -1197,8 +1208,10 @@ class posthog.feature_flags.InconclusiveMatchError class posthog.feature_flags.RequiresServerEvaluation class posthog.flag_definition_cache.FlagDefinitionCacheData class posthog.flag_definition_cache.FlagDefinitionCacheProvider +class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[_RequestFilter] = None, extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: bool = False) class posthog.integrations.celery.PosthogCeleryIntegration(client: Optional[Client] = None, capture_exceptions: bool = True, capture_task_lifecycle_events: bool = True, propagate_context: bool = True, task_filter: Optional[Callable[[Optional[str], dict[str, Any]], bool]] = None) class posthog.integrations.django.PosthogContextMiddleware(get_response) +class posthog.integrations.flask.PosthogFlaskIntegration(app: Optional[Flask] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[Callable[[Request], bool]] = None, extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None, trust_tracing_headers: bool = False) class posthog.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent @@ -1210,7 +1223,7 @@ class posthog.mcp.types.CollectFeedbackOptions(tool_name: Optional[str] = None, class posthog.mcp.types.FeedbackReport(feedback_type: str = 'other', summary: str = '', sentiment: Optional[str] = None, friction_points: Optional[str] = None, suggested_improvement: Optional[str] = None, details: Optional[str] = None, tool_name: Optional[str] = None, task_completed: Optional[bool] = None, extras: JsonRecord = dict(), raw: JsonRecord = dict()) class posthog.mcp.types.MCPAnalyticsContextOptions(description: Optional[str] = None) class posthog.mcp.types.MCPAnalyticsModelOptions(description: Optional[str] = None) -class posthog.mcp.types.MCPAnalyticsOptions(logger: Optional[LoggerFn] = None, report_missing: bool = False, missing_capability_tool_name: Optional[str] = None, enable_conversation_id: bool = True, enable_exception_autocapture: bool = True, context: Union[bool, MCPAnalyticsContextOptions] = True, identify: Optional[Union[IdentifyFn, UserIdentity]] = None, intent_fallback: Optional[IntentFallbackFn] = None, before_send: Optional[BeforeSendFn] = None, event_properties: Optional[EventPropertiesFn] = None, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, server_build: Optional[str] = None, should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, resolve_input_aliases: Optional[ResolveInputAliasesFn] = None) +class posthog.mcp.types.MCPAnalyticsOptions(logger: Optional[LoggerFn] = None, report_missing: bool = False, missing_capability_tool_name: Optional[str] = None, enable_conversation_id: bool = True, enable_exception_autocapture: bool = True, context: Union[bool, MCPAnalyticsContextOptions] = True, identify: Optional[Union[IdentifyFn, UserIdentity]] = None, intent_fallback: Optional[IntentFallbackFn] = None, before_send: Optional[BeforeSendFn] = None, event_properties: Optional[EventPropertiesFn] = None, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, server_build: Optional[str] = None, should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, resolve_input_aliases: Optional[ResolveInputAliasesFn] = None, resolve_original_tool: Optional[Callable[[str], Any]] = None) class posthog.mcp.types.PreparedToolCall(args: Optional[JsonRecord] = None, intent: Optional[str] = None, intent_source: Optional[str] = None, is_missing_capability: bool = False, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, is_feedback: bool = False, feedback_report: Optional[FeedbackReport] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, _conversation_state: Optional[PreparedConversationState] = None) class posthog.mcp.types.ToolInputOptions(should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, input_aliases: Optional[InputAliasMap] = None) class posthog.mcp.types.UserIdentity(distinct_id: str, properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None) @@ -1313,10 +1326,10 @@ function posthog.ai.utils.merge_system_prompt(kwargs: Dict[str, Any], provider: function posthog.ai.utils.merge_usage_stats(target: TokenUsage, source: TokenUsage, mode: str = 'incremental') -> None function posthog.ai.utils.serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]] function posthog.ai.utils.with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any) -function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] function posthog.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] -function posthog.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +function posthog.capture_exception(exception: Optional[ExceptionArg] = None, *, level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.client.add_context_tags(properties) function posthog.client.get_identity_state(passed) -> tuple[str, bool] function posthog.client.no_throw(default_return=None) @@ -1328,6 +1341,7 @@ function posthog.contexts.get_code_variables_mask_patterns_context() -> Optional function posthog.contexts.get_code_variables_mask_url_credentials_context() -> Optional[bool] function posthog.contexts.get_context_device_id() -> Optional[str] function posthog.contexts.get_context_distinct_id() -> Optional[str] +function posthog.contexts.get_context_options() -> Dict[str, Any] function posthog.contexts.get_context_session_id() -> Optional[str] function posthog.contexts.get_tags() -> Dict[str, Any] function posthog.contexts.identify_context(distinct_id: str) -> None @@ -1339,6 +1353,7 @@ function posthog.contexts.set_code_variables_ignore_patterns_context(ignore_patt function posthog.contexts.set_code_variables_mask_patterns_context(mask_patterns: list) -> None function posthog.contexts.set_code_variables_mask_url_credentials_context(enabled: bool) -> None function posthog.contexts.set_context_device_id(device_id: str) -> None +function posthog.contexts.set_context_option(key: str, value: Any) -> None function posthog.contexts.set_context_session(session_id: str) -> None function posthog.contexts.tag(key: str, value: Any) -> None function posthog.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys: Optional[list[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations @@ -1393,6 +1408,7 @@ function posthog.flush(timeout_seconds: Optional[float] = 10) -> None function posthog.get_active_span() -> Optional[Span] function posthog.get_all_flags(distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None, flag_keys_to_evaluate: Optional[list[str]] = None) -> Optional[dict[str, FlagValue]] function posthog.get_all_flags_and_payloads(distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None, flag_keys_to_evaluate: Optional[list[str]] = None) -> FlagsAndPayloads +function posthog.get_context_options() -> Dict[str, Any] function posthog.get_feature_flag(key: str, distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FlagValue] function posthog.get_feature_flag_evaluation_runtime(key: str) -> Optional[FeatureFlagEvaluationRuntime] function posthog.get_feature_flag_keys_by_evaluation_runtime(evaluation_runtime: Union[FeatureFlagEvaluationRuntime, str]) -> list[str] @@ -1400,9 +1416,11 @@ function posthog.get_feature_flag_payload(key: str, distinct_id: ID_TYPES, match function posthog.get_feature_flag_result(key: str, distinct_id: ID_TYPES, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FeatureFlagResult] function posthog.get_remote_config_payload(key: str) function posthog.get_tags() -> Dict[str, Any] -function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) +function posthog.integrations.drf.create_exception_handler(handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, capture_4xx: bool = False, exception_filter: Optional[Callable[[Exception, Any, Mapping[str, Any]], bool]] = None) -> Callable[[Exception, Mapping[str, Any]], Any] +function posthog.integrations.drf.exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any function posthog.join() -> None function posthog.load_feature_flags() function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None @@ -1419,14 +1437,13 @@ function posthog.mcp.session_token.encode_session_id(payload: SessionTokenPayloa function posthog.mcp.session_token.read_mcp_session_header(headers: Any) -> Optional[str] function posthog.mcp.tools.get_more_tools_result() -> Dict[str, Any] function posthog.new_context(fresh: bool = False, capture_exceptions: Optional[bool] = None, client: Optional[Client] = None) -function posthog.request.batch_post(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, path: str = EVENTS_ENDPOINT, **kwargs) -> requests.Response function posthog.request.determine_server_host(host: Optional[str]) -> str function posthog.request.disable_connection_reuse() -> None function posthog.request.enable_keep_alive() -> None -function posthog.request.flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, max_retries: int = 1, **kwargs) -> Any +function posthog.request.flags(api_key: str, host: Optional[str] = None, *, timeout: int = 15, max_retries: int = 1, **kwargs) -> Any function posthog.request.get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None, etag: Optional[str] = None) -> GetResponse function posthog.request.normalize_host(host: Optional[str]) -> str -function posthog.request.post(api_key: str, host: Optional[str] = None, path: Optional[str] = None, gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs) -> requests.Response +function posthog.request.post(api_key: str, host: Optional[str] = None, path: Optional[str] = None, *, timeout: int = 15, session: Optional[requests.Session] = None, **kwargs) -> requests.Response function posthog.request.remote_config(personal_api_key: str, project_api_key: str, host: Optional[str] = None, key: str = '', timeout: int = 15) -> Any function posthog.request.reset_sessions() -> None function posthog.request.set_socket_options(socket_options: Optional[SocketOptions]) -> None @@ -1438,6 +1455,7 @@ function posthog.set_code_variables_ignore_patterns_context(ignore_patterns: lis function posthog.set_code_variables_mask_patterns_context(mask_patterns: list[str]) function posthog.set_code_variables_mask_url_credentials_context(enabled: bool) function posthog.set_context_device_id(device_id: str) +function posthog.set_context_option(key: str, value: Any) -> None function posthog.set_context_session(session_id: str) function posthog.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] function posthog.setup() -> Client @@ -1566,24 +1584,27 @@ method posthog.ai.prompts.Prompts.get(name: str, *, with_metadata: Optional[bool method posthog.ai.prompts.Prompts.get_all(*, label: Optional[str] = None) -> Dict[str, PromptResult] method posthog.ai.stream.AsyncStreamWrapper.aclose() -> None method posthog.ai.stream.AsyncStreamWrapper.close() -> None -method posthog.async_client.AsyncClient.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +method posthog.async_client.AsyncClient.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.async_client.AsyncClient.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] -method posthog.async_client.AsyncClient.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +method posthog.async_client.AsyncClient.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +method posthog.async_client.AsyncClient.capture_ai_immediate(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +method posthog.async_client.AsyncClient.capture_exception(exception: Optional[ExceptionArg] = None, *, level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.async_client.AsyncClient.capture_immediate(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.async_client.AsyncClient.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, disable_geoip: Optional[bool] = None, flag_keys: Optional[list[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations method posthog.async_client.AsyncClient.flush(timeout_seconds: Optional[float] = 10) -> None method posthog.async_client.AsyncClient.get_remote_config_payload(key: str) -> Optional[Any] -method posthog.async_client.AsyncClient.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +method posthog.async_client.AsyncClient.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.async_client.AsyncClient.join() -> None method posthog.async_client.AsyncClient.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.async_client.AsyncClient.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.async_client.AsyncClient.shutdown() -> None method posthog.bucketed_rate_limiter.BucketedRateLimiter.consume_rate_limit(key: Hashable) -> bool method posthog.bucketed_rate_limiter.BucketedRateLimiter.stop() -> None -method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +method posthog.capture_send.CaptureError.verdict_summary() -> str +method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.client.Client.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] -method posthog.client.Client.capture_exception(exception: Optional[ExceptionArg], **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +method posthog.client.Client.capture_exception(exception: Optional[ExceptionArg], *, level: Optional[str] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys: Optional[List[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations method posthog.client.Client.feature_enabled(key: str, distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[bool] method posthog.client.Client.feature_flag_definitions() @@ -1591,6 +1612,7 @@ method posthog.client.Client.flush(timeout_seconds: Optional[float] = 10) -> Non method posthog.client.Client.get_active_span() -> Optional[Span] method posthog.client.Client.get_all_flags(distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> Optional[dict[str, Union[bool, str]]] method posthog.client.Client.get_all_flags_and_payloads(distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> FlagsAndPayloads +method posthog.client.Client.get_context_options() -> Dict[str, Any] method posthog.client.Client.get_feature_flag(key: str, distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[FlagValue] method posthog.client.Client.get_feature_flag_evaluation_runtime(key: str) -> Optional[FeatureFlagEvaluationRuntime] method posthog.client.Client.get_feature_flag_keys_by_evaluation_runtime(evaluation_runtime: Union[FeatureFlagEvaluationRuntime, str]) -> list[str] @@ -1602,7 +1624,7 @@ method posthog.client.Client.get_feature_variants(distinct_id: ID_TYPES, groups: method posthog.client.Client.get_flags_decision(distinct_id: Optional[ID_TYPES] = None, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, disable_geoip: Optional[bool] = None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> FlagsResponse method posthog.client.Client.get_remote_config_payload(key: str) method posthog.client.Client.get_tags() -> Dict[str, Any] -method posthog.client.Client.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str] +method posthog.client.Client.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, options: Optional[Dict[str, Any]] = None) -> Optional[str] method posthog.client.Client.identify_context(distinct_id: str) -> None method posthog.client.Client.join() -> None method posthog.client.Client.load_feature_flags() @@ -1610,6 +1632,7 @@ method posthog.client.Client.new_context(fresh=False, capture_exceptions: Option method posthog.client.Client.scoped(fresh=False, capture_exceptions: Optional[bool] = None) method posthog.client.Client.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.client.Client.set_context_device_id(device_id: str) -> None +method posthog.client.Client.set_context_option(key: str, value: Any) -> None method posthog.client.Client.set_context_session(session_id: str) -> None method posthog.client.Client.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.client.Client.shutdown() -> None @@ -1620,7 +1643,9 @@ method posthog.consumer.Consumer.pause() method posthog.consumer.Consumer.request(batch) method posthog.consumer.Consumer.run() method posthog.consumer.Consumer.upload() +method posthog.contexts.ContextScope.add_option(key: str, value: Any) method posthog.contexts.ContextScope.add_tag(key: str, value: Any) +method posthog.contexts.ContextScope.collect_options() -> Dict[str, Any] method posthog.contexts.ContextScope.collect_tags() -> Dict[str, Any] method posthog.contexts.ContextScope.get_capture_exception_code_variables() -> Optional[bool] method posthog.contexts.ContextScope.get_code_variables_detect_secrets() -> Optional[bool] @@ -1662,11 +1687,14 @@ method posthog.flag_definition_cache.FlagDefinitionCacheProvider.shutdown() -> O method posthog.integrations.celery.PosthogCeleryIntegration.instrument() -> None method posthog.integrations.celery.PosthogCeleryIntegration.shutdown() -> None method posthog.integrations.celery.PosthogCeleryIntegration.uninstrument() -> None +method posthog.integrations.django.PosthogContextMiddleware.aextract_properties(request) method posthog.integrations.django.PosthogContextMiddleware.aextract_request_user(request) method posthog.integrations.django.PosthogContextMiddleware.aextract_tags(request) +method posthog.integrations.django.PosthogContextMiddleware.extract_properties(request) method posthog.integrations.django.PosthogContextMiddleware.extract_request_user(request) method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request) method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception) +method posthog.integrations.flask.PosthogFlaskIntegration.init_app(app: Flask) -> None method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None method posthog.mcp.posthog_mcp.PostHogMCP.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1763,8 +1791,8 @@ module posthog.args module posthog.async_client module posthog.bucketed_rate_limiter module posthog.capture_compression -module posthog.capture_mode -module posthog.capture_v1 +module posthog.capture_event +module posthog.capture_send module posthog.client module posthog.consumer module posthog.contexts @@ -1774,8 +1802,11 @@ module posthog.feature_flag_evaluations module posthog.feature_flags module posthog.flag_definition_cache module posthog.integrations +module posthog.integrations.asgi module posthog.integrations.celery module posthog.integrations.django +module posthog.integrations.drf +module posthog.integrations.flask module posthog.mcp module posthog.mcp.asgi module posthog.mcp.constants diff --git a/sdk_compliance_adapter/CONTRIBUTING.md b/sdk_compliance_adapter/CONTRIBUTING.md index 3033609b3..ffddda4b3 100644 --- a/sdk_compliance_adapter/CONTRIBUTING.md +++ b/sdk_compliance_adapter/CONTRIBUTING.md @@ -4,12 +4,12 @@ This package contains the PostHog Python SDK compliance adapter used with the Po ## Running tests -Tests run automatically in CI via GitHub Actions against harness **1.1.1**, for both capture protocols. Both jobs opt into the local-evaluation suite and fail on compliance regressions. +Tests run automatically in CI via GitHub Actions against harness **1.13.1**. The compliance job runs once for each capture compression (gzip, deflate and zstd). Each run opts into the local-evaluation suite and fails on compliance regressions. Run adapter protocol tests from the repository root in an activated virtual environment: ```bash -python -m pip install -e . -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio +python -m pip install -e ".[zstd]" -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30 ``` @@ -44,7 +44,7 @@ docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-pyt docker run --rm \ --name test-harness \ --network test-network \ - ghcr.io/posthog/sdk-test-harness:1.1.1 \ + ghcr.io/posthog/sdk-test-harness:1.13.1 \ run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081 # Cleanup diff --git a/sdk_compliance_adapter/Dockerfile b/sdk_compliance_adapter/Dockerfile index c347f3266..bfc691dd3 100644 --- a/sdk_compliance_adapter/Dockerfile +++ b/sdk_compliance_adapter/Dockerfile @@ -6,8 +6,8 @@ WORKDIR /app COPY posthog/ /app/sdk/posthog/ COPY setup.py pyproject.toml README.md LICENSE /app/sdk/ -# Install the SDK from source -RUN cd /app/sdk && pip install --no-cache-dir -e . +# Install the SDK from source, with zstd for the zstd compliance job +RUN cd /app/sdk && pip install --no-cache-dir -e ".[zstd]" # Install adapter dependencies RUN pip install --no-cache-dir flask python-dateutil diff --git a/sdk_compliance_adapter/Dockerfile.v1 b/sdk_compliance_adapter/Dockerfile.v1 deleted file mode 100644 index 6891837a5..000000000 --- a/sdk_compliance_adapter/Dockerfile.v1 +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -# Copy the SDK source code -COPY posthog/ /app/sdk/posthog/ -COPY setup.py pyproject.toml README.md LICENSE /app/sdk/ - -# Install the SDK from source -RUN cd /app/sdk && pip install --no-cache-dir -e . - -# Install adapter dependencies -RUN pip install --no-cache-dir flask python-dateutil - -# Copy adapter code -COPY sdk_compliance_adapter/adapter.py /app/adapter.py - -# Select the capture-v1 protocol; same adapter code, different runtime mode. -ENV CAPTURE_MODE=v1 - -# Expose port 8080 -EXPOSE 8080 - -# Run the adapter -CMD ["python", "/app/adapter.py"] diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index eae25916b..9efbee7ce 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -30,7 +30,7 @@ The adapter implements the standard SDK adapter interface defined in the [test h ### Key Implementation Details -**Request Tracking**: The adapter monkey-patches `batch_post` to track all HTTP requests made by the SDK, including retries. +**Request Tracking**: The adapter monkey-patches the capture v1 `_post_v1` to track all HTTP requests made by the SDK, including retries. **State Management**: Thread-safe state tracking for events captured vs sent, retry attempts, and errors. @@ -38,8 +38,8 @@ The adapter implements the standard SDK adapter interface defined in the [test h ### Local feature flag evaluation -Both capture adapters advertise `feature_flags_local_evaluation_v1` for harness -**1.1.1**. The capability versions the adapter protocol and tests both legacy and +The adapter advertises `feature_flags_local_evaluation_v1` for harness +**1.13.1**. The capability versions the adapter protocol and tests both legacy and explicit property matching; it does not change the SDK's default matching mode. - `/init` maps optional `personal_api_key` to the SDK's `secret_key`. Ordinary diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 0d803ae52..7a9ec437c 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -15,9 +15,9 @@ from posthog import Client from posthog.capture_compression import CaptureCompression -from posthog.capture_v1 import _post_v1 as original_post_v1 -from posthog.request import EVENTS_ENDPOINT, USER_AGENT -from posthog.request import batch_post as original_batch_post +from posthog.capture_send import _CAPTURE_V1_PATH +from posthog.capture_send import _post_v1 as original_post_v1 +from posthog.request import USER_AGENT from posthog.version import VERSION # Configure logging @@ -28,15 +28,18 @@ app = Flask(__name__) -# Selects which capture protocol this adapter process speaks. Baked at build -# time via the CAPTURE_MODE env var ("v1" => capture-v1, anything else => legacy -# v0), mirroring the v0/v1 Dockerfile split. One process speaks one mode and -# advertises it via /health capabilities. -CAPTURE_MODE = os.environ.get("CAPTURE_MODE", "") +# The harness has one enable_compression flag but a test per codec, so each +# adapter instance takes its codec from COMPRESSION and advertises only that +# codec's encoding_ capability. The SDK has no brotli support. +COMPRESSION_CODECS = { + "gzip": CaptureCompression.GZIP, + "deflate": CaptureCompression.DEFLATE, + "zstd": CaptureCompression.ZSTD, +} -def is_v1() -> bool: - return CAPTURE_MODE == "v1" +def compression_name() -> str: + return os.environ.get("COMPRESSION") or "gzip" class RequestInfo: @@ -80,7 +83,6 @@ def __init__(self): self.client: Optional[Client] = None self.remote_client: Client | None = None self.reload_thread: threading.Thread | None = None - self.retry_attempts: Dict[str, int] = {} # Track retry attempts by batch ID def reset(self): """Reset all state""" @@ -106,7 +108,6 @@ def reset(self): self.total_retries = 0 self.last_error = None self.requests_made = [] - self.retry_attempts = {} def increment_captured(self): """Increment total events captured""" @@ -114,37 +115,6 @@ def increment_captured(self): self.total_events_captured += 1 self.pending_events += 1 - def record_request(self, status_code: int, batch: List[Dict], batch_id: str): - """Record an HTTP request made by the SDK""" - with self.lock: - # Determine retry attempt for this batch - retry_attempt = self.retry_attempts.get(batch_id, 0) - - # Extract UUIDs from batch - uuid_list = [event.get("uuid", "") for event in batch] - - request_info = RequestInfo( - timestamp_ms=int(time.time() * 1000), - status_code=status_code, - retry_attempt=retry_attempt, - event_count=len(batch), - uuid_list=uuid_list, - ) - self.requests_made.append(request_info) - - # Update counters - if status_code == 200: - # Success - clear pending events - self.total_events_sent += len(batch) - self.pending_events = max(0, self.pending_events - len(batch)) - # Remove batch from retry tracking - self.retry_attempts.pop(batch_id, None) - else: - # Failure - increment retry count - self.retry_attempts[batch_id] = retry_attempt + 1 - if retry_attempt > 0: - self.total_retries += 1 - def record_request_v1( self, status_code: int, batch: List[Dict], attempt: int, terminal_count: int ): @@ -194,40 +164,6 @@ def get_state(self) -> Dict[str, Any]: state = SDKState() -def create_batch_id(batch: List[Dict]) -> str: - """Create a unique ID for a batch based on UUIDs""" - uuids = sorted([event.get("uuid", "") for event in batch]) - return "-".join(uuids[:3]) # Use first 3 UUIDs as batch ID - - -def patched_batch_post( - api_key: str, - host: Optional[str] = None, - gzip: bool = False, - timeout: int = 15, - path: str = EVENTS_ENDPOINT, - **kwargs, -): - """Patched version of batch_post that tracks requests""" - batch = kwargs.get("batch", []) - batch_id = create_batch_id(batch) - - try: - # Call original batch_post - response = original_batch_post(api_key, host, gzip, timeout, path, **kwargs) - # Record successful request - state.record_request(200, batch, batch_id) - return response - except Exception as e: - # Record failed request - status_code = ( - getattr(e, "status_code", 500) if hasattr(e, "status_code") else 500 - ) - state.record_request(status_code, batch, batch_id) - state.record_error(str(e)) - raise - - def patched_post_v1( api_key: str, host: Optional[str], @@ -236,14 +172,15 @@ def patched_post_v1( attempt: int, request_id: str, compression: CaptureCompression = CaptureCompression.NONE, - timeout: int = 15, + timeout: float = 15, sdk_info: str = USER_AGENT, session: Any = None, + path: str = _CAPTURE_V1_PATH, ): """Patched version of _post_v1 that records requests for /state assertions. - Mirrors the legacy `patched_batch_post`, but reads the retry attempt from the - call (1-based) and counts only terminal per-event results as sent. + Reads the retry attempt from the call (1-based) and counts only terminal + per-event results as sent. """ batch = batch_body.get("batch", []) try: @@ -257,6 +194,7 @@ def patched_post_v1( timeout=timeout, sdk_info=sdk_info, session=session, + path=path, ) except Exception as e: status_code = getattr(e, "status", 0) @@ -284,33 +222,24 @@ def patched_post_v1( return response -# Monkey-patch the batch_post function -import posthog.request # noqa: E402 - -posthog.request.batch_post = patched_batch_post - -# Also patch in consumer module -import posthog.consumer # noqa: E402 - -posthog.consumer.batch_post = patched_batch_post - # Patch the capture-v1 submitter. `_send_v1_batch` resolves `_post_v1` as a module # global at call time, so patching it here covers both the async consumer and the # sync client paths. -import posthog.capture_v1 # noqa: E402 +import posthog.capture_send # noqa: E402 -posthog.capture_v1._post_v1 = patched_post_v1 +posthog.capture_send._post_v1 = patched_post_v1 @app.route("/health", methods=["GET"]) def health(): """Health check endpoint""" - capabilities = ( - ["capture_v1", "capture_ai_v0", "encoding_gzip"] - if is_v1() - else ["capture_v0", "capture_ai_v0", "encoding_gzip"] - ) - capabilities.append("feature_flags_local_evaluation_v1") + capabilities = [ + "capture_v1", + "capture_ai_v1", + "event_options", + "encoding_" + compression_name(), + "feature_flags_local_evaluation_v1", + ] return jsonify( { "sdk_name": "posthog-python", @@ -351,9 +280,11 @@ def init(): # Convert flush_interval from ms to seconds flush_interval = flush_interval_ms / 1000.0 - # One adapter process speaks one capture protocol, selected by CAPTURE_MODE. - capture_mode = "v1" if is_v1() else "v0" - + compression = ( + COMPRESSION_CODECS[compression_name()] + if enable_compression + else CaptureCompression.NONE + ) # Explicit reloads exercise the real loader without background polling # racing the harness's per-test definition snapshots. client_options = { @@ -361,12 +292,12 @@ def init(): "host": host, "flush_at": flush_at, "flush_interval": flush_interval, - "gzip": enable_compression, + "capture_compression": compression, + "capture_ai_compression": compression, "max_retries": max_retries, "debug": False, "disable_geoip": disable_geoip, "historical_migration": historical_migration, - "capture_mode": capture_mode, "enable_local_evaluation": False, } personal_api_key = data.get("personal_api_key") @@ -381,8 +312,8 @@ def init(): logger.info( f"Initialized SDK with api_key={api_key[:10]}..., host={host}, " f"flush_at={flush_at}, flush_interval={flush_interval}, " - f"max_retries={max_retries}, gzip={enable_compression}, " - f"capture_mode={capture_mode}, disable_geoip={disable_geoip}, " + f"max_retries={max_retries}, compression={enable_compression}, " + f"disable_geoip={disable_geoip}, " f"historical_migration={historical_migration}" ) @@ -392,57 +323,55 @@ def init(): return jsonify({"error": str(e)}), 500 -@app.route("/capture", methods=["POST"]) -def capture(): - """Capture a single event""" - try: - if not state.client: - return jsonify({"error": "SDK not initialized"}), 400 +def _capture_with(capture_method_name: str): + """Capture one event through the named SDK method. - data = request.json or {} + Options pass through unchanged, and a supplied uuid stays on the wire. The + response carries the uuid the SDK actually sent, which differs from the + supplied one when the SDK canonicalizes or replaces it. + """ + if not state.client: + return jsonify({"error": "SDK not initialized"}), 400 - distinct_id = data.get("distinct_id") - event = data.get("event") - properties = data.get("properties") - timestamp = data.get("timestamp") - options = data.get("options") + data = request.json or {} - if not distinct_id: - return jsonify({"error": "distinct_id is required"}), 400 - if not event: - return jsonify({"error": "event is required"}), 400 - - # Fold capture-v1 options back into the magic `$`-prefixed properties the - # SDK lifts onto the wire `options` object. Renamed keys mirror the SDK's - # sentinel table; unknown keys get a bare `$` prefix. v0 has no wire - # options object, so this only applies in v1 mode. - if options and is_v1(): - properties = dict(properties or {}) - option_to_property = { - "cookieless_mode": "$cookieless_mode", - "disable_skew_correction": "$ignore_sent_at", - "process_person_profile": "$process_person_profile", - "product_tour_id": "$product_tour_id", - } - for key, value in options.items(): - properties[option_to_property.get(key, "$" + key)] = value + distinct_id = data.get("distinct_id") + event = data.get("event") + timestamp = data.get("timestamp") + + if not distinct_id: + return jsonify({"error": "distinct_id is required"}), 400 + if not event: + return jsonify({"error": "event is required"}), 400 + + kwargs = { + "distinct_id": distinct_id, + "properties": data.get("properties"), + "options": data.get("options"), + } + if timestamp: + # Parse ISO8601 timestamp + from dateutil.parser import parse - # Capture event - kwargs = {"distinct_id": distinct_id, "properties": properties} - if timestamp: - # Parse ISO8601 timestamp - from dateutil.parser import parse + kwargs["timestamp"] = parse(timestamp) + if data.get("uuid"): + kwargs["uuid"] = data["uuid"] - kwargs["timestamp"] = parse(timestamp) + uuid = getattr(state.client, capture_method_name)(event, **kwargs) - uuid = state.client.capture(event, **kwargs) + # Track that we captured an event + state.increment_captured() - # Track that we captured an event - state.increment_captured() + logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}") - logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}") + return jsonify({"success": True, "uuid": uuid}) - return jsonify({"success": True, "uuid": uuid}) + +@app.route("/capture", methods=["POST"]) +def capture(): + """Capture a single event""" + try: + return _capture_with("capture") except Exception as e: logger.exception("Error capturing event") state.record_error(str(e)) @@ -453,50 +382,7 @@ def capture(): def capture_ai(): """Capture a single AI event on the dedicated AI capture endpoint""" try: - if not state.client: - return jsonify({"error": "SDK not initialized"}), 400 - - data = request.json or {} - - distinct_id = data.get("distinct_id") - event = data.get("event") - properties = data.get("properties") - timestamp = data.get("timestamp") - options = data.get("options") - supplied_uuid = data.get("uuid") - - if not distinct_id: - return jsonify({"error": "distinct_id is required"}), 400 - if not event: - return jsonify({"error": "event is required"}), 400 - - if options and is_v1(): - properties = dict(properties or {}) - option_to_property = { - "cookieless_mode": "$cookieless_mode", - "disable_skew_correction": "$ignore_sent_at", - "process_person_profile": "$process_person_profile", - "product_tour_id": "$product_tour_id", - } - for key, value in options.items(): - properties[option_to_property.get(key, "$" + key)] = value - - kwargs = {"distinct_id": distinct_id, "properties": properties} - if timestamp: - from dateutil.parser import parse - - kwargs["timestamp"] = parse(timestamp) - # Unlike /capture, forward a supplied uuid so it's echoed back to the caller. - if supplied_uuid: - kwargs["uuid"] = supplied_uuid - - uuid = state.client.capture_ai(event, **kwargs) - - state.increment_captured() - - logger.info(f"Captured AI event: {event} for {distinct_id}, uuid={uuid}") - - return jsonify({"success": True, "uuid": uuid}) + return _capture_with("capture_ai") except Exception as e: logger.exception("Error capturing AI event") state.record_error(str(e)) @@ -723,7 +609,14 @@ def reset(): def main(): """Main entry point""" port = int(os.environ.get("PORT", 8080)) - logger.info(f"Starting SDK Test Adapter on port {port}") + if compression_name() not in COMPRESSION_CODECS: + raise SystemExit( + f"unsupported COMPRESSION {compression_name()!r}: " + f"want one of {', '.join(COMPRESSION_CODECS)}" + ) + logger.info( + f"Starting SDK Test Adapter on port {port} (compression={compression_name()})" + ) app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index c679f5c4f..9281fa8e3 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -1,29 +1,22 @@ version: "3.8" services: - # PostHog Python SDK adapter (capture v0) + # PostHog Python SDK adapter sdk-adapter: build: context: .. dockerfile: sdk_compliance_adapter/Dockerfile + environment: + # One of gzip, deflate or zstd; selects the encoding_ test. + COMPRESSION: ${COMPRESSION:-gzip} ports: - "8080:8080" networks: - test-network - # PostHog Python SDK adapter (capture v1) - sdk-adapter-v1: - build: - context: .. - dockerfile: sdk_compliance_adapter/Dockerfile.v1 - ports: - - "8082:8080" - networks: - - test-network - # Test harness test-harness: - image: ghcr.io/posthog/sdk-test-harness:1.1.1 + image: ghcr.io/posthog/sdk-test-harness:1.13.1 command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] networks: - test-network diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 8acc78386..02eeac261 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -8,7 +8,7 @@ import pytest -import posthog.capture_v1 +import posthog.capture_send import posthog.client import posthog.consumer import posthog.request @@ -20,9 +20,7 @@ def adapter(monkeypatch): # Importing the adapter installs transport instrumentation. Restore it after # every test so collecting these tests alongside SDK tests is safe. for module, name in [ - (posthog.request, "batch_post"), - (posthog.consumer, "batch_post"), - (posthog.capture_v1, "_post_v1"), + (posthog.capture_send, "_post_v1"), ]: monkeypatch.setattr(module, name, getattr(module, name)) spec = importlib.util.spec_from_file_location( @@ -79,15 +77,60 @@ def initialize(adapter, **overrides): return adapter.app.test_client() -@pytest.mark.parametrize("mode,capability", [("", "capture_v0"), ("v1", "capture_v1")]) -def test_health_opts_into_local_evaluation_without_losing_capture( - adapter, monkeypatch, mode, capability -): - monkeypatch.setattr(adapter, "CAPTURE_MODE", mode) +@pytest.mark.parametrize( + "env_value,codec", + [(None, "gzip"), ("gzip", "gzip"), ("deflate", "deflate"), ("zstd", "zstd")], +) +def test_health_and_init_use_one_codec(adapter, monkeypatch, env_value, codec): + if env_value is None: + monkeypatch.delenv("COMPRESSION", raising=False) + else: + monkeypatch.setenv("COMPRESSION", env_value) capabilities = adapter.app.test_client().get("/health").json["capabilities"] assert "feature_flags_local_evaluation_v1" in capabilities - assert capability in capabilities - assert "capture_ai_v0" in capabilities + assert {"capture_v1", "capture_ai_v1", "event_options"} <= set(capabilities) + assert "capture_v0" not in capabilities + assert "capture_ai_v0" not in capabilities + assert [c for c in capabilities if c.startswith("encoding_")] == [ + f"encoding_{codec}" + ] + + initialize(adapter, enable_compression=True) + assert adapter.state.client.capture_compression.value == codec + assert adapter.state.client.capture_ai_compression.value == codec + + +@pytest.mark.parametrize( + "route,method", [("/capture", "capture"), ("/capture_ai", "capture_ai")] +) +def test_capture_passes_options_and_uuid_unchanged(adapter, monkeypatch, route, method): + client = initialize(adapter, personal_api_key=None) + sent = Mock(return_value="0190a5a8-0000-7000-8000-000000000001") + monkeypatch.setattr(adapter.state.client, method, sent) + options = {"cookieless_mode": "no", "process_person_profile": 0.0, "custom": [1]} + + response = client.post( + route, + json={ + "distinct_id": "user", + "event": "$ai_generation", + "properties": {"$ignore_sent_at": "maybe"}, + "options": options, + "uuid": "0190A5A8-0000-7000-8000-000000000001", + }, + ) + + assert response.json == { + "success": True, + "uuid": "0190a5a8-0000-7000-8000-000000000001", + } + sent.assert_called_once_with( + "$ai_generation", + distinct_id="user", + properties={"$ignore_sent_at": "maybe"}, + options=options, + uuid="0190A5A8-0000-7000-8000-000000000001", + ) def test_init_enables_explicit_definitions_loading_without_polling(adapter): diff --git a/typings/requests/__init__.pyi b/typings/requests/__init__.pyi index 9e7892beb..1143d97b6 100644 --- a/typings/requests/__init__.pyi +++ b/typings/requests/__init__.pyi @@ -34,8 +34,9 @@ class Session: *, data: str | bytes, headers: dict[str, str], - timeout: int, + timeout: float, stream: bool = ..., + allow_redirects: bool = ..., ) -> Response: ... def get( self, diff --git a/uv.lock b/uv.lock index 60046d858..0d70bd1e5 100644 --- a/uv.lock +++ b/uv.lock @@ -319,15 +319,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -355,6 +346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.1.6" @@ -821,6 +821,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/82/9fab66569b3e682205b52c2b203058a816c0755bc54e2adcd5d3f6018c43/django_stubs_ext-5.2.1-py3-none-any.whl", hash = "sha256:98fb0646f1a1ef07708eec5f6f7d27523f12c0c8714abae8db981571ff957588", size = 9153, upload-time = "2025-06-17T18:06:57.986Z" }, ] +[[package]] +name = "djangorestframework" +version = "3.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/2e/b3ce9d449b1ed9f9dd74fb7dfbc5f20860d5f40c2b4b10a2c3eabd8ff579/djangorestframework-3.18.1.tar.gz", hash = "sha256:605d79fa2ec2f02905492e5ea13d903c2d842d0b4c915a57f7bf02ab9f3c91dd", size = 915653, upload-time = "2026-09-07T18:04:08.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/83/5ed615e47339d8e65f62eb1a8133f8f046492eb3a25ad40d90acb9a208b3/djangorestframework-3.18.1-py3-none-any.whl", hash = "sha256:f1409d698967aaf82d5d98d76b549c8fba8bd92ebd0d83d24454508f72168dc2", size = 901373, upload-time = "2026-09-07T18:04:06.237Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -914,6 +926,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "freezegun" version = "1.5.1" @@ -1361,6 +1390,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1406,6 +1444,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.10.0" @@ -2440,7 +2490,7 @@ wheels = [ [[package]] name = "openfeature-provider-posthog" -version = "0.1.97" +version = "0.1.102" source = { editable = "openfeature-provider" } dependencies = [ { name = "openfeature-sdk" }, @@ -2770,10 +2820,9 @@ wheels = [ [[package]] name = "posthog" -version = "7.64.1" +version = "7.68.0" source = { editable = "." } dependencies = [ - { name = "backoff" }, { name = "distro" }, { name = "requests" }, { name = "typing-extensions" }, @@ -2813,7 +2862,9 @@ test = [ { name = "claude-agent-sdk" }, { name = "coverage" }, { name = "django" }, + { name = "djangorestframework" }, { name = "fastmcp" }, + { name = "flask" }, { name = "freezegun" }, { name = "gevent", marker = "implementation_name == 'cpython'" }, { name = "google-genai" }, @@ -2852,13 +2903,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, - { name = "backoff", specifier = ">=1.10.0" }, { name = "claude-agent-sdk", marker = "extra == 'test'" }, { name = "coverage", marker = "extra == 'test'" }, { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, @@ -4353,6 +4405,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] + [[package]] name = "wheel" version = "0.45.1"