├── posthog ├── py.typed ├── ai │ ├── __init__.py │ ├── langchain │ │ └── __init__.py │ ├── openai │ │ ├── __init__.py │ │ └── openai_providers.py │ ├── gemini │ │ └── __init__.py │ ├── anthropic │ │ ├── __init__.py │ │ ├── anthropic_providers.py │ │ ├── anthropic.py │ │ └── anthropic_async.py │ ├── types.py │ └── sanitization.py ├── test │ ├── ai │ │ ├── __init__.py │ │ └── langchain │ │ │ └── __init__.py │ ├── __init__.py │ ├── test_size_limited_dict.py │ ├── test_module.py │ ├── test_feature_flag.py │ ├── test_contexts.py │ ├── test_consumer.py │ ├── test_types.py │ ├── test_before_send.py │ └── test_utils.py ├── integrations │ ├── __init__.py │ └── django.py ├── version.py ├── poller.py ├── exception_capture.py ├── args.py ├── flag_definition_cache.py ├── consumer.py ├── types.py └── request.py ├── MANIFEST.in ├── CODEOWNERS ├── integration_tests └── django5 │ ├── testdjango │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ ├── views.py │ └── settings.py │ ├── .gitignore │ ├── pyproject.toml │ ├── manage.py │ ├── test_exception_capture.py │ └── test_middleware.py ├── e2e_test.sh ├── bin ├── build ├── test ├── docs ├── fmt ├── setup ├── helpers │ └── _utils.sh └── docs_scripts │ └── doc_constant.py ├── .pre-commit-config.yaml ├── .gitignore ├── .env.example ├── .github ├── workflows │ ├── call-flags-project-board.yml │ ├── generate-references.yml │ ├── release.yml │ └── ci.yml └── dependabot.yml ├── mypy.ini ├── examples ├── remote_config.py └── redis_flag_cache.py ├── setup.py ├── setup_analytics.py ├── LICENSE ├── Makefile ├── pyproject.toml ├── mypy-baseline.txt ├── README.md └── BEFORE_SEND.md /posthog/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /posthog/ai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /posthog/test/ai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /posthog/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @PostHog/team-feature-flags 2 | -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | python ./simulator.py "$@" -------------------------------------------------------------------------------- /integration_tests/django5/.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | *.pyc 3 | __pycache__/ 4 | .pytest_cache/ 5 | -------------------------------------------------------------------------------- /posthog/ai/langchain/__init__.py: -------------------------------------------------------------------------------- 1 | from .callbacks import CallbackHandler 2 | 3 | __all__ = ["CallbackHandler"] 4 | -------------------------------------------------------------------------------- /posthog/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "7.4.0" 2 | 3 | if __name__ == "__main__": 4 | print(VERSION, end="") # noqa: T201 5 | -------------------------------------------------------------------------------- /posthog/test/ai/langchain/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest.importorskip("langchain_core") 4 | pytest.importorskip("langchain_community") 5 | pytest.importorskip("langgraph") 6 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #/ Usage: bin/build 3 | #/ Description: Runs linter and mypy 4 | source bin/helpers/_utils.sh 5 | set_source_and_root_dir 6 | 7 | flake8 posthog --ignore E501,W503 8 | mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.11.12 5 | hooks: 6 | # Run the linter. 7 | - id: ruff-check 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #/ Usage: bin/test 3 | #/ Description: Runs all the unit tests for this project 4 | source bin/helpers/_utils.sh 5 | set_source_and_root_dir 6 | 7 | ensure_virtual_env 8 | 9 | # Pass through all arguments to pytest 10 | pytest "$@" 11 | -------------------------------------------------------------------------------- /bin/docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #/ Usage: bin/docs 3 | #/ Description: Generate documentation for the PostHog Python SDK 4 | source bin/helpers/_utils.sh 5 | set_source_and_root_dir 6 | ensure_virtual_env 7 | 8 | exec python3 "$(dirname "$0")/docs_scripts/generate_json_schemas.py" "$@" -------------------------------------------------------------------------------- /bin/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #/ Usage: bin/fmt 3 | #/ Description: Formats and lints the code 4 | source bin/helpers/_utils.sh 5 | set_source_and_root_dir 6 | ensure_virtual_env 7 | 8 | if [[ "$1" == "--check" ]]; then 9 | ruff format --check . 10 | else 11 | ruff format . 12 | fi -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #/ Usage: bin/setup 3 | #/ Description: Sets up the dependencies needed to develop this project 4 | source bin/helpers/_utils.sh 5 | set_source_and_root_dir 6 | 7 | if [ ! -d "env" ]; then 8 | python3 -m venv env 9 | fi 10 | 11 | source env/bin/activate 12 | pip install -e ".[dev,test]" 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **sublime** 2 | *.pyc 3 | dist/ 4 | *.egg-info 5 | MANIFEST 6 | build/ 7 | .eggs/ 8 | .coverage 9 | .vscode/ 10 | env/ 11 | venv/ 12 | flake8.out 13 | pylint.out 14 | posthog-analytics 15 | .idea 16 | .python-version 17 | .coverage 18 | pyrightconfig.json 19 | .env 20 | .DS_Store 21 | posthog-python-references.json 22 | .claude/settings.local.json 23 | -------------------------------------------------------------------------------- /posthog/test/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pkgutil 3 | import sys 4 | import unittest 5 | 6 | 7 | def all_names(): 8 | for _, modname, _ in pkgutil.iter_modules(__path__): 9 | yield "posthog.test." + modname 10 | 11 | 12 | def all(): 13 | logging.basicConfig(stream=sys.stderr) 14 | return unittest.defaultTestLoader.loadTestsFromNames(all_names()) 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # PostHog API Configuration 2 | # Copy this file to .env and update with your actual values 3 | 4 | # Your project API key (found on the /setup page in PostHog) 5 | POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here 6 | 7 | # Your personal API key (for local evaluation and other advanced features) 8 | POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here 9 | 10 | # PostHog host URL (remove this line if using posthog.com) 11 | POSTHOG_HOST=http://localhost:8000 12 | -------------------------------------------------------------------------------- /integration_tests/django5/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test-django5" 3 | version = "0.1.0" 4 | requires-python = ">=3.12" 5 | dependencies = [ 6 | "django~=5.2.7", 7 | "uvicorn[standard]~=0.38.0", 8 | "posthog", 9 | "pytest~=8.4.2", 10 | "pytest-asyncio~=1.2.0", 11 | "pytest-django~=4.11.1", 12 | "httpx~=0.28.1", 13 | ] 14 | 15 | [tool.uv] 16 | required-version = ">=0.5" 17 | 18 | [tool.uv.sources] 19 | posthog = { path = "../..", editable = true } 20 | -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testdjango project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testdjango project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /posthog/ai/openai/__init__.py: -------------------------------------------------------------------------------- 1 | from .openai import OpenAI 2 | from .openai_async import AsyncOpenAI 3 | from .openai_providers import AsyncAzureOpenAI, AzureOpenAI 4 | from .openai_converter import ( 5 | format_openai_response, 6 | format_openai_input, 7 | extract_openai_tools, 8 | format_openai_streaming_content, 9 | ) 10 | 11 | __all__ = [ 12 | "OpenAI", 13 | "AsyncOpenAI", 14 | "AzureOpenAI", 15 | "AsyncAzureOpenAI", 16 | "format_openai_response", 17 | "format_openai_input", 18 | "extract_openai_tools", 19 | "format_openai_streaming_content", 20 | ] 21 | -------------------------------------------------------------------------------- /posthog/ai/gemini/__init__.py: -------------------------------------------------------------------------------- 1 | from .gemini import Client 2 | from .gemini_async import AsyncClient 3 | from .gemini_converter import ( 4 | format_gemini_input, 5 | format_gemini_response, 6 | extract_gemini_tools, 7 | ) 8 | 9 | 10 | # Create a genai-like module for perfect drop-in replacement 11 | class _GenAI: 12 | Client = Client 13 | AsyncClient = AsyncClient 14 | 15 | 16 | genai = _GenAI() 17 | 18 | __all__ = [ 19 | "Client", 20 | "AsyncClient", 21 | "genai", 22 | "format_gemini_input", 23 | "format_gemini_response", 24 | "extract_gemini_tools", 25 | ] 26 | -------------------------------------------------------------------------------- /posthog/poller.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | class Poller(threading.Thread): 5 | def __init__(self, interval, execute, *args, **kwargs): 6 | threading.Thread.__init__(self) 7 | self.daemon = True # Make daemon to not interfere with program exit 8 | self.stopped = threading.Event() 9 | self.interval = interval 10 | self.execute = execute 11 | self.args = args 12 | self.kwargs = kwargs 13 | 14 | def stop(self): 15 | self.stopped.set() 16 | self.join() 17 | 18 | def run(self): 19 | while not self.stopped.wait(self.interval.total_seconds()): 20 | self.execute(*self.args, **self.kwargs) 21 | -------------------------------------------------------------------------------- /bin/helpers/_utils.sh: -------------------------------------------------------------------------------- 1 | error() { 2 | echo "$@" >&2 3 | } 4 | 5 | fatal() { 6 | error "$@" 7 | exit 1 8 | } 9 | 10 | set_source_and_root_dir() { 11 | { set +x; } 2>/dev/null 12 | source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )" 13 | root_dir=$(cd "$source_dir" && cd ../ && pwd) 14 | cd "$root_dir" 15 | } 16 | 17 | ensure_virtual_env() { 18 | if [ -z "$VIRTUAL_ENV" ]; then 19 | echo "Virtual environment not activated. Activating now..." 20 | if [ ! -f env/bin/activate ]; then 21 | echo "Virtual environment not found. Please run 'python -m venv env' first." 22 | exit 1 23 | fi 24 | source env/bin/activate 25 | fi 26 | } 27 | -------------------------------------------------------------------------------- /integration_tests/django5/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /posthog/ai/anthropic/__init__.py: -------------------------------------------------------------------------------- 1 | from .anthropic import Anthropic 2 | from .anthropic_async import AsyncAnthropic 3 | from .anthropic_providers import ( 4 | AnthropicBedrock, 5 | AnthropicVertex, 6 | AsyncAnthropicBedrock, 7 | AsyncAnthropicVertex, 8 | ) 9 | from .anthropic_converter import ( 10 | format_anthropic_response, 11 | format_anthropic_input, 12 | extract_anthropic_tools, 13 | format_anthropic_streaming_content, 14 | ) 15 | 16 | __all__ = [ 17 | "Anthropic", 18 | "AsyncAnthropic", 19 | "AnthropicBedrock", 20 | "AsyncAnthropicBedrock", 21 | "AnthropicVertex", 22 | "AsyncAnthropicVertex", 23 | "format_anthropic_response", 24 | "format_anthropic_input", 25 | "extract_anthropic_tools", 26 | "format_anthropic_streaming_content", 27 | ] 28 | -------------------------------------------------------------------------------- /.github/workflows/call-flags-project-board.yml: -------------------------------------------------------------------------------- 1 | # This workflow is used to call the flags-project-board workflow when a pull request is opened, ready for review, review requested, synchronized, converted to draft, or reopened. 2 | # It is used to update the feature flags project board with the pull request information. 3 | 4 | name: Call Feature Flags Project Workflow 5 | 6 | on: 7 | pull_request: 8 | types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened] 9 | 10 | jobs: 11 | call-flags-project: 12 | uses: PostHog/.github/.github/workflows/flags-project-board.yml@main 13 | with: 14 | pr_number: ${{ github.event.pull_request.number }} 15 | pr_node_id: ${{ github.event.pull_request.node_id }} 16 | is_draft: ${{ github.event.pull_request.draft }} 17 | secrets: inherit -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.11 3 | plugins = 4 | pydantic.mypy 5 | strict_optional = True 6 | no_implicit_optional = True 7 | warn_unused_ignores = True 8 | check_untyped_defs = True 9 | warn_unreachable = True 10 | strict_equality = True 11 | ignore_missing_imports = True 12 | exclude = env/.*|venv/.*|build/.* 13 | 14 | [mypy-django.*] 15 | ignore_missing_imports = True 16 | 17 | [mypy-sentry_sdk.*] 18 | ignore_missing_imports = True 19 | 20 | [mypy-posthog.test.*] 21 | ignore_errors = True 22 | 23 | [mypy-posthog.*.test.*] 24 | ignore_errors = True 25 | 26 | [mypy-openai.*] 27 | ignore_missing_imports = True 28 | 29 | [mypy-langchain.*] 30 | ignore_missing_imports = True 31 | 32 | [mypy-langchain_core.*] 33 | ignore_missing_imports = True 34 | 35 | [mypy-anthropic.*] 36 | ignore_missing_imports = True 37 | 38 | [mypy-httpx.*] 39 | ignore_missing_imports = True 40 | -------------------------------------------------------------------------------- /posthog/test/test_size_limited_dict.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from parameterized import parameterized 4 | 5 | from posthog import utils 6 | 7 | 8 | class TestSizeLimitedDict(unittest.TestCase): 9 | @parameterized.expand([(10, 100), (5, 20), (20, 200)]) 10 | def test_size_limited_dict(self, size: int, iterations: int) -> None: 11 | values = utils.SizeLimitedDict(size, lambda _: -1) 12 | 13 | for i in range(iterations): 14 | values[i] = i 15 | 16 | assert values[i] == i 17 | assert len(values) == i % size + 1 18 | 19 | if i % size == 0: 20 | # old numbers should've been removed 21 | self.assertIsNone(values.get(i - 1)) 22 | self.assertIsNone(values.get(i - 3)) 23 | self.assertIsNone(values.get(i - 5)) 24 | self.assertIsNone(values.get(i - 9)) 25 | -------------------------------------------------------------------------------- /posthog/test/test_module.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from posthog import Posthog 4 | 5 | 6 | class TestModule(unittest.TestCase): 7 | posthog = None 8 | 9 | def _assert_enqueue_result(self, result): 10 | self.assertEqual(type(result[0]), str) 11 | 12 | def failed(self): 13 | self.failed = True 14 | 15 | def setUp(self): 16 | self.failed = False 17 | self.posthog = Posthog( 18 | "testsecret", host="http://localhost:8000", on_error=self.failed 19 | ) 20 | 21 | def test_track(self): 22 | res = self.posthog.capture("python module event", distinct_id="distinct_id") 23 | self._assert_enqueue_result(res) 24 | self.posthog.flush() 25 | 26 | def test_alias(self): 27 | res = self.posthog.alias("previousId", "distinct_id") 28 | self._assert_enqueue_result(res) 29 | self.posthog.flush() 30 | 31 | def test_flush(self): 32 | self.posthog.flush() 33 | -------------------------------------------------------------------------------- /examples/remote_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple test script for PostHog remote config endpoint. 4 | """ 5 | 6 | import posthog 7 | 8 | # Initialize PostHog client 9 | posthog.api_key = "phc_..." 10 | posthog.personal_api_key = "phs_..." # or "phx_..." 11 | posthog.host = "http://localhost:8000" # or "https://us.posthog.com" 12 | posthog.debug = True 13 | 14 | 15 | def test_remote_config(): 16 | """Test remote config payload retrieval.""" 17 | print("Testing remote config endpoint...") 18 | 19 | # Test feature flag key - replace with an actual flag key from your project 20 | flag_key = "unencrypted-remote-config-setting" 21 | 22 | try: 23 | # Get remote config payload 24 | payload = posthog.get_remote_config_payload(flag_key) 25 | print(f"✅ Success! Remote config payload for '{flag_key}': {payload}") 26 | 27 | except Exception as e: 28 | print(f"❌ Error getting remote config: {e}") 29 | 30 | 31 | if __name__ == "__main__": 32 | test_remote_config() 33 | -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for testdjango project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path 20 | from testdjango import views 21 | 22 | urlpatterns = [ 23 | path("admin/", admin.site.urls), 24 | path("test/async-user", views.test_async_user), 25 | path("test/sync-user", views.test_sync_user), 26 | path("test/async-exception", views.test_async_exception), 27 | path("test/sync-exception", views.test_sync_exception), 28 | ] 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "10:00" 8 | timezone: "UTC" 9 | groups: 10 | ai-providers: 11 | patterns: 12 | - "openai" 13 | - "anthropic" 14 | - "google-genai" 15 | - "langchain-core" 16 | - "langchain-community" 17 | - "langchain-openai" 18 | - "langchain-anthropic" 19 | - "langgraph" 20 | allow: 21 | - dependency-name: "openai" 22 | - dependency-name: "anthropic" 23 | - dependency-name: "google-genai" 24 | - dependency-name: "langchain-core" 25 | - dependency-name: "langchain-community" 26 | - dependency-name: "langchain-openai" 27 | - dependency-name: "langchain-anthropic" 28 | - dependency-name: "langgraph" 29 | open-pull-requests-limit: 1 30 | reviewers: 31 | - "PostHog/team-llm-analytics" 32 | # Uncomment below to enable auto-merge for minor updates when CI passes 33 | # pull-request-branch-name: 34 | # separator: "/" 35 | # assignees: 36 | # - "PostHog/ai-team" 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | # Don't import analytics-python module here, since deps may not be installed 10 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog")) 11 | from version import VERSION # noqa: E402 12 | 13 | long_description = """ 14 | PostHog is developer-friendly, self-hosted product analytics. 15 | posthog-python is the python package. 16 | 17 | This package requires Python 3.10 or higher. 18 | """ 19 | 20 | # Minimal setup.py for backward compatibility 21 | # Most configuration is now in pyproject.toml 22 | setup( 23 | name="posthog", 24 | version=VERSION, 25 | # Basic fields for backward compatibility 26 | url="https://github.com/posthog/posthog-python", 27 | author="Posthog", 28 | author_email="hey@posthog.com", 29 | maintainer="PostHog", 30 | maintainer_email="hey@posthog.com", 31 | license="MIT License", 32 | description="Integrate PostHog into any python application.", 33 | long_description=long_description, 34 | # This will fallback to pyproject.toml for detailed configuration 35 | ) 36 | -------------------------------------------------------------------------------- /bin/docs_scripts/doc_constant.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants for PostHog Python SDK documentation generation. 3 | """ 4 | 5 | from typing import Dict, Union 6 | from posthog.version import VERSION 7 | 8 | # Documentation generation metadata 9 | DOCUMENTATION_METADATA = { 10 | "hogRef": "0.3", 11 | "slugPrefix": "posthog-python", 12 | "specUrl": "https://github.com/PostHog/posthog-python", 13 | } 14 | 15 | # Docstring parsing patterns for new format 16 | DOCSTRING_PATTERNS = { 17 | "examples_section": r"Examples:\s*\n(.*?)(?=\n\s*\n\s*Category:|\Z)", 18 | "args_section": r"Args:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Details:|\n\s*\n\s*Category:|\Z)", 19 | "details_section": r"Details:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Category:|\Z)", 20 | "category_section": r"Category:\s*\n\s*(.+?)\s*(?:\n|$)", 21 | "code_block": r"```(?:python)?\n(.*?)```", 22 | "param_description": r"^\s*{param_name}:\s*(.+?)(?=\n\s*\w+:|\Z)", 23 | "args_marker": r"\n\s*Args:\s*\n", 24 | "examples_marker": r"\n\s*Examples:\s*\n", 25 | "details_marker": r"\n\s*Details:\s*\n", 26 | "category_marker": r"\n\s*Category:\s*\n", 27 | } 28 | 29 | # Output file configuration 30 | OUTPUT_CONFIG: Dict[str, Union[str, int]] = { 31 | "output_dir": "./references", 32 | "filename": f"posthog-python-references-{VERSION}.json", 33 | "filename_latest": "posthog-python-references-latest.json", 34 | "indent": 2, 35 | } 36 | 37 | # Documentation structure defaults 38 | DOC_DEFAULTS = { 39 | "showDocs": True, 40 | "releaseTag": "public", 41 | "return_type_void": "None", 42 | "max_optional_params": 3, 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/generate-references.yml: -------------------------------------------------------------------------------- 1 | name: "Generate References" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | docs-generation: 8 | name: Generate references 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - name: Checkout the repository 14 | uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 20 | with: 21 | python-version: 3.11.11 22 | 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 25 | with: 26 | enable-cache: true 27 | pyproject-file: 'pyproject.toml' 28 | 29 | - name: Generate references 30 | run: | 31 | uv run bin/docs generate-references 32 | 33 | - name: Check for changes in references 34 | id: changes 35 | run: | 36 | if [ -n "$(git status --porcelain references/)" ]; then 37 | echo "changed=true" >> $GITHUB_OUTPUT 38 | echo "New references generated in references directory:" 39 | git status --porcelain references/ 40 | else 41 | echo "changed=false" >> $GITHUB_OUTPUT 42 | echo "No new references generated in references directory" 43 | fi 44 | 45 | - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 46 | if: steps.changes.outputs.changed == 'true' 47 | with: 48 | commit_message: "Update generated references" 49 | file_pattern: references/ -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test views for validating PostHog middleware with Django 5 ASGI. 3 | """ 4 | 5 | from django.http import JsonResponse 6 | 7 | 8 | async def test_async_user(request): 9 | """ 10 | Async view that tests middleware with request.user access. 11 | 12 | The middleware will access request.user (SimpleLazyObject) via auser() 13 | in async context. Without the fix, this causes SynchronousOnlyOperation. 14 | """ 15 | # The middleware has already accessed request.user via auser() 16 | # If we got here, the fix works! 17 | user = await request.auser() 18 | 19 | return JsonResponse( 20 | { 21 | "status": "success", 22 | "message": "Django 5 async middleware test passed!", 23 | "django_version": "5.x", 24 | "user_authenticated": user.is_authenticated if user else False, 25 | "note": "Middleware used await request.auser() successfully", 26 | } 27 | ) 28 | 29 | 30 | def test_sync_user(request): 31 | """Sync view for comparison.""" 32 | return JsonResponse( 33 | { 34 | "status": "success", 35 | "message": "Sync view works", 36 | "user_authenticated": request.user.is_authenticated 37 | if hasattr(request, "user") 38 | else False, 39 | } 40 | ) 41 | 42 | 43 | async def test_async_exception(request): 44 | """Async view that raises an exception for testing exception capture.""" 45 | raise ValueError("Test exception from Django 5 async view") 46 | 47 | 48 | def test_sync_exception(request): 49 | """Sync view that raises an exception for testing exception capture.""" 50 | raise ValueError("Test exception from Django 5 sync view") 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "posthog/version.py" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | release: 13 | name: Publish release 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | id-token: write 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 26 | with: 27 | python-version: 3.11.11 28 | 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 31 | with: 32 | enable-cache: true 33 | pyproject-file: 'pyproject.toml' 34 | 35 | - name: Detect version 36 | run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV 37 | 38 | - name: Prepare for building release 39 | run: uv sync --extra dev 40 | 41 | - name: Push releases to PyPI 42 | env: 43 | TWINE_USERNAME: __token__ 44 | run: uv run make release && uv run make release_analytics 45 | 46 | - name: Create GitHub release 47 | uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1 48 | with: 49 | tag_name: v${{ env.REPO_VERSION }} 50 | release_name: ${{ env.REPO_VERSION }} 51 | 52 | - name: Dispatch generate-references for posthog-python 53 | env: 54 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: | 56 | gh workflow run generate-references.yml --ref master 57 | -------------------------------------------------------------------------------- /posthog/exception_capture.py: -------------------------------------------------------------------------------- 1 | # Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry 2 | # Licensed under the MIT License 3 | 4 | # 💖open source (under MIT License) 5 | 6 | import logging 7 | import sys 8 | import threading 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from posthog.client import Client 13 | 14 | 15 | class ExceptionCapture: 16 | # TODO: Add client side rate limiting to prevent spamming the server with exceptions 17 | 18 | log = logging.getLogger("posthog") 19 | 20 | def __init__(self, client: "Client"): 21 | self.client = client 22 | self.original_excepthook = sys.excepthook 23 | sys.excepthook = self.exception_handler 24 | threading.excepthook = self.thread_exception_handler 25 | 26 | def close(self): 27 | sys.excepthook = self.original_excepthook 28 | 29 | def exception_handler(self, exc_type, exc_value, exc_traceback): 30 | # don't affect default behaviour. 31 | self.capture_exception((exc_type, exc_value, exc_traceback)) 32 | self.original_excepthook(exc_type, exc_value, exc_traceback) 33 | 34 | def thread_exception_handler(self, args): 35 | self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback)) 36 | 37 | def exception_receiver(self, exc_info, extra_properties): 38 | if "distinct_id" in extra_properties: 39 | metadata = {"distinct_id": extra_properties["distinct_id"]} 40 | else: 41 | metadata = None 42 | self.capture_exception((exc_info[0], exc_info[1], exc_info[2]), metadata) 43 | 44 | def capture_exception(self, exception, metadata=None): 45 | try: 46 | distinct_id = metadata.get("distinct_id") if metadata else None 47 | self.client.capture_exception(exception, distinct_id=distinct_id) 48 | except Exception as e: 49 | self.log.exception(f"Failed to capture exception: {e}") 50 | -------------------------------------------------------------------------------- /posthog/ai/anthropic/anthropic_providers.py: -------------------------------------------------------------------------------- 1 | try: 2 | import anthropic 3 | except ImportError: 4 | raise ModuleNotFoundError( 5 | "Please install the Anthropic SDK to use this feature: 'pip install anthropic'" 6 | ) 7 | 8 | from typing import Optional 9 | 10 | from posthog.ai.anthropic.anthropic import WrappedMessages 11 | from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages 12 | from posthog.client import Client as PostHogClient 13 | from posthog import setup 14 | 15 | 16 | class AnthropicBedrock(anthropic.AnthropicBedrock): 17 | """ 18 | A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog. 19 | """ 20 | 21 | _ph_client: PostHogClient 22 | 23 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 24 | super().__init__(**kwargs) 25 | self._ph_client = posthog_client or setup() 26 | self.messages = WrappedMessages(self) 27 | 28 | 29 | class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock): 30 | """ 31 | A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog. 32 | """ 33 | 34 | _ph_client: PostHogClient 35 | 36 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 37 | super().__init__(**kwargs) 38 | self._ph_client = posthog_client or setup() 39 | self.messages = AsyncWrappedMessages(self) 40 | 41 | 42 | class AnthropicVertex(anthropic.AnthropicVertex): 43 | """ 44 | A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog. 45 | """ 46 | 47 | _ph_client: PostHogClient 48 | 49 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 50 | super().__init__(**kwargs) 51 | self._ph_client = posthog_client or setup() 52 | self.messages = WrappedMessages(self) 53 | 54 | 55 | class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex): 56 | """ 57 | A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog. 58 | """ 59 | 60 | _ph_client: PostHogClient 61 | 62 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 63 | super().__init__(**kwargs) 64 | self._ph_client = posthog_client or setup() 65 | self.messages = AsyncWrappedMessages(self) 66 | -------------------------------------------------------------------------------- /setup_analytics.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tomli 4 | import tomli_w 5 | import shutil 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | # Don't import analytics-python module here, since deps may not be installed 13 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics")) 14 | from version import VERSION # noqa: E402 15 | 16 | 17 | # Copy the original pyproject.toml as backup 18 | shutil.copy("pyproject.toml", "pyproject.toml.backup") 19 | 20 | # Read the original pyproject.toml 21 | with open("pyproject.toml", "rb") as f: 22 | config = tomli.load(f) 23 | 24 | # Override specific values 25 | config["project"]["name"] = "posthoganalytics" 26 | config["tool"]["setuptools"]["dynamic"]["version"] = { 27 | "attr": "posthoganalytics.version.VERSION" 28 | } 29 | 30 | # Rename packages from posthog.* to posthoganalytics.* 31 | if "packages" in config["tool"]["setuptools"]: 32 | new_packages = [] 33 | for package in config["tool"]["setuptools"]["packages"]: 34 | if package == "posthog": 35 | new_packages.append("posthoganalytics") 36 | elif package.startswith("posthog."): 37 | new_packages.append(package.replace("posthog.", "posthoganalytics.", 1)) 38 | else: 39 | new_packages.append(package) 40 | config["tool"]["setuptools"]["packages"] = new_packages 41 | 42 | # Overwrite the original pyproject.toml 43 | with open("pyproject.toml", "wb") as f: 44 | tomli_w.dump(config, f) 45 | 46 | long_description = """ 47 | PostHog is developer-friendly, self-hosted product analytics. 48 | posthog-python is the python package. 49 | 50 | This package requires Python 3.10 or higher. 51 | """ 52 | 53 | # Minimal setup.py for backward compatibility 54 | # Most configuration is now in pyproject.toml 55 | setup( 56 | name="posthoganalytics", 57 | version=VERSION, 58 | # Basic fields for backward compatibility 59 | url="https://github.com/posthog/posthog-python", 60 | author="Posthog", 61 | author_email="hey@posthog.com", 62 | maintainer="PostHog", 63 | maintainer_email="hey@posthog.com", 64 | license="MIT License", 65 | description="Integrate PostHog into any python application.", 66 | long_description=long_description, 67 | # This will fallback to pyproject.toml for detailed configuration 68 | ) 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 PostHog (part of Hiberly Inc) 2 | 3 | Copyright (c) 2013 Segment Inc. friends@segment.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry. 26 | In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases. 27 | 28 | MIT License 29 | 30 | Copyright (c) 2012 Functional Software, Inc. dba Sentry 31 | 32 | Permission is hereby granted, free of charge, to any person obtaining a copy of 33 | this software and associated documentation files (the "Software"), to deal in 34 | the Software without restriction, including without limitation the rights to 35 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 36 | of the Software, and to permit persons to whom the Software is furnished to do 37 | so, subject to the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be included in all 40 | copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 43 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 44 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 45 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 46 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 47 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 48 | SOFTWARE. 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | uvx ruff format 3 | 4 | test: 5 | coverage run -m pytest 6 | coverage report 7 | 8 | release: 9 | rm -rf dist/* 10 | python setup.py sdist bdist_wheel 11 | twine upload dist/* 12 | 13 | release_analytics: 14 | rm -rf dist 15 | rm -rf build 16 | rm -rf posthoganalytics 17 | mkdir posthoganalytics 18 | cp -r posthog/* posthoganalytics/ 19 | find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \; 20 | find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \; 21 | find ./posthoganalytics -name "*.bak" -delete 22 | rm -rf posthog 23 | python setup_analytics.py sdist bdist_wheel 24 | twine upload dist/* 25 | mkdir posthog 26 | find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \; 27 | find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \; 28 | find ./posthoganalytics -name "*.bak" -delete 29 | cp -r posthoganalytics/* posthog/ 30 | rm -rf posthoganalytics 31 | rm -f pyproject.toml 32 | cp pyproject.toml.backup pyproject.toml 33 | rm -f pyproject.toml.backup 34 | 35 | e2e_test: 36 | .buildscripts/e2e.sh 37 | 38 | prep_local: 39 | rm -rf ../posthog-python-local 40 | mkdir ../posthog-python-local 41 | cp -r . ../posthog-python-local/ 42 | cd ../posthog-python-local && rm -rf dist build posthoganalytics .git 43 | cd ../posthog-python-local && mkdir posthoganalytics 44 | cd ../posthog-python-local && cp -r posthog/* posthoganalytics/ 45 | cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \; 46 | cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \; 47 | cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete 48 | cd ../posthog-python-local && rm -rf posthog 49 | cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py 50 | cd ../posthog-python-local && rm setup_analytics.py.bak 51 | cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py 52 | cd ../posthog-python-local && rm setup.py.bak 53 | cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true 54 | @echo "Local copy created at ../posthog-python-local" 55 | @echo "Install with: pip install -e ../posthog-python-local" 56 | 57 | .PHONY: test lint release e2e_test prep_local 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "posthog" 7 | dynamic = ["version"] 8 | description = "Integrate PostHog into any python application." 9 | authors = [{ name = "PostHog", email = "hey@posthog.com" }] 10 | maintainers = [{ name = "PostHog", email = "hey@posthog.com" }] 11 | license = { text = "MIT" } 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "Operating System :: OS Independent", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | ] 26 | dependencies = [ 27 | "requests>=2.7,<3.0", 28 | "six>=1.5", 29 | "python-dateutil>=2.2", 30 | "backoff>=1.10.0", 31 | "distro>=1.5.0", 32 | "typing-extensions>=4.2.0", 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/posthog/posthog-python" 37 | Repository = "https://github.com/posthog/posthog-python" 38 | 39 | [project.optional-dependencies] 40 | langchain = ["langchain>=0.2.0"] 41 | dev = [ 42 | "django-stubs", 43 | "lxml", 44 | "mypy", 45 | "mypy-baseline", 46 | "types-mock", 47 | "types-python-dateutil", 48 | "types-requests", 49 | "types-setuptools", 50 | "types-six", 51 | "pre-commit", 52 | "pydantic", 53 | "ruff", 54 | "setuptools", 55 | "packaging", 56 | "wheel", 57 | "twine", 58 | "tomli", 59 | "tomli_w", 60 | ] 61 | test = [ 62 | "mock>=2.0.0", 63 | "freezegun==1.5.1", 64 | "coverage", 65 | "pytest", 66 | "pytest-timeout", 67 | "pytest-asyncio", 68 | "django", 69 | "openai>=2.0", 70 | "anthropic>=0.72", 71 | "langgraph>=1.0", 72 | "langchain-core>=1.0", 73 | "langchain-community>=0.4", 74 | "langchain-openai>=1.0", 75 | "langchain-anthropic>=1.0", 76 | "google-genai", 77 | "pydantic", 78 | "parameterized>=0.8.1", 79 | ] 80 | 81 | [tool.setuptools] 82 | packages = [ 83 | "posthog", 84 | "posthog.ai", 85 | "posthog.ai.langchain", 86 | "posthog.ai.openai", 87 | "posthog.ai.anthropic", 88 | "posthog.ai.gemini", 89 | "posthog.test", 90 | "posthog.integrations", 91 | ] 92 | 93 | [tool.setuptools.dynamic] 94 | version = { attr = "posthog.version.VERSION" } 95 | 96 | [tool.pytest.ini_options] 97 | asyncio_mode = "auto" 98 | asyncio_default_fixture_loop_scope = "function" 99 | testpaths = ["posthog/test"] 100 | norecursedirs = ["integration_tests"] 101 | -------------------------------------------------------------------------------- /mypy-baseline.txt: -------------------------------------------------------------------------------- 1 | posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped] 2 | posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped] 3 | posthog/utils.py:0: error: Statement is unreachable [unreachable] 4 | posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped] 5 | posthog/request.py:0: note: Hint: "python3 -m pip install types-requests" 6 | posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped] 7 | posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment] 8 | posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef] 9 | posthog/consumer.py:0: error: Need type annotation for "items" (hint: "items: list[] = ...") [var-annotated] 10 | posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator] 11 | posthog/consumer.py:0: note: Right operand is of type "int | str" 12 | posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator] 13 | posthog/consumer.py:0: note: Left operand is of type "int | str" 14 | posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil" [import-untyped] 15 | posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil.relativedelta" [import-untyped] 16 | posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore] 17 | posthog/client.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped] 18 | posthog/client.py:0: note: Hint: "python3 -m pip install types-python-dateutil" 19 | posthog/client.py:0: note: (or run "mypy --install-types" to install all missing stub packages) 20 | posthog/client.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports 21 | posthog/client.py:0: error: Library stubs not installed for "six" [import-untyped] 22 | posthog/client.py:0: note: Hint: "python3 -m pip install types-six" 23 | posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef] 24 | posthog/client.py:0: error: Need type annotation for "queue" [var-annotated] 25 | posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment] 26 | posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment] 27 | posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined] 28 | posthog/client.py:0: error: Statement is unreachable [unreachable] 29 | posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable] 30 | posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment] 31 | posthog/client.py:0: error: "None" has no attribute "start" [attr-defined] 32 | posthog/client.py:0: error: Statement is unreachable [unreachable] 33 | posthog/client.py:0: error: Statement is unreachable [unreachable] 34 | posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef] 35 | posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostHog Python 2 | 3 |

4 | posthoglogo 5 |

6 |

7 | pypi installs 8 | GitHub contributors 9 | GitHub commit activity 10 | GitHub closed issues 11 |

12 | 13 | Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details. 14 | 15 | ## Development 16 | 17 | ### Testing Locally 18 | 19 | We recommend using [uv](https://docs.astral.sh/uv/). It's super fast. 20 | 21 | 1. Run `uv venv env` (creates virtual environment called "env") 22 | * or `python3 -m venv env` 23 | 2. Run `source env/bin/activate` (activates the virtual environment) 24 | 3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies) 25 | * or `pip install -e ".[dev,test]"` 26 | 4. you have to run `pre-commit install` to have auto linting pre commit 27 | 5. Run `make test` 28 | 1. To run a specific test do `pytest -k test_no_api_key` 29 | 30 | ## PostHog recommends `uv` so... 31 | 32 | ```bash 33 | uv python install 3.12 34 | uv python pin 3.12 35 | uv venv 36 | source env/bin/activate 37 | uv sync --extra dev --extra test 38 | pre-commit install 39 | make test 40 | ``` 41 | 42 | ### Running Locally 43 | 44 | Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action. 45 | 46 | ### Releasing Versions 47 | 48 | Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version. 49 | 50 | If you need to check the latest runs or manually trigger a release, you can go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`. 51 | 52 | 53 | ### Testing changes locally with the PostHog app 54 | 55 | You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this: 56 | ```toml 57 | dependencies = [ 58 | ... 59 | "posthoganalytics" #NOTE: no version number 60 | ... 61 | ] 62 | ... 63 | [tools.uv.sources] 64 | posthoganalytics = { path = "../posthog-python-local" } 65 | ``` 66 | This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project. 67 | -------------------------------------------------------------------------------- /posthog/args.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type 2 | from types import TracebackType 3 | from typing_extensions import NotRequired # For Python < 3.11 compatibility 4 | from datetime import datetime 5 | import numbers 6 | from uuid import UUID 7 | 8 | from posthog.types import SendFeatureFlagsOptions 9 | 10 | ID_TYPES = Union[numbers.Number, str, UUID, int] 11 | 12 | 13 | class OptionalCaptureArgs(TypedDict): 14 | """Optional arguments for the capture method. 15 | 16 | Args: 17 | distinct_id: Unique identifier for the person associated with this event. If not set, the context 18 | distinct_id is used, if available, otherwise a UUID is generated, and the event is marked 19 | as personless. Setting context-level distinct_id's is recommended. 20 | properties: Dictionary of properties to track with the event 21 | timestamp: When the event occurred (defaults to current time) 22 | uuid: Unique identifier for this specific event. If not provided, one is generated. The event 23 | UUID is returned, so you can correlate it with actions in your app (like showing users an 24 | error ID if you capture an exception). 25 | groups: Group identifiers to associate with this event (format: {group_type: group_key}) 26 | send_feature_flags: Whether to include currently active feature flags in the event properties. 27 | Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration. 28 | Defaults to False. 29 | disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False. 30 | """ 31 | 32 | distinct_id: NotRequired[Optional[ID_TYPES]] 33 | properties: NotRequired[Optional[Dict[str, Any]]] 34 | timestamp: NotRequired[Optional[Union[datetime, str]]] 35 | uuid: NotRequired[Optional[str]] 36 | groups: NotRequired[Optional[Dict[str, str]]] 37 | send_feature_flags: NotRequired[ 38 | Optional[Union[bool, SendFeatureFlagsOptions]] 39 | ] # Updated to support both boolean and options object 40 | disable_geoip: NotRequired[ 41 | Optional[bool] 42 | ] # As above, optional so we can tell if the user is intentionally overriding a client setting or not 43 | 44 | 45 | class OptionalSetArgs(TypedDict): 46 | """Optional arguments for the set method. 47 | 48 | Args: 49 | distinct_id: Unique identifier for the user to set properties on. If not set, the context 50 | distinct_id is used, if available, otherwise this function does nothing. Setting 51 | context-level distinct_id's is recommended. 52 | properties: Dictionary of properties to set on the person 53 | timestamp: When the properties were set (defaults to current time) 54 | uuid: Unique identifier for this operation. If not provided, one is generated. This 55 | UUID is returned, so you can correlate it with actions in your app. 56 | disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False. 57 | """ 58 | 59 | distinct_id: NotRequired[Optional[ID_TYPES]] 60 | properties: NotRequired[Optional[Dict[str, Any]]] 61 | timestamp: NotRequired[Optional[Union[datetime, str]]] 62 | uuid: NotRequired[Optional[str]] 63 | disable_geoip: NotRequired[Optional[bool]] 64 | 65 | 66 | ExcInfo = Union[ 67 | Tuple[Type[BaseException], BaseException, Optional[TracebackType]], 68 | Tuple[None, None, None], 69 | ] 70 | 71 | ExceptionArg = Union[BaseException, ExcInfo] 72 | -------------------------------------------------------------------------------- /posthog/ai/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common type definitions for PostHog AI SDK. 3 | 4 | These types are used for formatting messages and responses across different AI providers 5 | (Anthropic, OpenAI, Gemini, etc.) to ensure consistency in tracking and data structure. 6 | """ 7 | 8 | from typing import Any, Dict, List, Optional, TypedDict, Union 9 | 10 | 11 | class FormattedTextContent(TypedDict): 12 | """Formatted text content item.""" 13 | 14 | type: str # Literal["text"] 15 | text: str 16 | 17 | 18 | class FormattedFunctionCall(TypedDict, total=False): 19 | """Formatted function/tool call content item.""" 20 | 21 | type: str # Literal["function"] 22 | id: Optional[str] 23 | function: Dict[str, Any] # Contains 'name' and 'arguments' 24 | 25 | 26 | class FormattedImageContent(TypedDict): 27 | """Formatted image content item.""" 28 | 29 | type: str # Literal["image"] 30 | image: str 31 | 32 | 33 | # Union type for all formatted content items 34 | FormattedContentItem = Union[ 35 | FormattedTextContent, 36 | FormattedFunctionCall, 37 | FormattedImageContent, 38 | Dict[str, Any], # Fallback for unknown content types 39 | ] 40 | 41 | 42 | class FormattedMessage(TypedDict): 43 | """ 44 | Standardized message format for PostHog tracking. 45 | 46 | Used across all providers to ensure consistent message structure 47 | when sending events to PostHog. 48 | """ 49 | 50 | role: str 51 | content: Union[str, List[FormattedContentItem], Any] 52 | 53 | 54 | class TokenUsage(TypedDict, total=False): 55 | """ 56 | Token usage information for AI model responses. 57 | 58 | Different providers may populate different fields. 59 | """ 60 | 61 | input_tokens: int 62 | output_tokens: int 63 | cache_read_input_tokens: Optional[int] 64 | cache_creation_input_tokens: Optional[int] 65 | reasoning_tokens: Optional[int] 66 | web_search_count: Optional[int] 67 | 68 | 69 | class ProviderResponse(TypedDict, total=False): 70 | """ 71 | Standardized provider response format. 72 | 73 | Used for consistent response formatting across all providers. 74 | """ 75 | 76 | messages: List[FormattedMessage] 77 | usage: TokenUsage 78 | error: Optional[str] 79 | 80 | 81 | class StreamingContentBlock(TypedDict, total=False): 82 | """ 83 | Content block used during streaming to accumulate content. 84 | 85 | Used for tracking text and function calls as they stream in. 86 | """ 87 | 88 | type: str # "text" or "function" 89 | text: Optional[str] 90 | id: Optional[str] 91 | function: Optional[Dict[str, Any]] 92 | 93 | 94 | class ToolInProgress(TypedDict): 95 | """ 96 | Tracks a tool/function call being accumulated during streaming. 97 | 98 | Used by Anthropic to accumulate JSON input for tools. 99 | """ 100 | 101 | block: StreamingContentBlock 102 | input_string: str 103 | 104 | 105 | class StreamingEventData(TypedDict): 106 | """ 107 | Standardized data for streaming events across all providers. 108 | 109 | This type ensures consistent data structure when capturing streaming events, 110 | with all provider-specific formatting already completed. 111 | """ 112 | 113 | provider: str # "openai", "anthropic", "gemini" 114 | model: str 115 | base_url: str 116 | kwargs: Dict[str, Any] # Original kwargs for tool extraction and special handling 117 | formatted_input: Any # Provider-formatted input ready for tracking 118 | formatted_output: Any # Provider-formatted output ready for tracking 119 | usage_stats: TokenUsage 120 | latency: float 121 | distinct_id: Optional[str] 122 | trace_id: Optional[str] 123 | properties: Optional[Dict[str, Any]] 124 | privacy_mode: bool 125 | groups: Optional[Dict[str, Any]] 126 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - pull_request 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | code-quality: 11 | name: Code quality checks 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 16 | with: 17 | fetch-depth: 1 18 | 19 | - name: Set up Python 3.11 20 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 21 | with: 22 | python-version: 3.11.11 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 26 | with: 27 | enable-cache: true 28 | pyproject-file: 'pyproject.toml' 29 | 30 | - name: Install dev dependencies 31 | shell: bash 32 | run: | 33 | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev 34 | 35 | - name: Check formatting with ruff 36 | run: | 37 | ruff format --check . 38 | 39 | - name: Lint with ruff 40 | run: | 41 | ruff check . 42 | 43 | - name: Check types with mypy 44 | run: | 45 | mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter 46 | 47 | tests: 48 | name: Python ${{ matrix.python-version }} tests 49 | runs-on: ubuntu-latest 50 | strategy: 51 | matrix: 52 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] 53 | 54 | steps: 55 | - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 56 | with: 57 | fetch-depth: 1 58 | 59 | - name: Set up Python ${{ matrix.python-version }} 60 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | 64 | - name: Install uv 65 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 66 | with: 67 | enable-cache: true 68 | pyproject-file: 'pyproject.toml' 69 | 70 | - name: Install test dependencies 71 | shell: bash 72 | run: | 73 | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test 74 | 75 | - name: Run posthog tests 76 | run: | 77 | pytest --verbose --timeout=30 78 | 79 | django5-integration: 80 | name: Django 5 integration tests 81 | runs-on: ubuntu-latest 82 | 83 | steps: 84 | - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 85 | with: 86 | fetch-depth: 1 87 | 88 | - name: Set up Python 3.12 89 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 90 | with: 91 | python-version: 3.12 92 | 93 | - name: Install uv 94 | uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 95 | with: 96 | enable-cache: true 97 | pyproject-file: 'integration_tests/django5/pyproject.toml' 98 | 99 | - name: Install Django 5 test project dependencies 100 | shell: bash 101 | working-directory: integration_tests/django5 102 | run: | 103 | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync 104 | 105 | - name: Run Django 5 middleware integration tests 106 | working-directory: integration_tests/django5 107 | run: | 108 | uv run pytest test_middleware.py test_exception_capture.py --verbose 109 | -------------------------------------------------------------------------------- /integration_tests/django5/testdjango/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testdjango project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.2.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | "django.middleware.security.SecurityMiddleware", 44 | "django.contrib.sessions.middleware.SessionMiddleware", 45 | "django.middleware.common.CommonMiddleware", 46 | "django.middleware.csrf.CsrfViewMiddleware", 47 | "django.contrib.auth.middleware.AuthenticationMiddleware", 48 | "django.contrib.messages.middleware.MessageMiddleware", 49 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 50 | "posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware 51 | ] 52 | 53 | ROOT_URLCONF = "testdjango.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = "testdjango.wsgi.application" 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/5.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": BASE_DIR / "db.sqlite3", 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 99 | }, 100 | ] 101 | 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/5.2/topics/i18n/ 105 | 106 | LANGUAGE_CODE = "en-us" 107 | 108 | TIME_ZONE = "UTC" 109 | 110 | USE_I18N = True 111 | 112 | USE_TZ = True 113 | 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/5.2/howto/static-files/ 117 | 118 | STATIC_URL = "static/" 119 | 120 | # Default primary key field type 121 | # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field 122 | 123 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 124 | 125 | 126 | # PostHog settings for testing 127 | POSTHOG_API_KEY = "test-key" 128 | POSTHOG_HOST = "https://app.posthog.com" 129 | POSTHOG_MW_CAPTURE_EXCEPTIONS = True 130 | -------------------------------------------------------------------------------- /posthog/ai/openai/openai_providers.py: -------------------------------------------------------------------------------- 1 | try: 2 | import openai 3 | except ImportError: 4 | raise ModuleNotFoundError( 5 | "Please install the Open AI SDK to use this feature: 'pip install openai'" 6 | ) 7 | 8 | from posthog.ai.openai.openai import ( 9 | WrappedBeta, 10 | WrappedChat, 11 | WrappedEmbeddings, 12 | WrappedResponses, 13 | ) 14 | from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta 15 | from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat 16 | from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings 17 | from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses 18 | from typing import Optional 19 | 20 | from posthog.client import Client as PostHogClient 21 | from posthog import setup 22 | 23 | 24 | class AzureOpenAI(openai.AzureOpenAI): 25 | """ 26 | A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog. 27 | """ 28 | 29 | _ph_client: PostHogClient 30 | 31 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 32 | """ 33 | Args: 34 | api_key: Azure OpenAI API key. 35 | posthog_client: If provided, events will be captured via this client instead 36 | of the global posthog. 37 | **openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx"). 38 | """ 39 | super().__init__(**kwargs) 40 | self._ph_client = posthog_client or setup() 41 | 42 | # Store original objects after parent initialization (only if they exist) 43 | self._original_chat = getattr(self, "chat", None) 44 | self._original_embeddings = getattr(self, "embeddings", None) 45 | self._original_beta = getattr(self, "beta", None) 46 | self._original_responses = getattr(self, "responses", None) 47 | 48 | # Replace with wrapped versions (only if originals exist) 49 | if self._original_chat is not None: 50 | self.chat = WrappedChat(self, self._original_chat) 51 | 52 | if self._original_embeddings is not None: 53 | self.embeddings = WrappedEmbeddings(self, self._original_embeddings) 54 | 55 | if self._original_beta is not None: 56 | self.beta = WrappedBeta(self, self._original_beta) 57 | 58 | if self._original_responses is not None: 59 | self.responses = WrappedResponses(self, self._original_responses) 60 | 61 | 62 | class AsyncAzureOpenAI(openai.AsyncAzureOpenAI): 63 | """ 64 | An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog. 65 | """ 66 | 67 | _ph_client: PostHogClient 68 | 69 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 70 | """ 71 | Args: 72 | api_key: Azure OpenAI API key. 73 | posthog_client: If provided, events will be captured via this client instead 74 | of the global posthog. 75 | **openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx"). 76 | """ 77 | super().__init__(**kwargs) 78 | self._ph_client = posthog_client or setup() 79 | 80 | # Store original objects after parent initialization (only if they exist) 81 | self._original_chat = getattr(self, "chat", None) 82 | self._original_embeddings = getattr(self, "embeddings", None) 83 | self._original_beta = getattr(self, "beta", None) 84 | self._original_responses = getattr(self, "responses", None) 85 | 86 | # Replace with wrapped versions (only if originals exist) 87 | if self._original_chat is not None: 88 | self.chat = AsyncWrappedChat(self, self._original_chat) 89 | 90 | if self._original_embeddings is not None: 91 | self.embeddings = AsyncWrappedEmbeddings(self, self._original_embeddings) 92 | 93 | if self._original_beta is not None: 94 | self.beta = AsyncWrappedBeta(self, self._original_beta) 95 | 96 | # Only add responses if available (newer OpenAI versions) 97 | if self._original_responses is not None: 98 | self.responses = AsyncWrappedResponses(self, self._original_responses) 99 | -------------------------------------------------------------------------------- /integration_tests/django5/test_exception_capture.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test that verifies exception capture functionality. 3 | 4 | These tests verify that exceptions are actually captured to PostHog, not just that 5 | 500 responses are returned. 6 | 7 | Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier). 8 | With process_exception(), Django calls this method to capture exceptions before 9 | converting them to 500 responses. 10 | """ 11 | 12 | import os 13 | import django 14 | 15 | # Setup Django before importing anything else 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") 17 | django.setup() 18 | 19 | import pytest # noqa: E402 20 | from httpx import AsyncClient, ASGITransport # noqa: E402 21 | from django.core.asgi import get_asgi_application # noqa: E402 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def asgi_app(): 26 | """Shared ASGI application for all tests.""" 27 | return get_asgi_application() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_async_exception_is_captured(asgi_app): 32 | """ 33 | Test that async view exceptions are captured to PostHog. 34 | 35 | The middleware's process_exception() method ensures exceptions are captured. 36 | Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned. 37 | """ 38 | from unittest.mock import patch 39 | 40 | # Track captured exceptions 41 | captured = [] 42 | 43 | def mock_capture(exception, **kwargs): 44 | """Mock capture_exception to record calls.""" 45 | captured.append( 46 | { 47 | "exception": exception, 48 | "type": type(exception).__name__, 49 | "message": str(exception), 50 | } 51 | ) 52 | 53 | # Patch at the posthog module level where middleware imports from 54 | with patch("posthog.capture_exception", side_effect=mock_capture): 55 | async with AsyncClient( 56 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 57 | ) as ac: 58 | response = await ac.get("/test/async-exception") 59 | 60 | # Django returns 500 61 | assert response.status_code == 500 62 | 63 | # CRITICAL: Verify PostHog captured the exception 64 | assert len(captured) > 0, "Exception was NOT captured to PostHog!" 65 | 66 | # Verify it's the right exception 67 | exception_data = captured[0] 68 | assert exception_data["type"] == "ValueError" 69 | assert "Test exception from Django 5 async view" in exception_data["message"] 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_sync_exception_is_captured(asgi_app): 74 | """ 75 | Test that sync view exceptions are captured to PostHog. 76 | 77 | The middleware's process_exception() method ensures exceptions are captured. 78 | Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned. 79 | """ 80 | from unittest.mock import patch 81 | 82 | # Track captured exceptions 83 | captured = [] 84 | 85 | def mock_capture(exception, **kwargs): 86 | """Mock capture_exception to record calls.""" 87 | captured.append( 88 | { 89 | "exception": exception, 90 | "type": type(exception).__name__, 91 | "message": str(exception), 92 | } 93 | ) 94 | 95 | # Patch at the posthog module level where middleware imports from 96 | with patch("posthog.capture_exception", side_effect=mock_capture): 97 | async with AsyncClient( 98 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 99 | ) as ac: 100 | response = await ac.get("/test/sync-exception") 101 | 102 | # Django returns 500 103 | assert response.status_code == 500 104 | 105 | # CRITICAL: Verify PostHog captured the exception 106 | assert len(captured) > 0, "Exception was NOT captured to PostHog!" 107 | 108 | # Verify it's the right exception 109 | exception_data = captured[0] 110 | assert exception_data["type"] == "ValueError" 111 | assert "Test exception from Django 5 sync view" in exception_data["message"] 112 | -------------------------------------------------------------------------------- /posthog/flag_definition_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flag Definition Cache Provider interface for multi-worker environments. 3 | 4 | EXPERIMENTAL: This API may change in future minor version bumps. 5 | 6 | This module provides an interface for external caching of feature flag definitions, 7 | enabling multi-worker environments (Kubernetes, load-balanced servers, serverless 8 | functions) to share flag definitions and reduce API calls. 9 | 10 | Usage: 11 | 12 | from posthog import Posthog 13 | from posthog.flag_definition_cache import FlagDefinitionCacheProvider 14 | 15 | cache = RedisFlagDefinitionCache(redis_client, "my-team") 16 | posthog = Posthog( 17 | "", 18 | personal_api_key="", 19 | flag_definition_cache_provider=cache, 20 | ) 21 | """ 22 | 23 | from typing import Any, Dict, List, Optional, Protocol, runtime_checkable 24 | 25 | from typing_extensions import Required, TypedDict 26 | 27 | 28 | class FlagDefinitionCacheData(TypedDict): 29 | """ 30 | Data structure for cached flag definitions. 31 | 32 | Attributes: 33 | flags: List of feature flag definition dictionaries from the API. 34 | group_type_mapping: Mapping of group type indices to group names. 35 | cohorts: Dictionary of cohort definitions for local evaluation. 36 | """ 37 | 38 | flags: Required[List[Dict[str, Any]]] 39 | group_type_mapping: Required[Dict[str, str]] 40 | cohorts: Required[Dict[str, Any]] 41 | 42 | 43 | @runtime_checkable 44 | class FlagDefinitionCacheProvider(Protocol): 45 | """ 46 | Interface for external caching of feature flag definitions. 47 | 48 | Enables multi-worker environments to share flag definitions, reducing API 49 | calls while ensuring all workers have consistent data. 50 | 51 | EXPERIMENTAL: This API may change in future minor version bumps. 52 | 53 | The four methods handle the complete lifecycle of flag definition caching: 54 | 55 | 1. `should_fetch_flag_definitions()` - Called before each poll to determine 56 | if this worker should fetch new definitions. Use for distributed lock 57 | coordination to ensure only one worker fetches at a time. 58 | 59 | 2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()` 60 | returns False. Returns cached definitions if available. 61 | 62 | 3. `on_flag_definitions_received()` - Called after successfully fetching 63 | new definitions from the API. Store the data in your external cache 64 | and release any locks. 65 | 66 | 4. `shutdown()` - Called when the PostHog client shuts down. Release any 67 | distributed locks and clean up resources. 68 | 69 | Error Handling: 70 | All methods are wrapped in try/except. Errors will be logged but will 71 | never break flag evaluation. On error: 72 | - `should_fetch_flag_definitions()` errors default to fetching (fail-safe) 73 | - `get_flag_definitions()` errors fall back to API fetch 74 | - `on_flag_definitions_received()` errors are logged but flags remain in memory 75 | - `shutdown()` errors are logged but shutdown continues 76 | """ 77 | 78 | def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]: 79 | """ 80 | Retrieve cached flag definitions. 81 | 82 | Returns: 83 | Cached flag definitions if available and valid, None otherwise. 84 | Returning None will trigger a fetch from the API if this worker 85 | has no flags loaded yet. 86 | """ 87 | ... 88 | 89 | def should_fetch_flag_definitions(self) -> bool: 90 | """ 91 | Determine whether this instance should fetch new flag definitions. 92 | 93 | Use this for distributed lock coordination. Only one worker should 94 | return True to avoid thundering herd problems. A typical implementation 95 | uses a distributed lock (e.g., Redis SETNX) that expires after the 96 | poll interval. 97 | 98 | Returns: 99 | True if this instance should fetch from the API, False otherwise. 100 | When False, the client will call `get_flag_definitions()` to 101 | retrieve cached data instead. 102 | """ 103 | ... 104 | 105 | def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None: 106 | """ 107 | Called after successfully receiving new flag definitions from PostHog. 108 | 109 | Use this to store the data in your external cache and release any 110 | distributed locks acquired in `should_fetch_flag_definitions()`. 111 | 112 | Args: 113 | data: The flag definitions to cache, containing flags, 114 | group_type_mapping, and cohorts. 115 | """ 116 | ... 117 | 118 | def shutdown(self) -> None: 119 | """ 120 | Called when the PostHog client shuts down. 121 | 122 | Use this to release any distributed locks and clean up resources. 123 | This method is called even if `should_fetch_flag_definitions()` 124 | returned False, so implementations should handle the case where 125 | no lock was acquired. 126 | """ 127 | ... 128 | -------------------------------------------------------------------------------- /posthog/consumer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from threading import Thread 5 | 6 | import backoff 7 | 8 | from posthog.request import APIError, DatetimeSerializer, batch_post 9 | 10 | try: 11 | from queue import Empty 12 | except ImportError: 13 | from Queue import Empty 14 | 15 | 16 | MAX_MSG_SIZE = 900 * 1024 # 900KiB per event 17 | 18 | # The maximum request body size is currently 20MiB, let's be conservative 19 | # in case we want to lower it in the future. 20 | BATCH_SIZE_LIMIT = 5 * 1024 * 1024 21 | 22 | 23 | class Consumer(Thread): 24 | """Consumes the messages from the client's queue.""" 25 | 26 | log = logging.getLogger("posthog") 27 | 28 | def __init__( 29 | self, 30 | queue, 31 | api_key, 32 | flush_at=100, 33 | host=None, 34 | on_error=None, 35 | flush_interval=0.5, 36 | gzip=False, 37 | retries=10, 38 | timeout=15, 39 | historical_migration=False, 40 | ): 41 | """Create a consumer thread.""" 42 | Thread.__init__(self) 43 | # Make consumer a daemon thread so that it doesn't block program exit 44 | self.daemon = True 45 | self.flush_at = flush_at 46 | self.flush_interval = flush_interval 47 | self.api_key = api_key 48 | self.host = host 49 | self.on_error = on_error 50 | self.queue = queue 51 | self.gzip = gzip 52 | # It's important to set running in the constructor: if we are asked to 53 | # pause immediately after construction, we might set running to True in 54 | # run() *after* we set it to False in pause... and keep running 55 | # forever. 56 | self.running = True 57 | self.retries = retries 58 | self.timeout = timeout 59 | self.historical_migration = historical_migration 60 | 61 | def run(self): 62 | """Runs the consumer.""" 63 | self.log.debug("consumer is running...") 64 | while self.running: 65 | self.upload() 66 | 67 | self.log.debug("consumer exited.") 68 | 69 | def pause(self): 70 | """Pause the consumer.""" 71 | self.running = False 72 | 73 | def upload(self): 74 | """Upload the next batch of items, return whether successful.""" 75 | success = False 76 | batch = self.next() 77 | if len(batch) == 0: 78 | return False 79 | 80 | try: 81 | self.request(batch) 82 | success = True 83 | except Exception as e: 84 | self.log.error("error uploading: %s", e) 85 | success = False 86 | if self.on_error: 87 | self.on_error(e, batch) 88 | finally: 89 | # mark items as acknowledged from queue 90 | for item in batch: 91 | self.queue.task_done() 92 | return success 93 | 94 | def next(self): 95 | """Return the next batch of items to upload.""" 96 | queue = self.queue 97 | items = [] 98 | 99 | start_time = time.monotonic() 100 | total_size = 0 101 | 102 | while len(items) < self.flush_at: 103 | elapsed = time.monotonic() - start_time 104 | if elapsed >= self.flush_interval: 105 | break 106 | try: 107 | item = queue.get(block=True, timeout=self.flush_interval - elapsed) 108 | item_size = len(json.dumps(item, cls=DatetimeSerializer).encode()) 109 | if item_size > MAX_MSG_SIZE: 110 | self.log.error( 111 | "Item exceeds 900kib limit, dropping. (%s)", str(item) 112 | ) 113 | continue 114 | items.append(item) 115 | total_size += item_size 116 | if total_size >= BATCH_SIZE_LIMIT: 117 | self.log.debug("hit batch size limit (size: %d)", total_size) 118 | break 119 | except Empty: 120 | break 121 | 122 | return items 123 | 124 | def request(self, batch): 125 | """Attempt to upload the batch and retry before raising an error""" 126 | 127 | def fatal_exception(exc): 128 | if isinstance(exc, APIError): 129 | # retry on server errors and client errors 130 | # with 429 status code (rate limited), 131 | # don't retry on other client errors 132 | if exc.status == "N/A": 133 | return False 134 | return (400 <= exc.status < 500) and exc.status != 429 135 | else: 136 | # retry on all other errors (eg. network) 137 | return False 138 | 139 | @backoff.on_exception( 140 | backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception 141 | ) 142 | def send_request(): 143 | batch_post( 144 | self.api_key, 145 | self.host, 146 | gzip=self.gzip, 147 | timeout=self.timeout, 148 | batch=batch, 149 | historical_migration=self.historical_migration, 150 | ) 151 | 152 | send_request() 153 | -------------------------------------------------------------------------------- /examples/redis_flag_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Redis-based distributed cache for PostHog feature flag definitions. 3 | 4 | This example demonstrates how to implement a FlagDefinitionCacheProvider 5 | using Redis for multi-instance deployments (leader election pattern). 6 | 7 | Usage: 8 | import redis 9 | from posthog import Posthog 10 | 11 | redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) 12 | cache = RedisFlagCache(redis_client, service_key="my-service") 13 | 14 | posthog = Posthog( 15 | "", 16 | personal_api_key="", 17 | flag_definition_cache_provider=cache, 18 | ) 19 | 20 | Requirements: 21 | pip install redis 22 | """ 23 | 24 | import json 25 | import uuid 26 | 27 | from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider 28 | from redis import Redis 29 | from typing import Optional 30 | 31 | 32 | class RedisFlagCache(FlagDefinitionCacheProvider): 33 | """ 34 | A distributed cache for PostHog feature flag definitions using Redis. 35 | 36 | In a multi-instance deployment (e.g., multiple serverless functions or containers), 37 | we want only ONE instance to poll PostHog for flag updates, while all instances 38 | share the cached results. This prevents N instances from making N redundant API calls. 39 | 40 | The implementation uses leader election: 41 | - One instance "wins" and becomes responsible for fetching 42 | - Other instances read from the shared cache 43 | - If the leader dies, the lock expires (TTL) and another instance takes over 44 | 45 | Uses Lua scripts for atomic operations, following Redis distributed lock best practices: 46 | https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/ 47 | """ 48 | 49 | LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval 50 | CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours 51 | 52 | # Lua script: acquire lock if free, or extend if we own it 53 | _LUA_TRY_LEAD = """ 54 | local current = redis.call('GET', KEYS[1]) 55 | if current == false then 56 | redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) 57 | return 1 58 | elseif current == ARGV[1] then 59 | redis.call('PEXPIRE', KEYS[1], ARGV[2]) 60 | return 1 61 | end 62 | return 0 63 | """ 64 | 65 | # Lua script: release lock only if we own it 66 | _LUA_STOP_LEAD = """ 67 | if redis.call('GET', KEYS[1]) == ARGV[1] then 68 | return redis.call('DEL', KEYS[1]) 69 | end 70 | return 0 71 | """ 72 | 73 | def __init__(self, redis: Redis[str], service_key: str): 74 | """ 75 | Initialize the Redis flag cache. 76 | 77 | Args: 78 | redis: A redis-py client instance. Must be configured with 79 | decode_responses=True for correct string handling. 80 | service_key: A unique identifier for this service/environment. 81 | Used to scope Redis keys, allowing multiple services 82 | or environments to share the same Redis instance. 83 | Examples: "my-api-prod", "checkout-service", "staging". 84 | 85 | Redis Keys Created: 86 | - posthog:flags:{service_key} - Cached flag definitions (JSON) 87 | - posthog:flags:{service_key}:lock - Leader election lock 88 | 89 | Example: 90 | redis_client = redis.Redis( 91 | host='localhost', 92 | port=6379, 93 | decode_responses=True 94 | ) 95 | cache = RedisFlagCache(redis_client, service_key="my-api-prod") 96 | """ 97 | self._redis = redis 98 | self._cache_key = f"posthog:flags:{service_key}" 99 | self._lock_key = f"posthog:flags:{service_key}:lock" 100 | self._instance_id = str(uuid.uuid4()) 101 | self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD) 102 | self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD) 103 | 104 | def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]: 105 | """ 106 | Retrieve cached flag definitions from Redis. 107 | 108 | Returns: 109 | Cached flag definitions if available, None otherwise. 110 | """ 111 | cached = self._redis.get(self._cache_key) 112 | return json.loads(cached) if cached else None 113 | 114 | def should_fetch_flag_definitions(self) -> bool: 115 | """ 116 | Determines if this instance should fetch flag definitions from PostHog. 117 | 118 | Atomically either: 119 | - Acquires the lock if no one holds it, OR 120 | - Extends the lock TTL if we already hold it 121 | 122 | Returns: 123 | True if this instance is the leader and should fetch, False otherwise. 124 | """ 125 | result = self._try_lead( 126 | keys=[self._lock_key], 127 | args=[self._instance_id, self.LOCK_TTL_MS], 128 | ) 129 | return result == 1 130 | 131 | def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None: 132 | """ 133 | Store fetched flag definitions in Redis. 134 | 135 | Args: 136 | data: The flag definitions to cache. 137 | """ 138 | self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS) 139 | 140 | def shutdown(self) -> None: 141 | """ 142 | Release leadership if we hold it. Safe to call even if not the leader. 143 | """ 144 | self._stop_lead(keys=[self._lock_key], args=[self._instance_id]) 145 | -------------------------------------------------------------------------------- /integration_tests/django5/test_middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for PostHog Django middleware in async context. 3 | 4 | These tests verify that the middleware correctly handles: 5 | 1. Async user access (request.auser() in Django 5) 6 | 2. Exception capture in both sync and async views 7 | 3. No SynchronousOnlyOperation errors in async context 8 | 9 | Tests run directly against the ASGI application without needing a server. 10 | """ 11 | 12 | import os 13 | import django 14 | 15 | # Setup Django before importing anything else 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") 17 | django.setup() 18 | 19 | import pytest # noqa: E402 20 | from httpx import AsyncClient, ASGITransport # noqa: E402 21 | from django.core.asgi import get_asgi_application # noqa: E402 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def asgi_app(): 26 | """Shared ASGI application for all tests.""" 27 | return get_asgi_application() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_async_user_access(asgi_app): 32 | """ 33 | Test that middleware can access request.user in async context. 34 | 35 | In Django 5, this requires using await request.auser() instead of request.user 36 | to avoid SynchronousOnlyOperation error. 37 | 38 | Without authentication, request.user is AnonymousUser which doesn't 39 | trigger the lazy loading bug. This test verifies the middleware works 40 | in the common case. 41 | """ 42 | async with AsyncClient( 43 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 44 | ) as ac: 45 | response = await ac.get("/test/async-user") 46 | 47 | assert response.status_code == 200 48 | data = response.json() 49 | assert data["status"] == "success" 50 | assert "django_version" in data 51 | 52 | 53 | @pytest.mark.django_db(transaction=True) 54 | @pytest.mark.asyncio 55 | async def test_async_authenticated_user_access(asgi_app): 56 | """ 57 | Test that middleware can access an authenticated user in async context. 58 | 59 | This is the critical test that triggers the SynchronousOnlyOperation bug 60 | in v6.7.11. When AuthenticationMiddleware sets request.user to a 61 | SimpleLazyObject wrapping a database query, accessing user.pk or user.email 62 | in async context causes the error. 63 | 64 | In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False) 65 | which triggers the lazy object evaluation synchronously. 66 | 67 | The fix uses await request.auser() instead to avoid this. 68 | """ 69 | from django.contrib.auth import get_user_model 70 | from django.test import Client 71 | from asgiref.sync import sync_to_async 72 | from django.test import override_settings 73 | 74 | # Create a test user (must use sync_to_async since we're in async test) 75 | User = get_user_model() 76 | 77 | @sync_to_async 78 | def create_or_get_user(): 79 | user, created = User.objects.get_or_create( 80 | username="testuser", 81 | defaults={ 82 | "email": "test@example.com", 83 | }, 84 | ) 85 | if created: 86 | user.set_password("testpass123") 87 | user.save() 88 | return user 89 | 90 | user = await create_or_get_user() 91 | 92 | # Create a session with authenticated user (sync operation) 93 | @sync_to_async 94 | def create_session(): 95 | client = Client() 96 | client.force_login(user) 97 | return client.cookies.get("sessionid") 98 | 99 | session_cookie = await create_session() 100 | 101 | if not session_cookie: 102 | pytest.skip("Could not create authenticated session") 103 | 104 | # Make request with session cookie - this should trigger the bug in v6.7.11 105 | # Disable exception capture to see the SynchronousOnlyOperation clearly 106 | with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False): 107 | async with AsyncClient( 108 | transport=ASGITransport(app=asgi_app), 109 | base_url="http://testserver", 110 | cookies={"sessionid": session_cookie.value}, 111 | ) as ac: 112 | response = await ac.get("/test/async-user") 113 | 114 | assert response.status_code == 200 115 | data = response.json() 116 | assert data["status"] == "success" 117 | assert data["user_authenticated"] 118 | 119 | 120 | @pytest.mark.asyncio 121 | async def test_sync_user_access(asgi_app): 122 | """ 123 | Test that middleware works with sync views. 124 | 125 | This should always work regardless of middleware version. 126 | """ 127 | async with AsyncClient( 128 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 129 | ) as ac: 130 | response = await ac.get("/test/sync-user") 131 | 132 | assert response.status_code == 200 133 | data = response.json() 134 | assert data["status"] == "success" 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_async_exception_capture(asgi_app): 139 | """ 140 | Test that middleware handles exceptions from async views. 141 | 142 | The middleware's process_exception() method captures view exceptions to PostHog 143 | before Django converts them to 500 responses. This test verifies the exception 144 | causes a 500 response. See test_exception_capture.py for tests that verify 145 | actual exception capture to PostHog. 146 | """ 147 | async with AsyncClient( 148 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 149 | ) as ac: 150 | response = await ac.get("/test/async-exception") 151 | 152 | # Django returns 500 for unhandled exceptions 153 | assert response.status_code == 500 154 | 155 | 156 | @pytest.mark.asyncio 157 | async def test_sync_exception_capture(asgi_app): 158 | """ 159 | Test that middleware handles exceptions from sync views. 160 | 161 | The middleware's process_exception() method captures view exceptions to PostHog. 162 | This test verifies the exception causes a 500 response. 163 | """ 164 | async with AsyncClient( 165 | transport=ASGITransport(app=asgi_app), base_url="http://testserver" 166 | ) as ac: 167 | response = await ac.get("/test/sync-exception") 168 | 169 | # Django returns 500 for unhandled exceptions 170 | assert response.status_code == 500 171 | -------------------------------------------------------------------------------- /posthog/ai/sanitization.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import Any 4 | from urllib.parse import urlparse 5 | 6 | REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]" 7 | 8 | 9 | def _is_multimodal_enabled() -> bool: 10 | """Check if multimodal capture is enabled via environment variable.""" 11 | return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in ( 12 | "true", 13 | "1", 14 | "yes", 15 | ) 16 | 17 | 18 | def is_base64_data_url(text: str) -> bool: 19 | return re.match(r"^data:([^;]+);base64,", text) is not None 20 | 21 | 22 | def is_valid_url(text: str) -> bool: 23 | try: 24 | result = urlparse(text) 25 | return bool(result.scheme and result.netloc) 26 | except Exception: 27 | pass 28 | 29 | return text.startswith(("/", "./", "../")) 30 | 31 | 32 | def is_raw_base64(text: str) -> bool: 33 | if is_valid_url(text): 34 | return False 35 | 36 | return len(text) > 20 and re.match(r"^[A-Za-z0-9+/]+=*$", text) is not None 37 | 38 | 39 | def redact_base64_data_url(value: Any) -> Any: 40 | if _is_multimodal_enabled(): 41 | return value 42 | 43 | if not isinstance(value, str): 44 | return value 45 | 46 | if is_base64_data_url(value): 47 | return REDACTED_IMAGE_PLACEHOLDER 48 | 49 | if is_raw_base64(value): 50 | return REDACTED_IMAGE_PLACEHOLDER 51 | 52 | return value 53 | 54 | 55 | def process_messages(messages: Any, transform_content_func) -> Any: 56 | if not messages: 57 | return messages 58 | 59 | def process_content(content: Any) -> Any: 60 | if isinstance(content, str): 61 | return content 62 | 63 | if not content: 64 | return content 65 | 66 | if isinstance(content, list): 67 | return [transform_content_func(item) for item in content] 68 | 69 | return transform_content_func(content) 70 | 71 | def process_message(msg: Any) -> Any: 72 | if not isinstance(msg, dict) or "content" not in msg: 73 | return msg 74 | return {**msg, "content": process_content(msg["content"])} 75 | 76 | if isinstance(messages, list): 77 | return [process_message(msg) for msg in messages] 78 | 79 | return process_message(messages) 80 | 81 | 82 | def sanitize_openai_image(item: Any) -> Any: 83 | if not isinstance(item, dict): 84 | return item 85 | 86 | if ( 87 | item.get("type") == "image_url" 88 | and isinstance(item.get("image_url"), dict) 89 | and "url" in item["image_url"] 90 | ): 91 | return { 92 | **item, 93 | "image_url": { 94 | **item["image_url"], 95 | "url": redact_base64_data_url(item["image_url"]["url"]), 96 | }, 97 | } 98 | 99 | if item.get("type") == "audio" and "data" in item: 100 | if _is_multimodal_enabled(): 101 | return item 102 | return {**item, "data": REDACTED_IMAGE_PLACEHOLDER} 103 | 104 | return item 105 | 106 | 107 | def sanitize_openai_response_image(item: Any) -> Any: 108 | if not isinstance(item, dict): 109 | return item 110 | 111 | if item.get("type") == "input_image" and "image_url" in item: 112 | return { 113 | **item, 114 | "image_url": redact_base64_data_url(item["image_url"]), 115 | } 116 | 117 | return item 118 | 119 | 120 | def sanitize_anthropic_image(item: Any) -> Any: 121 | if _is_multimodal_enabled(): 122 | return item 123 | 124 | if not isinstance(item, dict): 125 | return item 126 | 127 | if ( 128 | item.get("type") == "image" 129 | and isinstance(item.get("source"), dict) 130 | and item["source"].get("type") == "base64" 131 | and "data" in item["source"] 132 | ): 133 | return { 134 | **item, 135 | "source": { 136 | **item["source"], 137 | "data": REDACTED_IMAGE_PLACEHOLDER, 138 | }, 139 | } 140 | 141 | return item 142 | 143 | 144 | def sanitize_gemini_part(part: Any) -> Any: 145 | if _is_multimodal_enabled(): 146 | return part 147 | 148 | if not isinstance(part, dict): 149 | return part 150 | 151 | if ( 152 | "inline_data" in part 153 | and isinstance(part["inline_data"], dict) 154 | and "data" in part["inline_data"] 155 | ): 156 | return { 157 | **part, 158 | "inline_data": { 159 | **part["inline_data"], 160 | "data": REDACTED_IMAGE_PLACEHOLDER, 161 | }, 162 | } 163 | 164 | return part 165 | 166 | 167 | def process_gemini_item(item: Any) -> Any: 168 | if not isinstance(item, dict): 169 | return item 170 | 171 | if "parts" in item and item["parts"]: 172 | parts = item["parts"] 173 | if isinstance(parts, list): 174 | parts = [sanitize_gemini_part(part) for part in parts] 175 | else: 176 | parts = sanitize_gemini_part(parts) 177 | 178 | return {**item, "parts": parts} 179 | 180 | return item 181 | 182 | 183 | def sanitize_langchain_image(item: Any) -> Any: 184 | if not isinstance(item, dict): 185 | return item 186 | 187 | if ( 188 | item.get("type") == "image_url" 189 | and isinstance(item.get("image_url"), dict) 190 | and "url" in item["image_url"] 191 | ): 192 | return { 193 | **item, 194 | "image_url": { 195 | **item["image_url"], 196 | "url": redact_base64_data_url(item["image_url"]["url"]), 197 | }, 198 | } 199 | 200 | if item.get("type") == "image" and "data" in item: 201 | return {**item, "data": redact_base64_data_url(item["data"])} 202 | 203 | if ( 204 | item.get("type") == "image" 205 | and isinstance(item.get("source"), dict) 206 | and "data" in item["source"] 207 | ): 208 | if _is_multimodal_enabled(): 209 | return item 210 | 211 | return { 212 | **item, 213 | "source": { 214 | **item["source"], 215 | "data": REDACTED_IMAGE_PLACEHOLDER, 216 | }, 217 | } 218 | 219 | if item.get("type") == "media" and "data" in item: 220 | return {**item, "data": redact_base64_data_url(item["data"])} 221 | 222 | return item 223 | 224 | 225 | def sanitize_openai(data: Any) -> Any: 226 | return process_messages(data, sanitize_openai_image) 227 | 228 | 229 | def sanitize_openai_response(data: Any) -> Any: 230 | return process_messages(data, sanitize_openai_response_image) 231 | 232 | 233 | def sanitize_anthropic(data: Any) -> Any: 234 | return process_messages(data, sanitize_anthropic_image) 235 | 236 | 237 | def sanitize_gemini(data: Any) -> Any: 238 | if not data: 239 | return data 240 | 241 | if isinstance(data, list): 242 | return [process_gemini_item(item) for item in data] 243 | 244 | return process_gemini_item(data) 245 | 246 | 247 | def sanitize_langchain(data: Any) -> Any: 248 | return process_messages(data, sanitize_langchain_image) 249 | -------------------------------------------------------------------------------- /BEFORE_SEND.md: -------------------------------------------------------------------------------- 1 | # Before Send Hook 2 | 3 | The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for: 4 | 5 | - **Privacy**: Removing or masking sensitive data (PII) 6 | - **Filtering**: Dropping unwanted events (test events, internal users, etc.) 7 | - **Enhancement**: Adding custom properties to all events 8 | - **Transformation**: Modifying event names or property formats 9 | 10 | ## Basic Usage 11 | 12 | ```python 13 | import posthog 14 | from typing import Optional, Dict, Any 15 | 16 | def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]: 17 | """ 18 | Process event before sending to PostHog. 19 | 20 | Args: 21 | event: The event dictionary containing 'event', 'distinct_id', 'properties', etc. 22 | 23 | Returns: 24 | Modified event dictionary to send, or None to drop the event 25 | """ 26 | # Your processing logic here 27 | return event 28 | 29 | # Initialize client with before_send hook 30 | client = posthog.Client( 31 | api_key="your-project-api-key", 32 | before_send=my_before_send 33 | ) 34 | ``` 35 | 36 | ## Common Use Cases 37 | 38 | ### 1. Filter Out Events 39 | 40 | ```python 41 | from typing import Optional, Any 42 | 43 | def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]: 44 | """Drop events from internal users or test environments.""" 45 | properties = event.get("properties", {}) 46 | 47 | # Choose some property from your events 48 | event_source = properties.get("event_source", "") 49 | if event_source.endswith("internal"): 50 | return None # Drop the event 51 | 52 | # Filter out test events 53 | if event.get("event") == "test_event": 54 | return None 55 | 56 | return event 57 | ``` 58 | 59 | ### 2. Remove/Mask PII Data 60 | 61 | ```python 62 | from typing import Optional, Any 63 | 64 | def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]: 65 | """Remove or mask personally identifiable information.""" 66 | properties = event.get("properties", {}) 67 | 68 | # Mask email but keep domain for analytics 69 | if "email" in properties: 70 | email = properties["email"] 71 | if "@" in email: 72 | domain = email.split("@")[1] 73 | properties["email"] = f"***@{domain}" 74 | else: 75 | properties["email"] = "***" 76 | 77 | # Remove sensitive fields entirely 78 | sensitive_fields = ["my_business_info", "secret_things"] 79 | for field in sensitive_fields: 80 | properties.pop(field, None) 81 | 82 | return event 83 | ``` 84 | 85 | ### 3. Add Custom Properties 86 | 87 | ```python 88 | from typing import Optional, Any 89 | 90 | from datetime import datetime 91 | from typing import Optional, Any 92 | 93 | def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]: 94 | """Add custom properties to all events.""" 95 | if "properties" not in event: 96 | event["properties"] = {} 97 | 98 | event["properties"].update({ 99 | "app_version": "2.1.0", 100 | "environment": "production", 101 | "processed_at": datetime.now().isoformat() 102 | }) 103 | 104 | return event 105 | ``` 106 | 107 | ### 4. Transform Event Names 108 | 109 | ```python 110 | from typing import Optional, Any 111 | 112 | def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]: 113 | """Convert event names to a consistent format.""" 114 | original_event = event.get("event") 115 | if original_event: 116 | # Convert to snake_case 117 | normalized = original_event.lower().replace(" ", "_").replace("-", "_") 118 | event["event"] = f"app_{normalized}" 119 | 120 | return event 121 | ``` 122 | 123 | ### 5. Log and drop in "dev" mode 124 | 125 | When running in local dev often, you want to log but drop all events 126 | 127 | 128 | ```python 129 | from typing import Optional, Any 130 | 131 | def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]: 132 | """Convert event names to a consistent format.""" 133 | print(event) 134 | 135 | return None 136 | ``` 137 | 138 | ### 6. Combined Processing 139 | 140 | ```python 141 | from typing import Optional, Any 142 | 143 | def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]: 144 | """Apply multiple transformations in sequence.""" 145 | 146 | # Step 1: Filter unwanted events 147 | if should_drop_event(event): 148 | return None 149 | 150 | # Step 2: Scrub PII 151 | event = scrub_pii(event) 152 | 153 | # Step 3: Add context 154 | event = add_context(event) 155 | 156 | # Step 4: Normalize names 157 | event = normalize_event_names(event) 158 | 159 | return event 160 | 161 | def should_drop_event(event: dict[str, Any]) -> bool: 162 | """Determine if event should be dropped.""" 163 | # Your filtering logic 164 | return False 165 | ``` 166 | 167 | ## Error Handling 168 | 169 | If your `before_send` function raises an exception, PostHog will: 170 | 171 | 1. Log the error 172 | 2. Continue with the original, unmodified event 173 | 3. Not crash your application 174 | 175 | ```python 176 | from typing import Optional, Any 177 | 178 | def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]: 179 | # If this raises an exception, the original event will be sent 180 | risky_operation() 181 | return event 182 | ``` 183 | 184 | ## Complete Example 185 | 186 | ```python 187 | import posthog 188 | from typing import Optional, Any 189 | import re 190 | 191 | def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]: 192 | try: 193 | properties = event.get("properties", {}) 194 | 195 | # 1. Filter out bot traffic 196 | user_agent = properties.get("$user_agent", "") 197 | if re.search(r'bot|crawler|spider', user_agent, re.I): 198 | return None 199 | 200 | # 2. Filter out internal traffic 201 | ip = properties.get("$ip", "") 202 | if ip.startswith("192.168.") or ip.startswith("10."): 203 | return None 204 | 205 | # 3. Scrub email PII but keep domain 206 | if "email" in properties: 207 | email = properties["email"] 208 | if "@" in email: 209 | domain = email.split("@")[1] 210 | properties["email"] = f"***@{domain}" 211 | 212 | # 4. Add custom context 213 | properties.update({ 214 | "app_version": "1.0.0", 215 | "build_number": "123" 216 | }) 217 | 218 | # 5. Normalize event name 219 | if event.get("event"): 220 | event["event"] = event["event"].lower().replace(" ", "_") 221 | 222 | return event 223 | 224 | except Exception as e: 225 | # Log error but don't crash 226 | print(f"Error in before_send: {e}") 227 | return event # Return original event on error 228 | 229 | # Usage 230 | client = posthog.Client( 231 | api_key="your-api-key", 232 | before_send=production_before_send 233 | ) 234 | 235 | # All events will now be processed by your before_send function 236 | client.capture("user_123", "Page View", {"url": "/home"}) 237 | ``` -------------------------------------------------------------------------------- /posthog/test/test_feature_flag.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata 4 | 5 | 6 | class TestFeatureFlag(unittest.TestCase): 7 | def test_feature_flag_from_json(self): 8 | # Test with full metadata 9 | resp = { 10 | "key": "test-flag", 11 | "enabled": True, 12 | "variant": "test-variant", 13 | "reason": { 14 | "code": "matched_condition", 15 | "condition_index": 0, 16 | "description": "Matched condition set 1", 17 | }, 18 | "metadata": { 19 | "id": 1, 20 | "payload": '{"some": "json"}', 21 | "version": 2, 22 | "description": "test-description", 23 | }, 24 | } 25 | 26 | flag = FeatureFlag.from_json(resp) 27 | self.assertEqual(flag.key, "test-flag") 28 | self.assertTrue(flag.enabled) 29 | self.assertEqual(flag.variant, "test-variant") 30 | self.assertEqual(flag.get_value(), "test-variant") 31 | self.assertEqual( 32 | flag.reason, 33 | FlagReason( 34 | code="matched_condition", 35 | condition_index=0, 36 | description="Matched condition set 1", 37 | ), 38 | ) 39 | self.assertEqual( 40 | flag.metadata, 41 | FlagMetadata( 42 | id=1, 43 | payload='{"some": "json"}', 44 | version=2, 45 | description="test-description", 46 | ), 47 | ) 48 | 49 | def test_feature_flag_from_json_minimal(self): 50 | # Test with minimal required fields 51 | resp = {"key": "test-flag", "enabled": True} 52 | 53 | flag = FeatureFlag.from_json(resp) 54 | self.assertEqual(flag.key, "test-flag") 55 | self.assertTrue(flag.enabled) 56 | self.assertIsNone(flag.variant) 57 | self.assertEqual(flag.get_value(), True) 58 | self.assertIsNone(flag.reason) 59 | self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None)) 60 | 61 | def test_feature_flag_from_json_without_metadata(self): 62 | # Test with reason but no metadata 63 | resp = { 64 | "key": "test-flag", 65 | "enabled": True, 66 | "variant": "test-variant", 67 | "reason": { 68 | "code": "matched_condition", 69 | "condition_index": 0, 70 | "description": "Matched condition set 1", 71 | }, 72 | } 73 | 74 | flag = FeatureFlag.from_json(resp) 75 | self.assertEqual(flag.key, "test-flag") 76 | self.assertTrue(flag.enabled) 77 | self.assertEqual(flag.variant, "test-variant") 78 | self.assertEqual(flag.get_value(), "test-variant") 79 | self.assertEqual( 80 | flag.reason, 81 | FlagReason( 82 | code="matched_condition", 83 | condition_index=0, 84 | description="Matched condition set 1", 85 | ), 86 | ) 87 | self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None)) 88 | 89 | def test_flag_reason_from_json(self): 90 | # Test with complete data 91 | resp = { 92 | "code": "user_in_segment", 93 | "condition_index": 1, 94 | "description": "User is in segment 'beta_users'", 95 | } 96 | reason = FlagReason.from_json(resp) 97 | self.assertEqual(reason.code, "user_in_segment") 98 | self.assertEqual(reason.condition_index, 1) 99 | self.assertEqual(reason.description, "User is in segment 'beta_users'") 100 | 101 | # Test with partial data 102 | resp = {"code": "user_in_segment"} 103 | reason = FlagReason.from_json(resp) 104 | self.assertEqual(reason.code, "user_in_segment") 105 | self.assertIsNone(reason.condition_index) # default value 106 | self.assertEqual(reason.description, "") 107 | 108 | # Test with None 109 | self.assertIsNone(FlagReason.from_json(None)) 110 | 111 | def test_flag_metadata_from_json(self): 112 | # Test with complete data 113 | resp = { 114 | "id": 123, 115 | "payload": {"key": "value"}, 116 | "version": 1, 117 | "description": "Test flag", 118 | } 119 | metadata = FlagMetadata.from_json(resp) 120 | self.assertEqual(metadata.id, 123) 121 | self.assertEqual(metadata.payload, {"key": "value"}) 122 | self.assertEqual(metadata.version, 1) 123 | self.assertEqual(metadata.description, "Test flag") 124 | 125 | # Test with partial data 126 | resp = {"id": 123} 127 | metadata = FlagMetadata.from_json(resp) 128 | self.assertEqual(metadata.id, 123) 129 | self.assertIsNone(metadata.payload) 130 | self.assertEqual(metadata.version, 0) # default value 131 | self.assertEqual(metadata.description, "") # default value 132 | 133 | # Test with None 134 | self.assertIsInstance(FlagMetadata.from_json(None), LegacyFlagMetadata) 135 | 136 | def test_feature_flag_from_json_complete(self): 137 | # Test with complete data 138 | resp = { 139 | "key": "test-flag", 140 | "enabled": True, 141 | "variant": "control", 142 | "reason": { 143 | "code": "user_in_segment", 144 | "condition_index": 1, 145 | "description": "User is in segment 'beta_users'", 146 | }, 147 | "metadata": { 148 | "id": 123, 149 | "payload": {"key": "value"}, 150 | "version": 1, 151 | "description": "Test flag", 152 | }, 153 | } 154 | flag = FeatureFlag.from_json(resp) 155 | self.assertEqual(flag.key, "test-flag") 156 | self.assertTrue(flag.enabled) 157 | self.assertEqual(flag.variant, "control") 158 | self.assertIsInstance(flag.reason, FlagReason) 159 | self.assertEqual(flag.reason.code, "user_in_segment") 160 | self.assertIsInstance(flag.metadata, FlagMetadata) 161 | self.assertEqual(flag.metadata.id, 123) 162 | self.assertEqual(flag.metadata.payload, {"key": "value"}) 163 | 164 | def test_feature_flag_from_json_minimal_data(self): 165 | # Test with minimal data 166 | resp = {"key": "test-flag", "enabled": False} 167 | flag = FeatureFlag.from_json(resp) 168 | self.assertEqual(flag.key, "test-flag") 169 | self.assertFalse(flag.enabled) 170 | self.assertIsNone(flag.variant) 171 | self.assertIsNone(flag.reason) 172 | self.assertIsInstance(flag.metadata, LegacyFlagMetadata) 173 | self.assertIsNone(flag.metadata.payload) 174 | 175 | def test_feature_flag_from_json_with_reason(self): 176 | # Test with reason but no metadata 177 | resp = { 178 | "key": "test-flag", 179 | "enabled": True, 180 | "reason": {"code": "user_in_segment"}, 181 | } 182 | flag = FeatureFlag.from_json(resp) 183 | self.assertEqual(flag.key, "test-flag") 184 | self.assertTrue(flag.enabled) 185 | self.assertIsNone(flag.variant) 186 | self.assertIsInstance(flag.reason, FlagReason) 187 | self.assertEqual(flag.reason.code, "user_in_segment") 188 | self.assertIsInstance(flag.metadata, LegacyFlagMetadata) 189 | self.assertIsNone(flag.metadata.payload) 190 | -------------------------------------------------------------------------------- /posthog/test/test_contexts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from posthog.contexts import ( 5 | get_tags, 6 | new_context, 7 | scoped, 8 | tag, 9 | identify_context, 10 | set_context_session, 11 | get_context_session_id, 12 | get_context_distinct_id, 13 | ) 14 | 15 | 16 | class TestContexts(unittest.TestCase): 17 | def test_tag_and_get_tags(self): 18 | with new_context(fresh=True): 19 | tag("key1", "value1") 20 | tag("key2", 2) 21 | 22 | tags = get_tags() 23 | assert tags["key1"] == "value1" 24 | assert tags["key2"] == 2 25 | 26 | def test_new_context_isolation(self): 27 | with new_context(fresh=True): 28 | # Set tag in outer context 29 | tag("outer", "value") 30 | 31 | with new_context(fresh=True): 32 | # Inner context should start empty 33 | assert get_tags() == {} 34 | 35 | # Set tag in inner context 36 | tag("inner", "value") 37 | assert get_tags()["inner"] == "value" 38 | 39 | # Outer tag should not be visible 40 | self.assertNotIn("outer", get_tags()) 41 | 42 | with new_context(fresh=False): 43 | # Inner context should inherit outer tag 44 | assert get_tags() == {"outer": "value"} 45 | 46 | # After exiting context, inner tag should be gone 47 | self.assertNotIn("inner", get_tags()) 48 | 49 | # Outer tag should still be there 50 | assert get_tags()["outer"] == "value" 51 | 52 | def test_nested_contexts(self): 53 | with new_context(fresh=True): 54 | tag("level1", "value1") 55 | 56 | with new_context(fresh=True): 57 | tag("level2", "value2") 58 | 59 | with new_context(fresh=True): 60 | tag("level3", "value3") 61 | assert get_tags() == {"level3": "value3"} 62 | 63 | # Back to level 2 64 | assert get_tags() == {"level2": "value2"} 65 | 66 | # Back to level 1 67 | assert get_tags() == {"level1": "value1"} 68 | 69 | @patch("posthog.capture_exception") 70 | def test_scoped_decorator_success(self, mock_capture): 71 | @scoped() 72 | def successful_function(x, y): 73 | tag("x", x) 74 | tag("y", y) 75 | return x + y 76 | 77 | result = successful_function(1, 2) 78 | 79 | # Function should execute normally 80 | assert result == 3 81 | 82 | # No exception should be captured 83 | mock_capture.assert_not_called() 84 | 85 | # Context should be cleared after function execution 86 | assert get_tags() == {} 87 | 88 | @patch("posthog.capture_exception") 89 | def test_scoped_decorator_exception(self, mock_capture): 90 | test_exception = ValueError("Test exception") 91 | 92 | def check_context_on_capture(exception, **kwargs): 93 | # Assert tags are available when capture_exception is called 94 | current_tags = get_tags() 95 | assert current_tags.get("important_context") == "value" 96 | 97 | mock_capture.side_effect = check_context_on_capture 98 | 99 | @scoped() 100 | def failing_function(): 101 | tag("important_context", "value") 102 | raise test_exception 103 | 104 | # Function should raise the exception 105 | with self.assertRaises(ValueError): 106 | failing_function() 107 | 108 | # Verify capture_exception was called 109 | mock_capture.assert_called_once_with(test_exception) 110 | 111 | # Context should be cleared after function execution 112 | assert get_tags() == {} 113 | 114 | @patch("posthog.capture_exception") 115 | def test_new_context_exception_handling(self, mock_capture): 116 | test_exception = RuntimeError("Context exception") 117 | 118 | def check_context_on_capture(exception, **kwargs): 119 | # Assert inner context tags are available when capture_exception is called 120 | current_tags = get_tags() 121 | assert current_tags.get("inner_context") == "inner_value" 122 | 123 | mock_capture.side_effect = check_context_on_capture 124 | 125 | # Set up outer context 126 | with new_context(): 127 | tag("outer_context", "outer_value") 128 | 129 | try: 130 | with new_context(): 131 | tag("inner_context", "inner_value") 132 | raise test_exception 133 | except RuntimeError: 134 | pass # Expected exception 135 | 136 | # Outer context should still be intact 137 | assert get_tags()["outer_context"] == "outer_value" 138 | 139 | # Verify capture_exception was called 140 | mock_capture.assert_called_once_with(test_exception) 141 | 142 | def test_identify_context(self): 143 | with new_context(fresh=True): 144 | # Initially no distinct ID 145 | assert get_context_distinct_id() is None 146 | 147 | # Set distinct ID 148 | identify_context("user123") 149 | assert get_context_distinct_id() == "user123" 150 | 151 | def test_set_context_session(self): 152 | with new_context(fresh=True): 153 | # Initially no session ID 154 | assert get_context_session_id() is None 155 | 156 | # Set session ID 157 | set_context_session("session456") 158 | assert get_context_session_id() == "session456" 159 | 160 | def test_context_inheritance_fresh_context(self): 161 | with new_context(fresh=True): 162 | identify_context("user123") 163 | set_context_session("session456") 164 | 165 | with new_context(fresh=True): 166 | # Fresh context should not inherit 167 | assert get_context_distinct_id() is None 168 | assert get_context_session_id() is None 169 | 170 | # Original context should still have values 171 | assert get_context_distinct_id() == "user123" 172 | assert get_context_session_id() == "session456" 173 | 174 | def test_context_inheritance_non_fresh_context(self): 175 | with new_context(fresh=True): 176 | identify_context("user123") 177 | set_context_session("session456") 178 | 179 | with new_context(fresh=False): 180 | # Non-fresh context should inherit 181 | assert get_context_distinct_id() == "user123" 182 | assert get_context_session_id() == "session456" 183 | 184 | # Override in child context 185 | identify_context("user789") 186 | set_context_session("session999") 187 | assert get_context_distinct_id() == "user789" 188 | assert get_context_session_id() == "session999" 189 | 190 | # Original context should still have original values 191 | assert get_context_distinct_id() == "user123" 192 | assert get_context_session_id() == "session456" 193 | 194 | def test_scoped_decorator_with_context_ids(self): 195 | @scoped() 196 | def function_with_context(): 197 | identify_context("user456") 198 | set_context_session("session789") 199 | return get_context_distinct_id(), get_context_session_id() 200 | 201 | distinct_id, session_id = function_with_context() 202 | assert distinct_id == "user456" 203 | assert session_id == "session789" 204 | 205 | # Context should be cleared after function execution 206 | assert get_context_distinct_id() is None 207 | assert get_context_session_id() is None 208 | -------------------------------------------------------------------------------- /posthog/test/test_consumer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import unittest 4 | 5 | import mock 6 | 7 | try: 8 | from queue import Queue 9 | except ImportError: 10 | from Queue import Queue 11 | 12 | from posthog.consumer import MAX_MSG_SIZE, Consumer 13 | from posthog.request import APIError 14 | from posthog.test.test_utils import TEST_API_KEY 15 | 16 | 17 | class TestConsumer(unittest.TestCase): 18 | def test_next(self): 19 | q = Queue() 20 | consumer = Consumer(q, "") 21 | q.put(1) 22 | next = consumer.next() 23 | self.assertEqual(next, [1]) 24 | 25 | def test_next_limit(self): 26 | q = Queue() 27 | flush_at = 50 28 | consumer = Consumer(q, "", flush_at) 29 | for i in range(10000): 30 | q.put(i) 31 | next = consumer.next() 32 | self.assertEqual(next, list(range(flush_at))) 33 | 34 | def test_dropping_oversize_msg(self): 35 | q = Queue() 36 | consumer = Consumer(q, "") 37 | oversize_msg = {"m": "x" * MAX_MSG_SIZE} 38 | q.put(oversize_msg) 39 | next = consumer.next() 40 | self.assertEqual(next, []) 41 | self.assertTrue(q.empty()) 42 | 43 | def test_upload(self): 44 | q = Queue() 45 | consumer = Consumer(q, TEST_API_KEY) 46 | track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"} 47 | q.put(track) 48 | success = consumer.upload() 49 | self.assertTrue(success) 50 | 51 | def test_flush_interval(self): 52 | # Put _n_ items in the queue, pausing a little bit more than 53 | # _flush_interval_ after each one. 54 | # The consumer should upload _n_ times. 55 | q = Queue() 56 | flush_interval = 0.3 57 | consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval) 58 | with mock.patch("posthog.consumer.batch_post") as mock_post: 59 | consumer.start() 60 | for i in range(0, 3): 61 | track = { 62 | "type": "track", 63 | "event": "python event %d" % i, 64 | "distinct_id": "distinct_id", 65 | } 66 | q.put(track) 67 | time.sleep(flush_interval * 1.1) 68 | self.assertEqual(mock_post.call_count, 3) 69 | 70 | def test_multiple_uploads_per_interval(self): 71 | # Put _flush_at*2_ items in the queue at once, then pause for 72 | # _flush_interval_. The consumer should upload 2 times. 73 | q = Queue() 74 | flush_interval = 0.5 75 | flush_at = 10 76 | consumer = Consumer( 77 | q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval 78 | ) 79 | with mock.patch("posthog.consumer.batch_post") as mock_post: 80 | consumer.start() 81 | for i in range(0, flush_at * 2): 82 | track = { 83 | "type": "track", 84 | "event": "python event %d" % i, 85 | "distinct_id": "distinct_id", 86 | } 87 | q.put(track) 88 | time.sleep(flush_interval * 1.1) 89 | self.assertEqual(mock_post.call_count, 2) 90 | 91 | def test_request(self): 92 | consumer = Consumer(None, TEST_API_KEY) 93 | track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"} 94 | consumer.request([track]) 95 | 96 | def _test_request_retry(self, consumer, expected_exception, exception_count): 97 | def mock_post(*args, **kwargs): 98 | mock_post.call_count += 1 99 | if mock_post.call_count <= exception_count: 100 | raise expected_exception 101 | 102 | mock_post.call_count = 0 103 | 104 | with mock.patch( 105 | "posthog.consumer.batch_post", mock.Mock(side_effect=mock_post) 106 | ): 107 | track = { 108 | "type": "track", 109 | "event": "python event", 110 | "distinct_id": "distinct_id", 111 | } 112 | # request() should succeed if the number of exceptions raised is 113 | # less than the retries paramater. 114 | if exception_count <= consumer.retries: 115 | consumer.request([track]) 116 | else: 117 | # if exceptions are raised more times than the retries 118 | # parameter, we expect the exception to be returned to 119 | # the caller. 120 | try: 121 | consumer.request([track]) 122 | except type(expected_exception) as exc: 123 | self.assertEqual(exc, expected_exception) 124 | else: 125 | self.fail( 126 | "request() should raise an exception if still failing after %d retries" 127 | % consumer.retries 128 | ) 129 | 130 | def test_request_retry(self): 131 | # we should retry on general errors 132 | consumer = Consumer(None, TEST_API_KEY) 133 | self._test_request_retry(consumer, Exception("generic exception"), 2) 134 | 135 | # we should retry on server errors 136 | consumer = Consumer(None, TEST_API_KEY) 137 | self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2) 138 | 139 | # we should retry on HTTP 429 errors 140 | consumer = Consumer(None, TEST_API_KEY) 141 | self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2) 142 | 143 | # we should NOT retry on other client errors 144 | consumer = Consumer(None, TEST_API_KEY) 145 | api_error = APIError(400, "Client Errors") 146 | try: 147 | self._test_request_retry(consumer, api_error, 1) 148 | except APIError: 149 | pass 150 | else: 151 | self.fail("request() should not retry on client errors") 152 | 153 | # test for number of exceptions raise > retries value 154 | consumer = Consumer(None, TEST_API_KEY, retries=3) 155 | self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3) 156 | 157 | def test_pause(self): 158 | consumer = Consumer(None, TEST_API_KEY) 159 | consumer.pause() 160 | self.assertFalse(consumer.running) 161 | 162 | def test_max_batch_size(self): 163 | q = Queue() 164 | consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3) 165 | properties = {} 166 | for n in range(0, 500): 167 | properties[str(n)] = "one_long_property_value_to_build_a_big_event" 168 | track = { 169 | "type": "track", 170 | "event": "python event", 171 | "distinct_id": "distinct_id", 172 | "properties": properties, 173 | } 174 | msg_size = len(json.dumps(track).encode()) 175 | # Let's capture 8MB of data to trigger two batches 176 | n_msgs = int(8_000_000 / msg_size) 177 | 178 | def mock_post_fn(_, data, **kwargs): 179 | res = mock.Mock() 180 | res.status_code = 200 181 | request_size = len(data.encode()) 182 | # Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin 183 | self.assertTrue( 184 | request_size < (5 * 1024 * 1024) * 1.1, 185 | "batch size (%d) higher than limit" % request_size, 186 | ) 187 | return res 188 | 189 | with mock.patch( 190 | "posthog.request._session.post", side_effect=mock_post_fn 191 | ) as mock_post: 192 | consumer.start() 193 | for _ in range(0, n_msgs + 2): 194 | q.put(track) 195 | q.join() 196 | self.assertEqual(mock_post.call_count, 2) 197 | -------------------------------------------------------------------------------- /posthog/test/test_types.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from parameterized import parameterized 4 | 5 | from posthog.types import ( 6 | FeatureFlag, 7 | FlagMetadata, 8 | FlagReason, 9 | LegacyFlagMetadata, 10 | normalize_flags_response, 11 | to_flags_and_payloads, 12 | ) 13 | 14 | 15 | class TestTypes(unittest.TestCase): 16 | @parameterized.expand([(True,), (False,)]) 17 | def test_normalize_decide_response_v4(self, has_errors: bool): 18 | resp = { 19 | "flags": { 20 | "my-flag": FeatureFlag( 21 | key="my-flag", 22 | enabled=True, 23 | variant="test-variant", 24 | reason=FlagReason( 25 | code="matched_condition", 26 | condition_index=0, 27 | description="Matched condition set 1", 28 | ), 29 | metadata=FlagMetadata( 30 | id=1, 31 | payload='{"some": "json"}', 32 | version=2, 33 | description="test-description", 34 | ), 35 | ) 36 | }, 37 | "errorsWhileComputingFlags": has_errors, 38 | "requestId": "test-id", 39 | } 40 | 41 | result = normalize_flags_response(resp) 42 | 43 | flag = result["flags"]["my-flag"] 44 | self.assertEqual(flag.key, "my-flag") 45 | self.assertTrue(flag.enabled) 46 | self.assertEqual(flag.variant, "test-variant") 47 | self.assertEqual(flag.get_value(), "test-variant") 48 | self.assertEqual( 49 | flag.reason, 50 | FlagReason( 51 | code="matched_condition", 52 | condition_index=0, 53 | description="Matched condition set 1", 54 | ), 55 | ) 56 | self.assertEqual( 57 | flag.metadata, 58 | FlagMetadata( 59 | id=1, 60 | payload='{"some": "json"}', 61 | version=2, 62 | description="test-description", 63 | ), 64 | ) 65 | self.assertEqual(result["errorsWhileComputingFlags"], has_errors) 66 | self.assertEqual(result["requestId"], "test-id") 67 | 68 | def test_normalize_decide_response_legacy(self): 69 | # Test legacy response format with "featureFlags" and "featureFlagPayloads" 70 | resp = { 71 | "featureFlags": {"my-flag": "test-variant"}, 72 | "featureFlagPayloads": {"my-flag": '{"some": "json-payload"}'}, 73 | "errorsWhileComputingFlags": False, 74 | "requestId": "test-id", 75 | } 76 | 77 | result = normalize_flags_response(resp) 78 | 79 | flag = result["flags"]["my-flag"] 80 | self.assertEqual(flag.key, "my-flag") 81 | self.assertTrue(flag.enabled) 82 | self.assertEqual(flag.variant, "test-variant") 83 | self.assertEqual(flag.get_value(), "test-variant") 84 | self.assertIsNone(flag.reason) 85 | self.assertEqual( 86 | flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}') 87 | ) 88 | self.assertFalse(result["errorsWhileComputingFlags"]) 89 | self.assertEqual(result["requestId"], "test-id") 90 | # Verify legacy fields are removed 91 | self.assertNotIn("featureFlags", result) 92 | self.assertNotIn("featureFlagPayloads", result) 93 | 94 | def test_normalize_decide_response_boolean_flag(self): 95 | # Test legacy response with boolean flag 96 | resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False} 97 | 98 | result = normalize_flags_response(resp) 99 | 100 | self.assertIn("requestId", result) 101 | self.assertIsNone(result["requestId"]) 102 | 103 | flag = result["flags"]["my-flag"] 104 | self.assertEqual(flag.key, "my-flag") 105 | self.assertTrue(flag.enabled) 106 | self.assertIsNone(flag.variant) 107 | self.assertIsNone(flag.reason) 108 | self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None)) 109 | self.assertFalse(result["errorsWhileComputingFlags"]) 110 | self.assertNotIn("featureFlags", result) 111 | self.assertNotIn("featureFlagPayloads", result) 112 | 113 | def test_to_flags_and_payloads_v4(self): 114 | # Test v4 response format 115 | resp = { 116 | "flags": { 117 | "my-variant-flag": FeatureFlag( 118 | key="my-variant-flag", 119 | enabled=True, 120 | variant="test-variant", 121 | reason=FlagReason( 122 | code="matched_condition", 123 | condition_index=0, 124 | description="Matched condition set 1", 125 | ), 126 | metadata=FlagMetadata( 127 | id=1, 128 | payload='{"some": "json"}', 129 | version=2, 130 | description="test-description", 131 | ), 132 | ), 133 | "my-boolean-flag": FeatureFlag( 134 | key="my-boolean-flag", 135 | enabled=True, 136 | variant=None, 137 | reason=FlagReason( 138 | code="matched_condition", 139 | condition_index=0, 140 | description="Matched condition set 1", 141 | ), 142 | metadata=FlagMetadata( 143 | id=1, payload=None, version=2, description="test-description" 144 | ), 145 | ), 146 | "disabled-flag": FeatureFlag( 147 | key="disabled-flag", 148 | enabled=False, 149 | variant=None, 150 | reason=None, 151 | metadata=LegacyFlagMetadata(payload=None), 152 | ), 153 | }, 154 | "errorsWhileComputingFlags": False, 155 | "requestId": "test-id", 156 | } 157 | 158 | result = to_flags_and_payloads(resp) 159 | 160 | self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant") 161 | self.assertEqual(result["featureFlags"]["my-boolean-flag"], True) 162 | self.assertEqual(result["featureFlags"]["disabled-flag"], False) 163 | self.assertEqual( 164 | result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}' 165 | ) 166 | self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"]) 167 | self.assertNotIn("disabled-flag", result["featureFlagPayloads"]) 168 | 169 | def test_to_flags_and_payloads_empty(self): 170 | # Test empty response 171 | resp = { 172 | "flags": {}, 173 | "errorsWhileComputingFlags": False, 174 | "requestId": "test-id", 175 | } 176 | 177 | result = to_flags_and_payloads(resp) 178 | 179 | self.assertEqual(result["featureFlags"], {}) 180 | self.assertEqual(result["featureFlagPayloads"], {}) 181 | 182 | def test_to_flags_and_payloads_with_payload(self): 183 | resp = { 184 | "flags": { 185 | "decide-flag": { 186 | "key": "decide-flag", 187 | "enabled": True, 188 | "variant": "decide-variant", 189 | "reason": { 190 | "code": "matched_condition", 191 | "condition_index": 0, 192 | "description": "Matched condition set 1", 193 | }, 194 | "metadata": { 195 | "id": 23, 196 | "version": 42, 197 | "payload": '{"foo": "bar"}', 198 | }, 199 | } 200 | }, 201 | "requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371", 202 | } 203 | 204 | normalized = normalize_flags_response(resp) 205 | result = to_flags_and_payloads(normalized) 206 | 207 | self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant") 208 | self.assertEqual(result["featureFlagPayloads"]["decide-flag"], '{"foo": "bar"}') 209 | -------------------------------------------------------------------------------- /posthog/test/test_before_send.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import mock 4 | 5 | from posthog.client import Client 6 | from posthog.test.test_utils import FAKE_TEST_API_KEY 7 | 8 | 9 | class TestClient(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | # This ensures no real HTTP POST requests are made 13 | cls.client_post_patcher = mock.patch("posthog.client.batch_post") 14 | cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post") 15 | cls.client_post_patcher.start() 16 | cls.consumer_post_patcher.start() 17 | 18 | @classmethod 19 | def tearDownClass(cls): 20 | cls.client_post_patcher.stop() 21 | cls.consumer_post_patcher.stop() 22 | 23 | def set_fail(self, e, batch): 24 | """Mark the failure handler""" 25 | print("FAIL", e, batch) # noqa: T201 26 | self.failed = True 27 | 28 | def setUp(self): 29 | self.failed = False 30 | self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail) 31 | 32 | def test_before_send_callback_modifies_event(self): 33 | """Test that before_send callback can modify events.""" 34 | processed_events = [] 35 | 36 | def my_before_send(event): 37 | processed_events.append(event.copy()) 38 | if "properties" not in event: 39 | event["properties"] = {} 40 | event["properties"]["processed_by_before_send"] = True 41 | return event 42 | 43 | with mock.patch("posthog.client.batch_post") as mock_post: 44 | client = Client( 45 | FAKE_TEST_API_KEY, 46 | on_error=self.set_fail, 47 | before_send=my_before_send, 48 | sync_mode=True, 49 | ) 50 | msg_uuid = client.capture( 51 | "test_event", distinct_id="user1", properties={"original": "value"} 52 | ) 53 | 54 | self.assertIsNotNone(msg_uuid) 55 | 56 | # Get the enqueued message from the mock 57 | mock_post.assert_called_once() 58 | batch_data = mock_post.call_args[1]["batch"] 59 | enqueued_msg = batch_data[0] 60 | 61 | self.assertEqual( 62 | enqueued_msg["properties"]["processed_by_before_send"], True 63 | ) 64 | self.assertEqual(enqueued_msg["properties"]["original"], "value") 65 | self.assertEqual(len(processed_events), 1) 66 | self.assertEqual(processed_events[0]["event"], "test_event") 67 | 68 | def test_before_send_callback_drops_event(self): 69 | """Test that before_send callback can drop events by returning None.""" 70 | 71 | def drop_test_events(event): 72 | if event.get("event") == "test_drop_me": 73 | return None 74 | return event 75 | 76 | with mock.patch("posthog.client.batch_post") as mock_post: 77 | client = Client( 78 | FAKE_TEST_API_KEY, 79 | on_error=self.set_fail, 80 | before_send=drop_test_events, 81 | sync_mode=True, 82 | ) 83 | 84 | # Event should be dropped 85 | msg_uuid = client.capture("test_drop_me", distinct_id="user1") 86 | self.assertIsNone(msg_uuid) 87 | 88 | # Event should go through 89 | msg_uuid = client.capture("keep_me", distinct_id="user1") 90 | self.assertIsNotNone(msg_uuid) 91 | 92 | # Check the enqueued message 93 | mock_post.assert_called_once() 94 | batch_data = mock_post.call_args[1]["batch"] 95 | enqueued_msg = batch_data[0] 96 | self.assertEqual(enqueued_msg["event"], "keep_me") 97 | 98 | def test_before_send_callback_handles_exceptions(self): 99 | """Test that exceptions in before_send don't crash the client.""" 100 | 101 | def buggy_before_send(event): 102 | raise ValueError("Oops!") 103 | 104 | with mock.patch("posthog.client.batch_post") as mock_post: 105 | client = Client( 106 | FAKE_TEST_API_KEY, 107 | on_error=self.set_fail, 108 | before_send=buggy_before_send, 109 | sync_mode=True, 110 | ) 111 | msg_uuid = client.capture("robust_event", distinct_id="user1") 112 | 113 | # Event should still be sent despite the exception 114 | self.assertIsNotNone(msg_uuid) 115 | 116 | # Check the enqueued message 117 | mock_post.assert_called_once() 118 | batch_data = mock_post.call_args[1]["batch"] 119 | enqueued_msg = batch_data[0] 120 | self.assertEqual(enqueued_msg["event"], "robust_event") 121 | 122 | def test_before_send_callback_works_with_all_event_types(self): 123 | """Test that before_send works with capture, set, etc.""" 124 | 125 | def add_marker(event): 126 | if "properties" not in event: 127 | event["properties"] = {} 128 | event["properties"]["marked"] = True 129 | return event 130 | 131 | with mock.patch("posthog.client.batch_post") as mock_post: 132 | client = Client( 133 | FAKE_TEST_API_KEY, 134 | on_error=self.set_fail, 135 | before_send=add_marker, 136 | sync_mode=True, 137 | ) 138 | 139 | # Test capture 140 | msg_uuid = client.capture("event", distinct_id="user1") 141 | self.assertIsNotNone(msg_uuid) 142 | 143 | # Test set 144 | msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"}) 145 | self.assertIsNotNone(msg_uuid) 146 | 147 | # Check all events were marked 148 | self.assertEqual(mock_post.call_count, 2) 149 | for call in mock_post.call_args_list: 150 | batch_data = call[1]["batch"] 151 | enqueued_msg = batch_data[0] 152 | self.assertTrue(enqueued_msg["properties"]["marked"]) 153 | 154 | def test_before_send_callback_disabled_when_none(self): 155 | """Test that client works normally when before_send is None.""" 156 | with mock.patch("posthog.client.batch_post") as mock_post: 157 | client = Client( 158 | FAKE_TEST_API_KEY, 159 | on_error=self.set_fail, 160 | before_send=None, 161 | sync_mode=True, 162 | ) 163 | msg_uuid = client.capture("normal_event", distinct_id="user1") 164 | self.assertIsNotNone(msg_uuid) 165 | 166 | # Check the event was sent normally 167 | mock_post.assert_called_once() 168 | batch_data = mock_post.call_args[1]["batch"] 169 | enqueued_msg = batch_data[0] 170 | self.assertEqual(enqueued_msg["event"], "normal_event") 171 | 172 | def test_before_send_callback_pii_scrubbing_example(self): 173 | """Test a realistic PII scrubbing use case.""" 174 | 175 | def scrub_pii(event): 176 | properties = event.get("properties", {}) 177 | 178 | # Mask email but keep domain 179 | if "email" in properties: 180 | email = properties["email"] 181 | if "@" in email: 182 | domain = email.split("@")[1] 183 | properties["email"] = f"***@{domain}" 184 | else: 185 | properties["email"] = "***" 186 | 187 | # Remove credit card 188 | properties.pop("credit_card", None) 189 | 190 | return event 191 | 192 | with mock.patch("posthog.client.batch_post") as mock_post: 193 | client = Client( 194 | FAKE_TEST_API_KEY, 195 | on_error=self.set_fail, 196 | before_send=scrub_pii, 197 | sync_mode=True, 198 | ) 199 | msg_uuid = client.capture( 200 | "form_submit", 201 | distinct_id="user1", 202 | properties={ 203 | "email": "user@example.com", 204 | "credit_card": "1234-5678-9012-3456", 205 | "form_name": "contact", 206 | }, 207 | ) 208 | 209 | self.assertIsNotNone(msg_uuid) 210 | 211 | # Check the enqueued message was scrubbed 212 | mock_post.assert_called_once() 213 | batch_data = mock_post.call_args[1]["batch"] 214 | enqueued_msg = batch_data[0] 215 | 216 | self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com") 217 | self.assertNotIn("credit_card", enqueued_msg["properties"]) 218 | self.assertEqual(enqueued_msg["properties"]["form_name"], "contact") 219 | -------------------------------------------------------------------------------- /posthog/ai/anthropic/anthropic.py: -------------------------------------------------------------------------------- 1 | try: 2 | import anthropic 3 | from anthropic.resources import Messages 4 | except ImportError: 5 | raise ModuleNotFoundError( 6 | "Please install the Anthropic SDK to use this feature: 'pip install anthropic'" 7 | ) 8 | 9 | import time 10 | import uuid 11 | from typing import Any, Dict, List, Optional 12 | 13 | from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress 14 | from posthog.ai.utils import ( 15 | call_llm_and_track_usage, 16 | merge_usage_stats, 17 | ) 18 | from posthog.ai.anthropic.anthropic_converter import ( 19 | extract_anthropic_usage_from_event, 20 | handle_anthropic_content_block_start, 21 | handle_anthropic_text_delta, 22 | handle_anthropic_tool_delta, 23 | finalize_anthropic_tool_input, 24 | ) 25 | from posthog.ai.sanitization import sanitize_anthropic 26 | from posthog.client import Client as PostHogClient 27 | from posthog import setup 28 | 29 | 30 | class Anthropic(anthropic.Anthropic): 31 | """ 32 | A wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog. 33 | """ 34 | 35 | _ph_client: PostHogClient 36 | 37 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 38 | """ 39 | Args: 40 | posthog_client: PostHog client for tracking usage 41 | **kwargs: Additional arguments passed to the Anthropic client 42 | """ 43 | super().__init__(**kwargs) 44 | self._ph_client = posthog_client or setup() 45 | self.messages = WrappedMessages(self) 46 | 47 | 48 | class WrappedMessages(Messages): 49 | _client: Anthropic 50 | 51 | def create( 52 | self, 53 | posthog_distinct_id: Optional[str] = None, 54 | posthog_trace_id: Optional[str] = None, 55 | posthog_properties: Optional[Dict[str, Any]] = None, 56 | posthog_privacy_mode: bool = False, 57 | posthog_groups: Optional[Dict[str, Any]] = None, 58 | **kwargs: Any, 59 | ): 60 | """ 61 | Create a message using Anthropic's API while tracking usage in PostHog. 62 | 63 | Args: 64 | posthog_distinct_id: Optional ID to associate with the usage event 65 | posthog_trace_id: Optional trace UUID for linking events 66 | posthog_properties: Optional dictionary of extra properties to include in the event 67 | posthog_privacy_mode: Whether to redact sensitive information in tracking 68 | posthog_groups: Optional group analytics properties 69 | **kwargs: Arguments passed to Anthropic's messages.create 70 | """ 71 | 72 | if posthog_trace_id is None: 73 | posthog_trace_id = str(uuid.uuid4()) 74 | 75 | if kwargs.get("stream", False): 76 | return self._create_streaming( 77 | posthog_distinct_id, 78 | posthog_trace_id, 79 | posthog_properties, 80 | posthog_privacy_mode, 81 | posthog_groups, 82 | **kwargs, 83 | ) 84 | 85 | return call_llm_and_track_usage( 86 | posthog_distinct_id, 87 | self._client._ph_client, 88 | "anthropic", 89 | posthog_trace_id, 90 | posthog_properties, 91 | posthog_privacy_mode, 92 | posthog_groups, 93 | self._client.base_url, 94 | super().create, 95 | **kwargs, 96 | ) 97 | 98 | def stream( 99 | self, 100 | posthog_distinct_id: Optional[str] = None, 101 | posthog_trace_id: Optional[str] = None, 102 | posthog_properties: Optional[Dict[str, Any]] = None, 103 | posthog_privacy_mode: bool = False, 104 | posthog_groups: Optional[Dict[str, Any]] = None, 105 | **kwargs: Any, 106 | ): 107 | if posthog_trace_id is None: 108 | posthog_trace_id = str(uuid.uuid4()) 109 | 110 | return self._create_streaming( 111 | posthog_distinct_id, 112 | posthog_trace_id, 113 | posthog_properties, 114 | posthog_privacy_mode, 115 | posthog_groups, 116 | **kwargs, 117 | ) 118 | 119 | def _create_streaming( 120 | self, 121 | posthog_distinct_id: Optional[str], 122 | posthog_trace_id: Optional[str], 123 | posthog_properties: Optional[Dict[str, Any]], 124 | posthog_privacy_mode: bool, 125 | posthog_groups: Optional[Dict[str, Any]], 126 | **kwargs: Any, 127 | ): 128 | start_time = time.time() 129 | usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0) 130 | accumulated_content = "" 131 | content_blocks: List[StreamingContentBlock] = [] 132 | tools_in_progress: Dict[str, ToolInProgress] = {} 133 | current_text_block: Optional[StreamingContentBlock] = None 134 | response = super().create(**kwargs) 135 | 136 | def generator(): 137 | nonlocal usage_stats 138 | nonlocal accumulated_content 139 | nonlocal content_blocks 140 | nonlocal tools_in_progress 141 | nonlocal current_text_block 142 | 143 | try: 144 | for event in response: 145 | # Extract usage stats from event 146 | event_usage = extract_anthropic_usage_from_event(event) 147 | merge_usage_stats(usage_stats, event_usage) 148 | 149 | # Handle content block start events 150 | if hasattr(event, "type") and event.type == "content_block_start": 151 | block, tool = handle_anthropic_content_block_start(event) 152 | 153 | if block: 154 | content_blocks.append(block) 155 | 156 | if block.get("type") == "text": 157 | current_text_block = block 158 | else: 159 | current_text_block = None 160 | 161 | if tool: 162 | tool_id = tool["block"].get("id") 163 | if tool_id: 164 | tools_in_progress[tool_id] = tool 165 | 166 | # Handle text delta events 167 | delta_text = handle_anthropic_text_delta(event, current_text_block) 168 | 169 | if delta_text: 170 | accumulated_content += delta_text 171 | 172 | # Handle tool input delta events 173 | handle_anthropic_tool_delta( 174 | event, content_blocks, tools_in_progress 175 | ) 176 | 177 | # Handle content block stop events 178 | if hasattr(event, "type") and event.type == "content_block_stop": 179 | current_text_block = None 180 | finalize_anthropic_tool_input( 181 | event, content_blocks, tools_in_progress 182 | ) 183 | 184 | yield event 185 | 186 | finally: 187 | end_time = time.time() 188 | latency = end_time - start_time 189 | 190 | self._capture_streaming_event( 191 | posthog_distinct_id, 192 | posthog_trace_id, 193 | posthog_properties, 194 | posthog_privacy_mode, 195 | posthog_groups, 196 | kwargs, 197 | usage_stats, 198 | latency, 199 | content_blocks, 200 | accumulated_content, 201 | ) 202 | 203 | return generator() 204 | 205 | def _capture_streaming_event( 206 | self, 207 | posthog_distinct_id: Optional[str], 208 | posthog_trace_id: Optional[str], 209 | posthog_properties: Optional[Dict[str, Any]], 210 | posthog_privacy_mode: bool, 211 | posthog_groups: Optional[Dict[str, Any]], 212 | kwargs: Dict[str, Any], 213 | usage_stats: TokenUsage, 214 | latency: float, 215 | content_blocks: List[StreamingContentBlock], 216 | accumulated_content: str, 217 | ): 218 | from posthog.ai.types import StreamingEventData 219 | from posthog.ai.anthropic.anthropic_converter import ( 220 | format_anthropic_streaming_input, 221 | format_anthropic_streaming_output_complete, 222 | ) 223 | from posthog.ai.utils import capture_streaming_event 224 | 225 | # Prepare standardized event data 226 | formatted_input = format_anthropic_streaming_input(kwargs) 227 | sanitized_input = sanitize_anthropic(formatted_input) 228 | 229 | event_data = StreamingEventData( 230 | provider="anthropic", 231 | model=kwargs.get("model", "unknown"), 232 | base_url=str(self._client.base_url), 233 | kwargs=kwargs, 234 | formatted_input=sanitized_input, 235 | formatted_output=format_anthropic_streaming_output_complete( 236 | content_blocks, accumulated_content 237 | ), 238 | usage_stats=usage_stats, 239 | latency=latency, 240 | distinct_id=posthog_distinct_id, 241 | trace_id=posthog_trace_id, 242 | properties=posthog_properties, 243 | privacy_mode=posthog_privacy_mode, 244 | groups=posthog_groups, 245 | ) 246 | 247 | # Use the common capture function 248 | capture_streaming_event(self._client._ph_client, event_data) 249 | -------------------------------------------------------------------------------- /posthog/ai/anthropic/anthropic_async.py: -------------------------------------------------------------------------------- 1 | try: 2 | import anthropic 3 | from anthropic.resources import AsyncMessages 4 | except ImportError: 5 | raise ModuleNotFoundError( 6 | "Please install the Anthropic SDK to use this feature: 'pip install anthropic'" 7 | ) 8 | 9 | import time 10 | import uuid 11 | from typing import Any, Dict, List, Optional 12 | 13 | from posthog import setup 14 | from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress 15 | from posthog.ai.utils import ( 16 | call_llm_and_track_usage_async, 17 | merge_usage_stats, 18 | ) 19 | from posthog.ai.anthropic.anthropic_converter import ( 20 | extract_anthropic_usage_from_event, 21 | handle_anthropic_content_block_start, 22 | handle_anthropic_text_delta, 23 | handle_anthropic_tool_delta, 24 | finalize_anthropic_tool_input, 25 | ) 26 | from posthog.ai.sanitization import sanitize_anthropic 27 | from posthog.client import Client as PostHogClient 28 | 29 | 30 | class AsyncAnthropic(anthropic.AsyncAnthropic): 31 | """ 32 | An async wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog. 33 | """ 34 | 35 | _ph_client: PostHogClient 36 | 37 | def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): 38 | """ 39 | Args: 40 | posthog_client: PostHog client for tracking usage 41 | **kwargs: Additional arguments passed to the Anthropic client 42 | """ 43 | super().__init__(**kwargs) 44 | self._ph_client = posthog_client or setup() 45 | self.messages = AsyncWrappedMessages(self) 46 | 47 | 48 | class AsyncWrappedMessages(AsyncMessages): 49 | _client: AsyncAnthropic 50 | 51 | async def create( 52 | self, 53 | posthog_distinct_id: Optional[str] = None, 54 | posthog_trace_id: Optional[str] = None, 55 | posthog_properties: Optional[Dict[str, Any]] = None, 56 | posthog_privacy_mode: bool = False, 57 | posthog_groups: Optional[Dict[str, Any]] = None, 58 | **kwargs: Any, 59 | ): 60 | """ 61 | Create a message using Anthropic's API while tracking usage in PostHog. 62 | 63 | Args: 64 | posthog_distinct_id: Optional ID to associate with the usage event 65 | posthog_trace_id: Optional trace UUID for linking events 66 | posthog_properties: Optional dictionary of extra properties to include in the event 67 | posthog_privacy_mode: Whether to redact sensitive information in tracking 68 | posthog_groups: Optional group analytics properties 69 | **kwargs: Arguments passed to Anthropic's messages.create 70 | """ 71 | 72 | if posthog_trace_id is None: 73 | posthog_trace_id = str(uuid.uuid4()) 74 | 75 | if kwargs.get("stream", False): 76 | return await self._create_streaming( 77 | posthog_distinct_id, 78 | posthog_trace_id, 79 | posthog_properties, 80 | posthog_privacy_mode, 81 | posthog_groups, 82 | **kwargs, 83 | ) 84 | 85 | return await call_llm_and_track_usage_async( 86 | posthog_distinct_id, 87 | self._client._ph_client, 88 | "anthropic", 89 | posthog_trace_id, 90 | posthog_properties, 91 | posthog_privacy_mode, 92 | posthog_groups, 93 | self._client.base_url, 94 | super().create, 95 | **kwargs, 96 | ) 97 | 98 | async def stream( 99 | self, 100 | posthog_distinct_id: Optional[str] = None, 101 | posthog_trace_id: Optional[str] = None, 102 | posthog_properties: Optional[Dict[str, Any]] = None, 103 | posthog_privacy_mode: bool = False, 104 | posthog_groups: Optional[Dict[str, Any]] = None, 105 | **kwargs: Any, 106 | ): 107 | if posthog_trace_id is None: 108 | posthog_trace_id = str(uuid.uuid4()) 109 | 110 | return await self._create_streaming( 111 | posthog_distinct_id, 112 | posthog_trace_id, 113 | posthog_properties, 114 | posthog_privacy_mode, 115 | posthog_groups, 116 | **kwargs, 117 | ) 118 | 119 | async def _create_streaming( 120 | self, 121 | posthog_distinct_id: Optional[str], 122 | posthog_trace_id: Optional[str], 123 | posthog_properties: Optional[Dict[str, Any]], 124 | posthog_privacy_mode: bool, 125 | posthog_groups: Optional[Dict[str, Any]], 126 | **kwargs: Any, 127 | ): 128 | start_time = time.time() 129 | usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0) 130 | accumulated_content = "" 131 | content_blocks: List[StreamingContentBlock] = [] 132 | tools_in_progress: Dict[str, ToolInProgress] = {} 133 | current_text_block: Optional[StreamingContentBlock] = None 134 | response = await super().create(**kwargs) 135 | 136 | async def generator(): 137 | nonlocal usage_stats 138 | nonlocal accumulated_content 139 | nonlocal content_blocks 140 | nonlocal tools_in_progress 141 | nonlocal current_text_block 142 | 143 | try: 144 | async for event in response: 145 | # Extract usage stats from event 146 | event_usage = extract_anthropic_usage_from_event(event) 147 | merge_usage_stats(usage_stats, event_usage) 148 | 149 | # Handle content block start events 150 | if hasattr(event, "type") and event.type == "content_block_start": 151 | block, tool = handle_anthropic_content_block_start(event) 152 | 153 | if block: 154 | content_blocks.append(block) 155 | 156 | if block.get("type") == "text": 157 | current_text_block = block 158 | else: 159 | current_text_block = None 160 | 161 | if tool: 162 | tool_id = tool["block"].get("id") 163 | if tool_id: 164 | tools_in_progress[tool_id] = tool 165 | 166 | # Handle text delta events 167 | delta_text = handle_anthropic_text_delta(event, current_text_block) 168 | 169 | if delta_text: 170 | accumulated_content += delta_text 171 | 172 | # Handle tool input delta events 173 | handle_anthropic_tool_delta( 174 | event, content_blocks, tools_in_progress 175 | ) 176 | 177 | # Handle content block stop events 178 | if hasattr(event, "type") and event.type == "content_block_stop": 179 | current_text_block = None 180 | finalize_anthropic_tool_input( 181 | event, content_blocks, tools_in_progress 182 | ) 183 | 184 | yield event 185 | 186 | finally: 187 | end_time = time.time() 188 | latency = end_time - start_time 189 | 190 | await self._capture_streaming_event( 191 | posthog_distinct_id, 192 | posthog_trace_id, 193 | posthog_properties, 194 | posthog_privacy_mode, 195 | posthog_groups, 196 | kwargs, 197 | usage_stats, 198 | latency, 199 | content_blocks, 200 | accumulated_content, 201 | ) 202 | 203 | return generator() 204 | 205 | async def _capture_streaming_event( 206 | self, 207 | posthog_distinct_id: Optional[str], 208 | posthog_trace_id: Optional[str], 209 | posthog_properties: Optional[Dict[str, Any]], 210 | posthog_privacy_mode: bool, 211 | posthog_groups: Optional[Dict[str, Any]], 212 | kwargs: Dict[str, Any], 213 | usage_stats: TokenUsage, 214 | latency: float, 215 | content_blocks: List[StreamingContentBlock], 216 | accumulated_content: str, 217 | ): 218 | from posthog.ai.types import StreamingEventData 219 | from posthog.ai.anthropic.anthropic_converter import ( 220 | format_anthropic_streaming_input, 221 | format_anthropic_streaming_output_complete, 222 | ) 223 | from posthog.ai.utils import capture_streaming_event 224 | 225 | # Prepare standardized event data 226 | formatted_input = format_anthropic_streaming_input(kwargs) 227 | sanitized_input = sanitize_anthropic(formatted_input) 228 | 229 | event_data = StreamingEventData( 230 | provider="anthropic", 231 | model=kwargs.get("model", "unknown"), 232 | base_url=str(self._client.base_url), 233 | kwargs=kwargs, 234 | formatted_input=sanitized_input, 235 | formatted_output=format_anthropic_streaming_output_complete( 236 | content_blocks, accumulated_content 237 | ), 238 | usage_stats=usage_stats, 239 | latency=latency, 240 | distinct_id=posthog_distinct_id, 241 | trace_id=posthog_trace_id, 242 | properties=posthog_properties, 243 | privacy_mode=posthog_privacy_mode, 244 | groups=posthog_groups, 245 | ) 246 | 247 | # Use the common capture function 248 | capture_streaming_event(self._client._ph_client, event_data) 249 | -------------------------------------------------------------------------------- /posthog/test/test_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import unittest 4 | from dataclasses import dataclass 5 | from datetime import date, datetime, timedelta 6 | from decimal import Decimal 7 | from typing import Optional 8 | from uuid import UUID 9 | 10 | import six 11 | from dateutil.tz import tzutc 12 | from parameterized import parameterized 13 | from pydantic import BaseModel 14 | from pydantic.v1 import BaseModel as BaseModelV1 15 | 16 | from posthog import utils 17 | from posthog.types import FeatureFlagResult 18 | 19 | TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4" 20 | FAKE_TEST_API_KEY = "random_key" 21 | 22 | 23 | class TestUtils(unittest.TestCase): 24 | @parameterized.expand( 25 | [ 26 | ("naive datetime should be naive", True), 27 | ("timezone-aware datetime should not be naive", False), 28 | ] 29 | ) 30 | def test_is_naive(self, _name: str, expected_naive: bool): 31 | if expected_naive: 32 | dt = datetime.now() # naive datetime 33 | else: 34 | dt = datetime.now(tz=tzutc()) # timezone-aware datetime 35 | 36 | assert utils.is_naive(dt) is expected_naive 37 | 38 | def test_timezone_utils(self): 39 | now = datetime.now() 40 | utcnow = datetime.now(tz=tzutc()) 41 | 42 | fixed = utils.guess_timezone(now) 43 | assert utils.is_naive(fixed) is False 44 | 45 | shouldnt_be_edited = utils.guess_timezone(utcnow) 46 | assert utcnow == shouldnt_be_edited 47 | 48 | def test_clean(self): 49 | simple = { 50 | "decimal": Decimal("0.142857"), 51 | "unicode": six.u("woo"), 52 | "date": datetime.now(), 53 | "long": 200000000, 54 | "integer": 1, 55 | "float": 2.0, 56 | "bool": True, 57 | "str": "woo", 58 | "none": None, 59 | } 60 | 61 | complicated = { 62 | "exception": Exception("This should show up"), 63 | "timedelta": timedelta(microseconds=20), 64 | "list": [1, 2, 3], 65 | } 66 | 67 | combined = dict(simple.items()) 68 | combined.update(complicated.items()) 69 | 70 | pre_clean_keys = combined.keys() 71 | 72 | utils.clean(combined) 73 | assert combined.keys() == pre_clean_keys 74 | 75 | # test UUID separately, as the UUID object doesn't equal its string representation according to Python 76 | assert ( 77 | utils.clean(UUID("12345678123456781234567812345678")) 78 | == "12345678-1234-5678-1234-567812345678" 79 | ) 80 | 81 | def test_clean_with_dates(self): 82 | dict_with_dates = { 83 | "birthdate": date(1980, 1, 1), 84 | "registration": datetime.now(tz=tzutc()), 85 | } 86 | assert dict_with_dates == utils.clean(dict_with_dates) 87 | 88 | def test_bytes(self): 89 | item = bytes(10) 90 | utils.clean(item) 91 | assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" 92 | 93 | def test_clean_fn(self): 94 | cleaned = utils.clean({"fn": lambda x: x, "number": 4}) 95 | assert cleaned == {"fn": None, "number": 4} 96 | 97 | @parameterized.expand( 98 | [ 99 | ("http://posthog.io/", "http://posthog.io"), 100 | ("http://posthog.io", "http://posthog.io"), 101 | ("https://example.com/path/", "https://example.com/path"), 102 | ("https://example.com/path", "https://example.com/path"), 103 | ] 104 | ) 105 | def test_remove_slash(self, input_url, expected_url): 106 | assert expected_url == utils.remove_trailing_slash(input_url) 107 | 108 | def test_clean_pydantic(self): 109 | class ModelV2(BaseModel): 110 | foo: str 111 | bar: int 112 | baz: Optional[str] = None 113 | 114 | class ModelV1(BaseModelV1): 115 | foo: int 116 | bar: str 117 | 118 | class NestedModel(BaseModel): 119 | foo: ModelV2 120 | 121 | assert utils.clean(ModelV2(foo="1", bar=2)) == { 122 | "foo": "1", 123 | "bar": 2, 124 | "baz": None, 125 | } 126 | # Pydantic V1 is not compatible with Python 3.14+ 127 | if sys.version_info < (3, 14): 128 | assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"} 129 | assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == { 130 | "foo": {"foo": "1", "bar": 2, "baz": "3"} 131 | } 132 | 133 | def test_clean_pydantic_like_class(self) -> None: 134 | class Dummy: 135 | def model_dump(self, required_param: str) -> dict: 136 | return {} 137 | 138 | # previously python 2 code would cause an error while cleaning, 139 | # and this entire object would be None, and we would log an error 140 | # let's allow ourselves to clean `Dummy` as None, 141 | # without blatting the `test` key 142 | assert utils.clean({"test": Dummy()}) == {"test": None} 143 | 144 | def test_clean_dataclass(self): 145 | @dataclass 146 | class InnerDataClass: 147 | inner_foo: str 148 | inner_bar: int 149 | inner_uuid: UUID 150 | inner_date: datetime 151 | inner_optional: Optional[str] = None 152 | 153 | @dataclass 154 | class TestDataClass: 155 | foo: str 156 | bar: int 157 | nested: InnerDataClass 158 | 159 | assert utils.clean( 160 | TestDataClass( 161 | foo="1", 162 | bar=2, 163 | nested=InnerDataClass( 164 | inner_foo="3", 165 | inner_bar=4, 166 | inner_uuid=UUID("12345678123456781234567812345678"), 167 | inner_date=datetime(2025, 1, 1), 168 | ), 169 | ) 170 | ) == { 171 | "foo": "1", 172 | "bar": 2, 173 | "nested": { 174 | "inner_foo": "3", 175 | "inner_bar": 4, 176 | "inner_uuid": "12345678-1234-5678-1234-567812345678", 177 | "inner_date": datetime(2025, 1, 1), 178 | "inner_optional": None, 179 | }, 180 | } 181 | 182 | 183 | class TestFlagCache(unittest.TestCase): 184 | def setUp(self): 185 | self.cache = utils.FlagCache(max_size=3, default_ttl=1) 186 | self.flag_result = FeatureFlagResult.from_value_and_payload( 187 | "test-flag", True, None 188 | ) 189 | 190 | def test_cache_basic_operations(self): 191 | distinct_id = "user123" 192 | flag_key = "test-flag" 193 | flag_version = 1 194 | 195 | # Test cache miss 196 | result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version) 197 | assert result is None 198 | 199 | # Test cache set and hit 200 | self.cache.set_cached_flag( 201 | distinct_id, flag_key, self.flag_result, flag_version 202 | ) 203 | result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version) 204 | assert result is not None 205 | assert result.get_value() 206 | 207 | def test_cache_ttl_expiration(self): 208 | distinct_id = "user123" 209 | flag_key = "test-flag" 210 | flag_version = 1 211 | 212 | # Set flag in cache 213 | self.cache.set_cached_flag( 214 | distinct_id, flag_key, self.flag_result, flag_version 215 | ) 216 | 217 | # Should be available immediately 218 | result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version) 219 | assert result is not None 220 | 221 | # Wait for TTL to expire (1 second + buffer) 222 | time.sleep(1.1) 223 | 224 | # Should be expired 225 | result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version) 226 | assert result is None 227 | 228 | def test_cache_version_invalidation(self): 229 | distinct_id = "user123" 230 | flag_key = "test-flag" 231 | old_version = 1 232 | new_version = 2 233 | 234 | # Set flag with old version 235 | self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version) 236 | 237 | # Should hit with old version 238 | result = self.cache.get_cached_flag(distinct_id, flag_key, old_version) 239 | assert result is not None 240 | 241 | # Should miss with new version 242 | result = self.cache.get_cached_flag(distinct_id, flag_key, new_version) 243 | assert result is None 244 | 245 | # Invalidate old version 246 | self.cache.invalidate_version(old_version) 247 | 248 | # Should miss even with old version after invalidation 249 | result = self.cache.get_cached_flag(distinct_id, flag_key, old_version) 250 | assert result is None 251 | 252 | def test_stale_cache_functionality(self): 253 | distinct_id = "user123" 254 | flag_key = "test-flag" 255 | flag_version = 1 256 | 257 | # Set flag in cache 258 | self.cache.set_cached_flag( 259 | distinct_id, flag_key, self.flag_result, flag_version 260 | ) 261 | 262 | # Wait for TTL to expire 263 | time.sleep(1.1) 264 | 265 | # Should not get fresh cache 266 | result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version) 267 | assert result is None 268 | 269 | # Should get stale cache (within 1 hour default) 270 | stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key) 271 | assert stale_result is not None 272 | assert stale_result.get_value() 273 | 274 | def test_lru_eviction(self): 275 | # Cache has max_size=3, so adding 4 users should evict the LRU one 276 | flag_version = 1 277 | 278 | # Add 3 users 279 | for i in range(3): 280 | user_id = f"user{i}" 281 | self.cache.set_cached_flag( 282 | user_id, "test-flag", self.flag_result, flag_version 283 | ) 284 | 285 | # Access user0 to make it recently used 286 | self.cache.get_cached_flag("user0", "test-flag", flag_version) 287 | 288 | # Add 4th user, should evict user1 (least recently used) 289 | self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version) 290 | 291 | # user0 should still be there (was recently accessed) 292 | result = self.cache.get_cached_flag("user0", "test-flag", flag_version) 293 | assert result is not None 294 | 295 | # user2 should still be there (was recently added) 296 | result = self.cache.get_cached_flag("user2", "test-flag", flag_version) 297 | assert result is not None 298 | 299 | # user3 should be there (just added) 300 | result = self.cache.get_cached_flag("user3", "test-flag", flag_version) 301 | assert result is not None 302 | -------------------------------------------------------------------------------- /posthog/types.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Any, Callable, List, Optional, TypedDict, Union, cast 4 | 5 | FlagValue = Union[bool, str] 6 | 7 | # Type alias for the before_send callback function 8 | # Takes an event dictionary and returns the modified event or None to drop it 9 | BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] 10 | 11 | 12 | # Type alias for the send_feature_flags parameter 13 | class SendFeatureFlagsOptions(TypedDict, total=False): 14 | """Options for sending feature flags with capture events. 15 | 16 | Args: 17 | only_evaluate_locally: Whether to only use local evaluation for feature flags. 18 | If True, only flags that can be evaluated locally will be included. 19 | If False, remote evaluation via /flags API will be used when needed. 20 | person_properties: Properties to use for feature flag evaluation specific to this event. 21 | These properties will be merged with any existing person properties. 22 | group_properties: Group properties to use for feature flag evaluation specific to this event. 23 | Format: { group_type_name: { group_properties } } 24 | """ 25 | 26 | should_send: bool 27 | only_evaluate_locally: Optional[bool] 28 | person_properties: Optional[dict[str, Any]] 29 | group_properties: Optional[dict[str, dict[str, Any]]] 30 | flag_keys_filter: Optional[list[str]] 31 | 32 | 33 | @dataclass(frozen=True) 34 | class FlagReason: 35 | code: str 36 | condition_index: Optional[int] 37 | description: str 38 | 39 | @classmethod 40 | def from_json(cls, resp: Any) -> Optional["FlagReason"]: 41 | if not resp: 42 | return None 43 | return cls( 44 | code=resp.get("code", ""), 45 | condition_index=resp.get("condition_index"), 46 | description=resp.get("description", ""), 47 | ) 48 | 49 | 50 | @dataclass(frozen=True) 51 | class LegacyFlagMetadata: 52 | payload: Any 53 | 54 | 55 | @dataclass(frozen=True) 56 | class FlagMetadata: 57 | id: int 58 | payload: Optional[str] 59 | version: int 60 | description: str 61 | 62 | @classmethod 63 | def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: 64 | if not resp: 65 | return LegacyFlagMetadata(payload=None) 66 | return cls( 67 | id=resp.get("id", 0), 68 | payload=resp.get("payload"), 69 | version=resp.get("version", 0), 70 | description=resp.get("description", ""), 71 | ) 72 | 73 | 74 | @dataclass(frozen=True) 75 | class FeatureFlag: 76 | key: str 77 | enabled: bool 78 | variant: Optional[str] 79 | reason: Optional[FlagReason] 80 | metadata: Union[FlagMetadata, LegacyFlagMetadata] 81 | 82 | def get_value(self) -> FlagValue: 83 | return self.variant or self.enabled 84 | 85 | @classmethod 86 | def from_json(cls, resp: Any) -> "FeatureFlag": 87 | reason = None 88 | if resp.get("reason"): 89 | reason = FlagReason.from_json(resp.get("reason")) 90 | 91 | metadata = None 92 | if resp.get("metadata"): 93 | metadata = FlagMetadata.from_json(resp.get("metadata")) 94 | else: 95 | metadata = LegacyFlagMetadata(payload=None) 96 | 97 | return cls( 98 | key=resp.get("key"), 99 | enabled=resp.get("enabled"), 100 | variant=resp.get("variant"), 101 | reason=reason, 102 | metadata=metadata, 103 | ) 104 | 105 | @classmethod 106 | def from_value_and_payload( 107 | cls, key: str, value: FlagValue, payload: Any 108 | ) -> "FeatureFlag": 109 | enabled, variant = (True, value) if isinstance(value, str) else (value, None) 110 | return cls( 111 | key=key, 112 | enabled=enabled, 113 | variant=variant, 114 | reason=None, 115 | metadata=LegacyFlagMetadata( 116 | payload=payload, 117 | ), 118 | ) 119 | 120 | 121 | class FlagsResponse(TypedDict, total=False): 122 | flags: dict[str, FeatureFlag] 123 | errorsWhileComputingFlags: bool 124 | requestId: str 125 | quotaLimit: Optional[List[str]] 126 | evaluatedAt: Optional[int] 127 | 128 | 129 | class FlagsAndPayloads(TypedDict, total=True): 130 | featureFlags: Optional[dict[str, FlagValue]] 131 | featureFlagPayloads: Optional[dict[str, Any]] 132 | 133 | 134 | @dataclass(frozen=True) 135 | class FeatureFlagResult: 136 | """ 137 | The result of calling a feature flag which includes the flag result, variant, and payload. 138 | 139 | Attributes: 140 | key (str): The unique identifier of the feature flag. 141 | enabled (bool): Whether the feature flag is enabled for the current context. 142 | variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise. 143 | payload (Optional[Any]): Additional data associated with the feature flag, if any. 144 | reason (Optional[str]): A description of why the flag was enabled or disabled, if available. 145 | """ 146 | 147 | key: str 148 | enabled: bool 149 | variant: Optional[str] 150 | payload: Optional[Any] 151 | reason: Optional[str] 152 | 153 | def get_value(self) -> FlagValue: 154 | """ 155 | Returns the value of the flag. This is the variant if it exists, otherwise the enabled value. 156 | This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event. 157 | 158 | Returns: 159 | FlagValue: Either a string variant or boolean value representing the flag's state. 160 | """ 161 | return self.variant or self.enabled 162 | 163 | @classmethod 164 | def from_value_and_payload( 165 | cls, key: str, value: Union[FlagValue, None], payload: Any 166 | ) -> Union["FeatureFlagResult", None]: 167 | """ 168 | Creates a FeatureFlagResult from a flag value and payload. 169 | 170 | Args: 171 | key (str): The unique identifier of the feature flag. 172 | value (Union[FlagValue, None]): The value of the flag (string variant or boolean). 173 | payload (Any): Additional data associated with the feature flag. 174 | 175 | Returns: 176 | Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None. 177 | """ 178 | if value is None: 179 | return None 180 | enabled, variant = (True, value) if isinstance(value, str) else (value, None) 181 | return cls( 182 | key=key, 183 | enabled=enabled, 184 | variant=variant, 185 | payload=json.loads(payload) 186 | if isinstance(payload, str) and payload 187 | else payload, 188 | reason=None, 189 | ) 190 | 191 | @classmethod 192 | def from_flag_details( 193 | cls, 194 | details: Union[FeatureFlag, None], 195 | override_match_value: Optional[FlagValue] = None, 196 | ) -> "FeatureFlagResult | None": 197 | """ 198 | Create a FeatureFlagResult from a FeatureFlag object. 199 | 200 | Args: 201 | details (Union[FeatureFlag, None]): The FeatureFlag object to convert. 202 | override_match_value (Optional[FlagValue]): If provided, this value will be used to populate 203 | the enabled and variant fields instead of the values from the FeatureFlag. 204 | 205 | Returns: 206 | FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None. 207 | """ 208 | 209 | if details is None: 210 | return None 211 | 212 | if override_match_value is not None: 213 | enabled, variant = ( 214 | (True, override_match_value) 215 | if isinstance(override_match_value, str) 216 | else (override_match_value, None) 217 | ) 218 | else: 219 | enabled, variant = (details.enabled, details.variant) 220 | 221 | return cls( 222 | key=details.key, 223 | enabled=enabled, 224 | variant=variant, 225 | payload=( 226 | json.loads(details.metadata.payload) 227 | if isinstance(details.metadata.payload, str) 228 | and details.metadata.payload 229 | else details.metadata.payload 230 | ), 231 | reason=details.reason.description if details.reason else None, 232 | ) 233 | 234 | 235 | def normalize_flags_response(resp: Any) -> FlagsResponse: 236 | """ 237 | Normalize the response from the decide or flags API endpoint into a FlagsResponse. 238 | 239 | Args: 240 | resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint. 241 | 242 | Returns: 243 | A FlagsResponse containing feature flags and their details. 244 | """ 245 | if "requestId" not in resp: 246 | resp["requestId"] = None 247 | if "flags" in resp: 248 | flags = resp["flags"] 249 | # For each flag, create a FeatureFlag object 250 | for key, value in flags.items(): 251 | if isinstance(value, FeatureFlag): 252 | continue 253 | value["key"] = key 254 | flags[key] = FeatureFlag.from_json(value) 255 | else: 256 | # Handle legacy format 257 | featureFlags = resp.get("featureFlags", {}) 258 | featureFlagPayloads = resp.get("featureFlagPayloads", {}) 259 | resp.pop("featureFlags", None) 260 | resp.pop("featureFlagPayloads", None) 261 | # look at each key in featureFlags and create a FeatureFlag object 262 | flags = {} 263 | for key, value in featureFlags.items(): 264 | flags[key] = FeatureFlag.from_value_and_payload( 265 | key, value, featureFlagPayloads.get(key, None) 266 | ) 267 | resp["flags"] = flags 268 | return cast(FlagsResponse, resp) 269 | 270 | 271 | def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads: 272 | """ 273 | Convert a FlagsResponse into a FlagsAndPayloads object which is a 274 | dict of feature flags and their payloads. This is needed by certain 275 | functions in the client. 276 | Args: 277 | resp: A FlagsResponse containing feature flags and their payloads. 278 | 279 | Returns: 280 | A tuple containing: 281 | - A dictionary mapping flag keys to their values (bool or str) 282 | - A dictionary mapping flag keys to their payloads 283 | """ 284 | return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)} 285 | 286 | 287 | def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]: 288 | if "flags" not in response: 289 | return None 290 | 291 | flags = response.get("flags", {}) 292 | return { 293 | key: value.get_value() 294 | for key, value in flags.items() 295 | if isinstance(value, FeatureFlag) 296 | } 297 | 298 | 299 | def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]: 300 | if "flags" not in response: 301 | return None 302 | 303 | return { 304 | key: value.metadata.payload 305 | for key, value in response.get("flags", {}).items() 306 | if isinstance(value, FeatureFlag) 307 | and value.enabled 308 | and value.metadata.payload is not None 309 | } 310 | 311 | 312 | class FeatureFlagError: 313 | """Error type constants for the $feature_flag_error property. 314 | 315 | These values are sent in analytics events to track flag evaluation failures. 316 | They should not be changed without considering impact on existing dashboards 317 | and queries that filter on these values. 318 | 319 | Error values: 320 | ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true 321 | FLAG_MISSING: Requested flag not in API response 322 | QUOTA_LIMITED: Rate/quota limit exceeded 323 | TIMEOUT: Request timed out 324 | CONNECTION_ERROR: Network connectivity issue 325 | UNKNOWN_ERROR: Unexpected exceptions 326 | 327 | For API errors with status codes, use the api_error() method which returns 328 | a string like "api_error_500". 329 | """ 330 | 331 | ERRORS_WHILE_COMPUTING = "errors_while_computing_flags" 332 | FLAG_MISSING = "flag_missing" 333 | QUOTA_LIMITED = "quota_limited" 334 | TIMEOUT = "timeout" 335 | CONNECTION_ERROR = "connection_error" 336 | UNKNOWN_ERROR = "unknown_error" 337 | 338 | @staticmethod 339 | def api_error(status: Union[int, str]) -> str: 340 | """Generate API error string with status code. 341 | 342 | Args: 343 | status: HTTP status code from the API error 344 | 345 | Returns: 346 | Error string like "api_error_500" 347 | """ 348 | return f"api_error_{status}" 349 | -------------------------------------------------------------------------------- /posthog/request.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | import socket 5 | from dataclasses import dataclass 6 | from datetime import date, datetime 7 | from gzip import GzipFile 8 | from io import BytesIO 9 | from typing import Any, List, Optional, Tuple, Union 10 | 11 | import requests 12 | from dateutil.tz import tzutc 13 | from requests.adapters import HTTPAdapter # type: ignore[import-untyped] 14 | from urllib3.connection import HTTPConnection 15 | from urllib3.util.retry import Retry 16 | 17 | from posthog.utils import remove_trailing_slash 18 | from posthog.version import VERSION 19 | 20 | SocketOptions = List[Tuple[int, int, Union[int, bytes]]] 21 | 22 | KEEPALIVE_IDLE_SECONDS = 60 23 | KEEPALIVE_INTERVAL_SECONDS = 60 24 | KEEPALIVE_PROBE_COUNT = 3 25 | 26 | # TCP keepalive probes idle connections to prevent them from being dropped. 27 | # SO_KEEPALIVE is cross-platform, but timing options vary: 28 | # - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT 29 | # - macOS: only SO_KEEPALIVE (uses system defaults) 30 | # - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709) 31 | KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list( 32 | HTTPConnection.default_socket_options 33 | ) + [ 34 | (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), 35 | ] 36 | for attr, value in [ 37 | ("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS), 38 | ("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS), 39 | ("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT), 40 | ]: 41 | if hasattr(socket, attr): 42 | KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value)) 43 | 44 | # Status codes that indicate transient server errors worth retrying 45 | RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504] 46 | 47 | 48 | def _mask_tokens_in_url(url: str) -> str: 49 | """Mask token values in URLs for safe logging, keeping first 10 chars visible.""" 50 | return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url) 51 | 52 | 53 | @dataclass 54 | class GetResponse: 55 | """Response from a GET request with ETag support.""" 56 | 57 | data: Any 58 | etag: Optional[str] = None 59 | not_modified: bool = False 60 | 61 | 62 | class HTTPAdapterWithSocketOptions(HTTPAdapter): 63 | """HTTPAdapter with configurable socket options.""" 64 | 65 | def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs): 66 | self.socket_options = socket_options 67 | super().__init__(*args, **kwargs) 68 | 69 | def init_poolmanager(self, *args, **kwargs): 70 | if self.socket_options is not None: 71 | kwargs["socket_options"] = self.socket_options 72 | super().init_poolmanager(*args, **kwargs) 73 | 74 | 75 | def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session: 76 | """Build a session for general requests (batch, decide, etc.).""" 77 | adapter = HTTPAdapterWithSocketOptions( 78 | max_retries=Retry( 79 | total=2, 80 | connect=2, 81 | read=2, 82 | ), 83 | socket_options=socket_options, 84 | ) 85 | session = requests.Session() 86 | session.mount("https://", adapter) 87 | return session 88 | 89 | 90 | def _build_flags_session( 91 | socket_options: Optional[SocketOptions] = None, 92 | ) -> requests.Session: 93 | """ 94 | Build a session for feature flag requests with POST retries. 95 | 96 | Feature flag requests are idempotent (read-only), so retrying POST 97 | requests is safe. This session retries on transient server errors 98 | (408, 5xx) and network failures with exponential backoff 99 | (0.5s, 1s delays between retries). 100 | """ 101 | adapter = HTTPAdapterWithSocketOptions( 102 | max_retries=Retry( 103 | total=2, 104 | connect=2, 105 | read=2, 106 | backoff_factor=0.5, 107 | status_forcelist=RETRY_STATUS_FORCELIST, 108 | allowed_methods=["POST"], 109 | ), 110 | socket_options=socket_options, 111 | ) 112 | session = requests.Session() 113 | session.mount("https://", adapter) 114 | return session 115 | 116 | 117 | _session = _build_session() 118 | _flags_session = _build_flags_session() 119 | _socket_options: Optional[SocketOptions] = None 120 | _pooling_enabled = True 121 | 122 | 123 | def _get_session() -> requests.Session: 124 | if _pooling_enabled: 125 | return _session 126 | return _build_session(_socket_options) 127 | 128 | 129 | def _get_flags_session() -> requests.Session: 130 | if _pooling_enabled: 131 | return _flags_session 132 | return _build_flags_session(_socket_options) 133 | 134 | 135 | def set_socket_options(socket_options: Optional[SocketOptions]) -> None: 136 | """ 137 | Configure socket options for all HTTP connections. 138 | 139 | Example: 140 | from posthog import set_socket_options 141 | set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]) 142 | """ 143 | global _session, _flags_session, _socket_options 144 | if socket_options == _socket_options: 145 | return 146 | _socket_options = socket_options 147 | _session = _build_session(socket_options) 148 | _flags_session = _build_flags_session(socket_options) 149 | 150 | 151 | def enable_keep_alive() -> None: 152 | """Enable TCP keepalive to prevent idle connections from being dropped.""" 153 | set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS) 154 | 155 | 156 | def disable_connection_reuse() -> None: 157 | """Disable connection reuse, creating a fresh connection for each request.""" 158 | global _pooling_enabled 159 | _pooling_enabled = False 160 | 161 | 162 | US_INGESTION_ENDPOINT = "https://us.i.posthog.com" 163 | EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com" 164 | DEFAULT_HOST = US_INGESTION_ENDPOINT 165 | USER_AGENT = "posthog-python/" + VERSION 166 | 167 | 168 | def determine_server_host(host: Optional[str]) -> str: 169 | """Determines the server host to use.""" 170 | host_or_default = host or DEFAULT_HOST 171 | trimmed_host = remove_trailing_slash(host_or_default) 172 | if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"): 173 | return US_INGESTION_ENDPOINT 174 | elif trimmed_host == "https://eu.posthog.com": 175 | return EU_INGESTION_ENDPOINT 176 | else: 177 | return host_or_default 178 | 179 | 180 | def post( 181 | api_key: str, 182 | host: Optional[str] = None, 183 | path=None, 184 | gzip: bool = False, 185 | timeout: int = 15, 186 | session: Optional[requests.Session] = None, 187 | **kwargs, 188 | ) -> requests.Response: 189 | """Post the `kwargs` to the API""" 190 | log = logging.getLogger("posthog") 191 | body = kwargs 192 | body["sentAt"] = datetime.now(tz=tzutc()).isoformat() 193 | url = remove_trailing_slash(host or DEFAULT_HOST) + path 194 | body["api_key"] = api_key 195 | data = json.dumps(body, cls=DatetimeSerializer) 196 | log.debug("making request: %s to url: %s", data, url) 197 | headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} 198 | if gzip: 199 | headers["Content-Encoding"] = "gzip" 200 | buf = BytesIO() 201 | with GzipFile(fileobj=buf, mode="w") as gz: 202 | # 'data' was produced by json.dumps(), 203 | # whose default encoding is utf-8. 204 | gz.write(data.encode("utf-8")) 205 | data = buf.getvalue() 206 | 207 | res = (session or _get_session()).post( 208 | url, data=data, headers=headers, timeout=timeout 209 | ) 210 | 211 | if res.status_code == 200: 212 | log.debug("data uploaded successfully") 213 | 214 | return res 215 | 216 | 217 | def _process_response( 218 | res: requests.Response, success_message: str, *, return_json: bool = True 219 | ) -> Union[requests.Response, Any]: 220 | log = logging.getLogger("posthog") 221 | if res.status_code == 200: 222 | log.debug(success_message) 223 | response = res.json() if return_json else res 224 | # Handle quota limited decide responses by raising a specific error 225 | # NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags 226 | # since most of the other services handle quota limiting in other places in the application. 227 | if ( 228 | isinstance(response, dict) 229 | and "quotaLimited" in response 230 | and isinstance(response["quotaLimited"], list) 231 | and "feature_flags" in response["quotaLimited"] 232 | ): 233 | log.warning( 234 | "[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts" 235 | ) 236 | raise QuotaLimitError(res.status_code, "Feature flags quota limited") 237 | return response 238 | try: 239 | payload = res.json() 240 | log.debug("received response: %s", payload) 241 | raise APIError(res.status_code, payload["detail"]) 242 | except (KeyError, ValueError): 243 | raise APIError(res.status_code, res.text) 244 | 245 | 246 | def decide( 247 | api_key: str, 248 | host: Optional[str] = None, 249 | gzip: bool = False, 250 | timeout: int = 15, 251 | **kwargs, 252 | ) -> Any: 253 | """Post the `kwargs to the decide API endpoint""" 254 | res = post(api_key, host, "/decide/?v=4", gzip, timeout, **kwargs) 255 | return _process_response(res, success_message="Feature flags decided successfully") 256 | 257 | 258 | def flags( 259 | api_key: str, 260 | host: Optional[str] = None, 261 | gzip: bool = False, 262 | timeout: int = 15, 263 | **kwargs, 264 | ) -> Any: 265 | """Post the kwargs to the flags API endpoint with automatic retries.""" 266 | res = post( 267 | api_key, 268 | host, 269 | "/flags/?v=2", 270 | gzip, 271 | timeout, 272 | session=_get_flags_session(), 273 | **kwargs, 274 | ) 275 | return _process_response( 276 | res, success_message="Feature flags evaluated successfully" 277 | ) 278 | 279 | 280 | def remote_config( 281 | personal_api_key: str, 282 | project_api_key: str, 283 | host: Optional[str] = None, 284 | key: str = "", 285 | timeout: int = 15, 286 | ) -> Any: 287 | """Get remote config flag value from remote_config API endpoint""" 288 | response = get( 289 | personal_api_key, 290 | f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}", 291 | host, 292 | timeout, 293 | ) 294 | return response.data 295 | 296 | 297 | def batch_post( 298 | api_key: str, 299 | host: Optional[str] = None, 300 | gzip: bool = False, 301 | timeout: int = 15, 302 | **kwargs, 303 | ) -> requests.Response: 304 | """Post the `kwargs` to the batch API endpoint for events""" 305 | res = post(api_key, host, "/batch/", gzip, timeout, **kwargs) 306 | return _process_response( 307 | res, success_message="data uploaded successfully", return_json=False 308 | ) 309 | 310 | 311 | def get( 312 | api_key: str, 313 | url: str, 314 | host: Optional[str] = None, 315 | timeout: Optional[int] = None, 316 | etag: Optional[str] = None, 317 | ) -> GetResponse: 318 | """ 319 | Make a GET request with optional ETag support. 320 | 321 | If an etag is provided, sends If-None-Match header. Returns GetResponse with: 322 | - not_modified=True and data=None if server returns 304 323 | - not_modified=False and data=response if server returns 200 324 | """ 325 | log = logging.getLogger("posthog") 326 | full_url = remove_trailing_slash(host or DEFAULT_HOST) + url 327 | headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} 328 | 329 | if etag: 330 | headers["If-None-Match"] = etag 331 | 332 | res = _get_session().get(full_url, headers=headers, timeout=timeout) 333 | 334 | masked_url = _mask_tokens_in_url(full_url) 335 | 336 | # Handle 304 Not Modified 337 | if res.status_code == 304: 338 | log.debug(f"GET {masked_url} returned 304 Not Modified") 339 | response_etag = res.headers.get("ETag") 340 | return GetResponse(data=None, etag=response_etag or etag, not_modified=True) 341 | 342 | # Handle normal response 343 | data = _process_response( 344 | res, success_message=f"GET {masked_url} completed successfully" 345 | ) 346 | response_etag = res.headers.get("ETag") 347 | return GetResponse(data=data, etag=response_etag, not_modified=False) 348 | 349 | 350 | class APIError(Exception): 351 | def __init__(self, status: Union[int, str], message: str): 352 | self.message = message 353 | self.status = status 354 | 355 | def __str__(self): 356 | msg = "[PostHog] {0} ({1})" 357 | return msg.format(self.message, self.status) 358 | 359 | 360 | class QuotaLimitError(APIError): 361 | pass 362 | 363 | 364 | # Re-export requests exceptions for use in client.py 365 | # This keeps all requests library imports centralized in this module 366 | RequestsTimeout = requests.exceptions.Timeout 367 | RequestsConnectionError = requests.exceptions.ConnectionError 368 | 369 | 370 | class DatetimeSerializer(json.JSONEncoder): 371 | def default(self, obj: Any): 372 | if isinstance(obj, (date, datetime)): 373 | return obj.isoformat() 374 | 375 | return json.JSONEncoder.default(self, obj) 376 | -------------------------------------------------------------------------------- /posthog/integrations/django.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, cast 2 | from posthog import contexts 3 | from posthog.client import Client 4 | 5 | try: 6 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction 7 | except ImportError: 8 | # Fallback for older Django versions without asgiref 9 | import asyncio 10 | 11 | iscoroutinefunction = asyncio.iscoroutinefunction 12 | 13 | # No-op fallback for markcoroutinefunction 14 | # Older Django versions without asgiref typically don't support async middleware anyway 15 | def markcoroutinefunction(func): 16 | return func 17 | 18 | 19 | if TYPE_CHECKING: 20 | from django.http import HttpRequest, HttpResponse # noqa: F401 21 | from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401 22 | 23 | 24 | class PosthogContextMiddleware: 25 | """Middleware to automatically track Django requests. 26 | 27 | This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers: 28 | - Session ID, (extracted from `X-POSTHOG-SESSION-ID`) 29 | - Distinct ID, (extracted from `X-POSTHOG-DISTINCT-ID`) 30 | - Request URL as $current_url 31 | - Request Method as $request_method 32 | 33 | The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting 34 | `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the 35 | global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance 36 | 37 | The middleware behaviour is customisable through 3 additional functions: 38 | - `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. 39 | - `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked. 40 | - `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. 41 | 42 | 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. 43 | 44 | Context tags are automatically included as properties on all events captured within a context, including exceptions. 45 | See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to 46 | associate all events captured in the middleware context with the same distinct ID and session as currently active on the 47 | frontend. See the documentation for `set_context_session` and `identify_context` for more details. 48 | 49 | This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware 50 | detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly. 51 | This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode. 52 | """ 53 | 54 | sync_capable = True 55 | async_capable = True 56 | 57 | def __init__(self, get_response): 58 | # type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None 59 | self.get_response = get_response 60 | self._is_coroutine = iscoroutinefunction(get_response) 61 | 62 | # Mark this instance as a coroutine function if get_response is async 63 | # This is required for Django to correctly detect async middleware 64 | if self._is_coroutine: 65 | markcoroutinefunction(self) 66 | 67 | from django.conf import settings 68 | 69 | if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable( 70 | settings.POSTHOG_MW_EXTRA_TAGS 71 | ): 72 | self.extra_tags = cast( 73 | "Optional[Callable[[HttpRequest], Dict[str, Any]]]", 74 | settings.POSTHOG_MW_EXTRA_TAGS, 75 | ) 76 | else: 77 | self.extra_tags = None 78 | 79 | if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable( 80 | settings.POSTHOG_MW_REQUEST_FILTER 81 | ): 82 | self.request_filter = cast( 83 | "Optional[Callable[[HttpRequest], bool]]", 84 | settings.POSTHOG_MW_REQUEST_FILTER, 85 | ) 86 | else: 87 | self.request_filter = None 88 | 89 | if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable( 90 | settings.POSTHOG_MW_TAG_MAP 91 | ): 92 | self.tag_map = cast( 93 | "Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]", 94 | settings.POSTHOG_MW_TAG_MAP, 95 | ) 96 | else: 97 | self.tag_map = None 98 | 99 | if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance( 100 | settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool 101 | ): 102 | self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS 103 | else: 104 | self.capture_exceptions = True 105 | 106 | if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance( 107 | settings.POSTHOG_MW_CLIENT, Client 108 | ): 109 | self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT) 110 | else: 111 | self.client = None 112 | 113 | def extract_tags(self, request): 114 | # type: (HttpRequest) -> Dict[str, Any] 115 | """Extract tags from request in sync context.""" 116 | user_id, user_email = self.extract_request_user(request) 117 | return self._build_tags(request, user_id, user_email) 118 | 119 | def _build_tags(self, request, user_id, user_email): 120 | # type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any] 121 | """ 122 | Build tags dict from request and user info. 123 | 124 | Centralized tag extraction logic used by both sync and async paths. 125 | """ 126 | tags = {} 127 | 128 | # Extract session ID from X-POSTHOG-SESSION-ID header 129 | session_id = request.headers.get("X-POSTHOG-SESSION-ID") 130 | if session_id: 131 | contexts.set_context_session(session_id) 132 | 133 | # Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id 134 | distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id 135 | if distinct_id: 136 | contexts.identify_context(distinct_id) 137 | 138 | # Extract user email 139 | if user_email: 140 | tags["email"] = user_email 141 | 142 | # Extract current URL 143 | absolute_url = request.build_absolute_uri() 144 | if absolute_url: 145 | tags["$current_url"] = absolute_url 146 | 147 | # Extract request method 148 | if request.method: 149 | tags["$request_method"] = request.method 150 | 151 | # Extract request path 152 | if request.path: 153 | tags["$request_path"] = request.path 154 | 155 | # Extract IP address 156 | ip_address = request.headers.get("X-Forwarded-For") 157 | if ip_address: 158 | tags["$ip_address"] = ip_address 159 | 160 | # Extract user agent 161 | user_agent = request.headers.get("User-Agent") 162 | if user_agent: 163 | tags["$user_agent"] = user_agent 164 | 165 | # Apply extra tags if configured 166 | if self.extra_tags: 167 | extra = self.extra_tags(request) 168 | if extra: 169 | tags.update(extra) 170 | 171 | # Apply tag mapping if configured 172 | if self.tag_map: 173 | tags = self.tag_map(tags) 174 | 175 | return tags 176 | 177 | def extract_request_user(self, request): 178 | # type: (HttpRequest) -> tuple[Optional[str], Optional[str]] 179 | """Extract user ID and email from request in sync context.""" 180 | user = getattr(request, "user", None) 181 | return self._resolve_user_details(user) 182 | 183 | async def aextract_tags(self, request): 184 | # type: (HttpRequest) -> Dict[str, Any] 185 | """ 186 | Async version of extract_tags for use in async request handling. 187 | 188 | Uses await request.auser() instead of request.user to avoid 189 | SynchronousOnlyOperation in async context. 190 | 191 | Follows Django's naming convention for async methods (auser, asave, etc.). 192 | """ 193 | user_id, user_email = await self.aextract_request_user(request) 194 | return self._build_tags(request, user_id, user_email) 195 | 196 | async def aextract_request_user(self, request): 197 | # type: (HttpRequest) -> tuple[Optional[str], Optional[str]] 198 | """ 199 | Async version of extract_request_user for use in async request handling. 200 | 201 | Uses await request.auser() instead of request.user to avoid 202 | SynchronousOnlyOperation in async context. 203 | 204 | Follows Django's naming convention for async methods (auser, asave, etc.). 205 | """ 206 | auser = getattr(request, "auser", None) 207 | if callable(auser): 208 | try: 209 | user = await auser() 210 | return self._resolve_user_details(user) 211 | except Exception: 212 | # If auser() fails, return empty - don't break the request 213 | # Real errors (permissions, broken auth) will be logged by Django 214 | return None, None 215 | 216 | # Fallback for test requests without auser 217 | return None, None 218 | 219 | def _resolve_user_details(self, user): 220 | # type: (Any) -> tuple[Optional[str], Optional[str]] 221 | """ 222 | Extract user ID and email from a user object. 223 | 224 | Handles both authenticated and unauthenticated users, as well as 225 | legacy Django where is_authenticated was a method. 226 | """ 227 | user_id = None 228 | email = None 229 | 230 | if user is None: 231 | return user_id, email 232 | 233 | # Handle is_authenticated (property in modern Django, method in legacy) 234 | is_authenticated = getattr(user, "is_authenticated", False) 235 | if callable(is_authenticated): 236 | is_authenticated = is_authenticated() 237 | 238 | if not is_authenticated: 239 | return user_id, email 240 | 241 | # Extract user primary key 242 | user_pk = getattr(user, "pk", None) 243 | if user_pk is not None: 244 | user_id = str(user_pk) 245 | 246 | # Extract user email 247 | user_email = getattr(user, "email", None) 248 | if user_email: 249 | email = str(user_email) 250 | 251 | return user_id, email 252 | 253 | def __call__(self, request): 254 | # type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]] 255 | """ 256 | Unified entry point for both sync and async request handling. 257 | 258 | When sync_capable and async_capable are both True, Django passes requests 259 | without conversion. This method detects the mode and routes accordingly. 260 | """ 261 | if self._is_coroutine: 262 | return self.__acall__(request) 263 | else: 264 | # Synchronous path 265 | if self.request_filter and not self.request_filter(request): 266 | return self.get_response(request) 267 | 268 | with contexts.new_context(self.capture_exceptions, client=self.client): 269 | for k, v in self.extract_tags(request).items(): 270 | contexts.tag(k, v) 271 | 272 | return self.get_response(request) 273 | 274 | async def __acall__(self, request): 275 | # type: (HttpRequest) -> Awaitable[HttpResponse] 276 | """ 277 | Asynchronous entry point for async request handling. 278 | 279 | This method is called when the middleware chain is async. 280 | Uses aextract_tags() which calls request.auser() to avoid 281 | SynchronousOnlyOperation when accessing user in async context. 282 | """ 283 | if self.request_filter and not self.request_filter(request): 284 | return await self.get_response(request) 285 | 286 | with contexts.new_context(self.capture_exceptions, client=self.client): 287 | for k, v in (await self.aextract_tags(request)).items(): 288 | contexts.tag(k, v) 289 | 290 | return await self.get_response(request) 291 | 292 | def process_exception(self, request, exception): 293 | # type: (HttpRequest, Exception) -> None 294 | """ 295 | Process exceptions from views and downstream middleware. 296 | 297 | Django calls this WHILE still inside the context created by __call__, 298 | so request tags have already been extracted and set. This method just 299 | needs to capture the exception directly. 300 | 301 | Django converts view exceptions into responses before they propagate through 302 | the middleware stack, so the context manager in __call__/__acall__ never sees them. 303 | 304 | Note: Django's process_exception is always synchronous, even for async views. 305 | """ 306 | if self.request_filter and not self.request_filter(request): 307 | return 308 | 309 | if not self.capture_exceptions: 310 | return 311 | 312 | # Context and tags already set by __call__ or __acall__ 313 | # Just capture the exception 314 | if self.client: 315 | self.client.capture_exception(exception) 316 | else: 317 | from posthog import capture_exception 318 | 319 | capture_exception(exception) 320 | --------------------------------------------------------------------------------