Compare commits

...
25 changed files with 4383 additions and 277 deletions
+15 -9
View File
@@ -18,17 +18,16 @@ jobs:
with:
python-version: 3.11.11
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Install dev dependencies
shell: bash
run: |
python -m pip install -e .[dev]
if: steps.cache.outputs.cache-hit != 'true'
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
- name: Check formatting with ruff
run: |
@@ -55,9 +54,16 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install requirements.txt dependencies with pip
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Install test dependencies
shell: bash
run: |
python -m pip install -e .[test]
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
- name: Run posthog tests
run: |
+11 -3
View File
@@ -24,15 +24,23 @@ jobs:
- name: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools packaging wheel twine
run: uv sync --extra dev
- name: Push release to PyPI
run: make release && make release_analytics
- name: Push releases to PyPI
run: uv run make release && uv run make release_analytics
- name: Create GitHub release
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
+1
View File
@@ -17,3 +17,4 @@ posthog-analytics
.coverage
pyrightconfig.json
.env
.DS_Store
+237
View File
@@ -0,0 +1,237 @@
# Before Send Hook
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
- **Privacy**: Removing or masking sensitive data (PII)
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
- **Enhancement**: Adding custom properties to all events
- **Transformation**: Modifying event names or property formats
## Basic Usage
```python
import posthog
from typing import Optional, Dict, Any
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Process event before sending to PostHog.
Args:
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
Returns:
Modified event dictionary to send, or None to drop the event
"""
# Your processing logic here
return event
# Initialize client with before_send hook
client = posthog.Client(
api_key="your-project-api-key",
before_send=my_before_send
)
```
## Common Use Cases
### 1. Filter Out Events
```python
from typing import Optional, Any
def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Drop events from internal users or test environments."""
properties = event.get("properties", {})
# Choose some property from your events
event_source = properties.get("event_source", "")
if event_source.endswith("internal"):
return None # Drop the event
# Filter out test events
if event.get("event") == "test_event":
return None
return event
```
### 2. Remove/Mask PII Data
```python
from typing import Optional, Any
def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Remove or mask personally identifiable information."""
properties = event.get("properties", {})
# Mask email but keep domain for analytics
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
else:
properties["email"] = "***"
# Remove sensitive fields entirely
sensitive_fields = ["my_business_info", "secret_things"]
for field in sensitive_fields:
properties.pop(field, None)
return event
```
### 3. Add Custom Properties
```python
from typing import Optional, Any
from datetime import datetime
from typing import Optional, Any
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Add custom properties to all events."""
if "properties" not in event:
event["properties"] = {}
event["properties"].update({
"app_version": "2.1.0",
"environment": "production",
"processed_at": datetime.now().isoformat()
})
return event
```
### 4. Transform Event Names
```python
from typing import Optional, Any
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Convert event names to a consistent format."""
original_event = event.get("event")
if original_event:
# Convert to snake_case
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
event["event"] = f"app_{normalized}"
return event
```
### 5. Log and drop in "dev" mode
When running in local dev often, you want to log but drop all events
```python
from typing import Optional, Any
def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Convert event names to a consistent format."""
print(event)
return None
```
### 6. Combined Processing
```python
from typing import Optional, Any
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Apply multiple transformations in sequence."""
# Step 1: Filter unwanted events
if should_drop_event(event):
return None
# Step 2: Scrub PII
event = scrub_pii(event)
# Step 3: Add context
event = add_context(event)
# Step 4: Normalize names
event = normalize_event_names(event)
return event
def should_drop_event(event: dict[str, Any]) -> bool:
"""Determine if event should be dropped."""
# Your filtering logic
return False
```
## Error Handling
If your `before_send` function raises an exception, PostHog will:
1. Log the error
2. Continue with the original, unmodified event
3. Not crash your application
```python
from typing import Optional, Any
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
# If this raises an exception, the original event will be sent
risky_operation()
return event
```
## Complete Example
```python
import posthog
from typing import Optional, Any
import re
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
try:
properties = event.get("properties", {})
# 1. Filter out bot traffic
user_agent = properties.get("$user_agent", "")
if re.search(r'bot|crawler|spider', user_agent, re.I):
return None
# 2. Filter out internal traffic
ip = properties.get("$ip", "")
if ip.startswith("192.168.") or ip.startswith("10."):
return None
# 3. Scrub email PII but keep domain
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
# 4. Add custom context
properties.update({
"app_version": "1.0.0",
"build_number": "123"
})
# 5. Normalize event name
if event.get("event"):
event["event"] = event["event"].lower().replace(" ", "_")
return event
except Exception as e:
# Log error but don't crash
print(f"Error in before_send: {e}")
return event # Return original event on error
# Usage
client = posthog.Client(
api_key="your-api-key",
before_send=production_before_send
)
# All events will now be processed by your before_send function
client.capture("user_123", "Page View", {"url": "/home"})
```
+43 -11
View File
@@ -1,18 +1,50 @@
## 4.7.0 - 2025-06-10
- feat: add support for parse endpoint in responses API (no longer beta)
## 4.6.2 - 2025-06-09
- fix: replace `import posthog` with direct method imports
## 4.6.1 - 2025-06-09
- fix: replace `import posthog` in `posthoganalytics` package
## 4.6.0 - 2025-06-09
- feat: add additional user and request context to captured exceptions via the Django integration
- feat: Add `setup()` function to initialise default client
## 4.5.0 - 2025-06-09
- feat: add before_send callback (#249)
## 4.4.2- 2025-06-09
- empty point release to fix release automation
## 4.4.1 2025-06-09
- empty point release to fix release automation
## 4.4.0 - 2025-06-09
- Use the new `/flags` endpoint for all feature flag evaluations (don't fall back to `/decide` at all)
## 4.3.2 - 2025-06-06
Add context management:
- New context manager with `posthog.new_context()`
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
- Function decorator:
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
- Automatic deduplication of exceptions to ensure each exception is only captured once
1. Add context management:
## 4.2.1 - 2025-6-05
- New context manager with `posthog.new_context()`
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
- Function decorator:
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
- Automatic deduplication of exceptions to ensure each exception is only captured once
1. fix: feature flag request use geoip_disable (#235)
2. chore: pin actions versions (#210)
3. fix: opinionated setup and clean fn fix (#240)
4. fix: release action failed (#241)
2. fix: feature flag request use geoip_disable (#235)
3. chore: pin actions versions (#210)
4. fix: opinionated setup and clean fn fix (#240)
5. fix: release action failed (#241)
## 4.2.0 - 2025-05-22
+9 -4
View File
@@ -16,16 +16,21 @@ release_analytics:
rm -rf posthoganalytics
mkdir posthoganalytics
cp -r posthog/* posthoganalytics/
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
cp -r posthoganalytics/* posthog/
rm -rf posthoganalytics
rm -f pyproject.toml
cp pyproject.toml.backup pyproject.toml
rm -f pyproject.toml.backup
e2e_test:
.buildscripts/e2e.sh
+7 -5
View File
@@ -16,11 +16,13 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
### Testing Locally
1. Run `python3 -m venv env` (creates virtual environment called "env")
* or `uv venv env`
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
1. Run `uv venv env` (creates virtual environment called "env")
* or `python3 -m venv env`
2. Run `source env/bin/activate` (activates the virtual environment)
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
* or `uv pip install -e ".[test]"`
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
* or `pip install -e ".[dev,test]"`
4. you have to run `pre-commit install` to have auto linting pre commit
5. Run `make test`
1. To run a specific test do `pytest -k test_no_api_key`
@@ -32,7 +34,7 @@ uv python install 3.9.19
uv python pin 3.9.19
uv venv env
source env/bin/activate
uv pip install --editable ".[dev,test]"
uv sync --extra dev --extra test
pre-commit install
make test
```
+6 -2
View File
@@ -580,8 +580,7 @@ def shutdown():
_proxy("join")
def _proxy(method, *args, **kwargs):
"""Create an analytics client if one doesn't exist and send to it."""
def setup():
global default_client
if not default_client:
default_client = Client(
@@ -610,6 +609,11 @@ def _proxy(method, *args, **kwargs):
default_client.disabled = disabled
default_client.debug = debug
def _proxy(method, *args, **kwargs):
"""Create an analytics client if one doesn't exist and send to it."""
setup()
fn = getattr(default_client, method)
return fn(*args, **kwargs)
+36
View File
@@ -230,6 +230,42 @@ class WrappedResponses:
groups=posthog_groups,
)
def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
+36
View File
@@ -230,6 +230,42 @@ class WrappedResponses:
groups=posthog_groups,
)
async def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
+26 -130
View File
@@ -1,5 +1,4 @@
import atexit
import hashlib
import logging
import numbers
import os
@@ -27,7 +26,6 @@ from posthog.request import (
DEFAULT_HOST,
APIError,
batch_post,
decide,
determine_server_host,
flags,
get,
@@ -58,78 +56,6 @@ except ImportError:
ID_TYPES = (numbers.Number, string_types, UUID)
MAX_DICT_SIZE = 50_000
# TODO: Get rid of these when you're done rolling out `/flags` to all customers
ROLLOUT_PERCENTAGE = 1
INCLUDED_HASHES = set(
{"bc94e67150c97dbcbf52549d50a7b80814841dbf"}
) # this is PostHog's API key
# Explicitly excluding all the API tokens associated with the top 10 customers; we'll get to them soon, but don't want to rollout to them just yet
EXCLUDED_HASHES = set(
{
"03005596796f9ee626e9596b8062972cb6a556a0",
"05620a20b287e0d5cb1d4a0dd492797f36b952c5",
"0f95b5ca12878693c01c6420e727904f1737caa7",
"1212b6287a6e7e5ff6be5cb30ec563f35c2139d6",
"171ec1bb2caf762e06b1fde2e36a38c4638691a8",
"171faa9fc754b1aa42252a4eedb948b7c805d5cb",
"178ddde3f628fb0030321387acf939e4e6946d35",
"1790085d7e9aa136e8b73c180dd6a6060e2ef949",
"1895a3349c2371559c886f19ef1bf60617a934e0",
"1f01267d4f0295f88e8943bc963d816ee4abc84b",
"213df54990a34e62e3570b430f7ee36ec0928743",
"23d235537d988ab98ad259853eab02b07d828c2b",
"27135f7ae8f936222a5fcfcdc75c139b27dd3254",
"2817396d80fafc86c0816af8e73880f8b3e54320",
"29d3235e63db42056858ef04c6a5488c2a459eaa",
"2a76d9b5eb9307e540de9d516aa80f6cb5a0292f",
"2a92965a1344ab8a1f7dac2507e858f579a88ac2",
"2d5823818261512d616161de2bb8a161d48f1e35",
"32942f6a879dbfa8011cc68288c098e4a76e6cc0",
"3db6c17ab65827ceadf77d9a8462fabd94170ca6",
"4975b24f9ced9b2c06b604ddc9612f663f9452d5",
"497c7b017b13cd6cdbfe641c71f0dfb660a4c518",
"49c79e1dbce4a7b9394d6c14bf0421e04cecb445",
"4d63e1c5cd3a80972eac4e7526f03357ac538043",
"4da0f42a6f8f116822411152e5cda3c65ed2561f",
"4e494675ecd2b841784d6f29b658b38a0877a62e",
"4e852d8422130cec991eca2d6416dbe321d0a689",
"5120bfd92c9c6731074a89e4a82f49e947d34369",
"512cd72f9aa7ab11dfd012cc2e19394a020bd9a8",
"5b175d4064cc62f01118a2c6818c2c02fc8f27e1",
"5ba4bba3979e97d2c84df2aba394ca29c6c43187",
"639014946463614353ca640b268dc6592f62b652",
"643b9be9d50104e2b4ba94bc56688adba69c80fe",
"658f92992af9fc6a360143d72d93a36f63bbccb0",
"673a59c99739dfcee35202e428dd020b94866d52",
"67a9829b4997f5c6f3ab8173ad299f634adcfa53",
"6d686043e914ae8275df65e1ad890bd32a3b6fdd",
"6e4b5e1d649ad006d78f1f1617a9a0f35fc73078",
"6f1fc3a8fa9df54d00cbc1ef9ad5f24640589fd0",
"764e5fec2c7899cfee620fae8450fcc62cd72bf0",
"80ea6d6ed9a5895633c7bee7aba4323eeacdc90e",
"872e420156f583bc97351f3d83c02dae734a85df",
"8a24844cbeae31e74b4372964cdea74e99d9c0e2",
"975ae7330506d4583b000f96ad87abb41a0141ce",
"9e3d71378b340def3080e0a3a785a1b964cf43ef",
"9ede7b21365661331d024d92915de6e69749892b",
"a1ed1b4216ef4cec542c6b3b676507770be24ddc",
"a4f66a70a9647b3b89fc59f7642af8ffab073ba1",
"a7adb80be9e90948ab6bb726cc6e8e52694aec74",
"bca4b14ac8de49cccc02306c7bb6e5ae2acc0f72",
"bde5fe49f61e13629c5498d7428a7f6215e482a6",
"c54a7074c323aa7c5cb7b24bf826751b2a58f5d8",
"c552d20da0c87fb4ebe2da97c7f95c05eef2bca1",
"d7682f2d268f3064d433309af34f2935810989d2",
"d794ac43d8be26bf99f369ea79501eb774fe1b16",
"e0963e2552af77d46bb24d5b5806b5b456c64c5f",
"e6f14b2100cb0598925958b097ace82486037a25",
"e79ec399ad45f44a4295a5bb1322e2f14600ae39",
"eecf29f73f9c31009e5737a6c5ec3f87ec5b8ea6",
"f2c01f3cc770c7788257ee60910e2530f92eefc3",
"f7bbc58f4122b1e2812c0f1962c584cb404a1ac3",
}
)
def get_os_info():
"""
@@ -185,43 +111,6 @@ def system_context() -> dict[str, Any]:
}
def is_token_in_rollout(
token: str,
percentage: float = 0,
included_hashes: Optional[set[str]] = None,
excluded_hashes: Optional[set[str]] = None,
) -> bool:
"""
Determines if a token should be included in a rollout based on:
1. If its hash matches any included_hashes provided
2. If its hash falls within the percentage rollout
Args:
token: String to hash (usually API key)
percentage: Float between 0 and 1 representing rollout percentage
included_hashes: Optional set of specific SHA1 hashes to match against
excluded_hashes: Optional set of specific SHA1 hashes to exclude from rollout
Returns:
bool: True if token should be included in rollout
"""
# First generate SHA1 hash of token
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
# Check if hash matches any included hashes
if included_hashes and token_hash in included_hashes:
return True
# Check if hash matches any excluded hashes
if excluded_hashes and token_hash in excluded_hashes:
return False
# Convert first 8 chars of hash to int and divide by max value to get number between 0-1
hash_int = int(token_hash[:8], 16)
hash_float = hash_int / 0xFFFFFFFF
return hash_float < percentage
class Client(object):
"""Create a new PostHog client."""
@@ -255,6 +144,7 @@ class Client(object):
exception_autocapture_integrations=None,
project_root=None,
privacy_mode=False,
before_send=None,
):
self.queue = queue.Queue(max_queue_size)
@@ -310,6 +200,15 @@ class Client(object):
else:
self.log.setLevel(logging.WARNING)
if before_send is not None:
if callable(before_send):
self.before_send = before_send
else:
self.log.warning("before_send is not callable, it will be ignored")
self.before_send = None
else:
self.before_send = None
if self.enable_exception_autocapture:
self.exception_capture = ExceptionCapture(
self, integrations=self.exception_autocapture_integrations
@@ -475,28 +374,13 @@ class Client(object):
"geoip_disable": disable_geoip,
}
use_flags = is_token_in_rollout(
resp_data = flags(
self.api_key,
ROLLOUT_PERCENTAGE,
included_hashes=INCLUDED_HASHES,
excluded_hashes=EXCLUDED_HASHES,
self.host,
timeout=self.feature_flags_request_timeout_seconds,
**request_data,
)
if use_flags:
resp_data = flags(
self.api_key,
self.host,
timeout=self.feature_flags_request_timeout_seconds,
**request_data,
)
else:
resp_data = decide(
self.api_key,
self.host,
timeout=self.feature_flags_request_timeout_seconds,
**request_data,
)
return normalize_flags_response(resp_data)
def capture(
@@ -870,6 +754,18 @@ class Client(object):
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
msg = clean(msg)
if self.before_send:
try:
modified_msg = self.before_send(msg)
if modified_msg is None:
self.log.debug("Event dropped by before_send callback")
return True, None
msg = modified_msg
except Exception as e:
self.log.exception(f"Error in before_send callback: {e}")
# Continue with the original message if callback fails
self.log.debug("queueing: %s", msg)
# if send is False, return msg as if it was successfully queued
+28 -2
View File
@@ -67,8 +67,8 @@ class DjangoRequestExtractor:
headers = self.headers()
# Extract traceparent and tracestate headers
traceparent = headers.get("traceparent")
tracestate = headers.get("tracestate")
traceparent = headers.get("Traceparent")
tracestate = headers.get("Tracestate")
# Extract the distinct_id from tracestate
distinct_id = None
@@ -80,12 +80,38 @@ class DjangoRequestExtractor:
distinct_id = match.group(1)
return {
**self.user(),
"distinct_id": distinct_id,
"ip": headers.get("X-Forwarded-For"),
"user_agent": headers.get("User-Agent"),
"traceparent": traceparent,
"$request_path": self.request.path,
}
def user(self):
user_data: dict[str, str] = {}
user = getattr(self.request, "user", None)
if user is None or not user.is_authenticated:
return user_data
try:
user_id = str(user.pk)
if user_id:
user_data.setdefault("$user_id", user_id)
except Exception:
pass
try:
email = str(user.email)
if email:
user_data.setdefault("email", email)
except Exception:
pass
return user_data
def headers(self):
# type: () -> Dict[str, str]
return dict(self.request.headers)
+2 -2
View File
@@ -39,7 +39,7 @@ def new_context(fresh=False):
raise ValueError("Something went wrong")
"""
import posthog
from posthog import capture_exception
current_tags = _get_current_context().copy()
current_stack = _context_stack.get()
@@ -49,7 +49,7 @@ def new_context(fresh=False):
try:
yield
except Exception as e:
posthog.capture_exception(e)
capture_exception(e)
raise
finally:
_context_stack.reset(token)
+3 -3
View File
@@ -4,7 +4,7 @@ from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import Dsn
import posthog
from posthog import capture, host
from posthog.request import DEFAULT_HOST
from posthog.sentry import POSTHOG_ID_TAG
@@ -34,7 +34,7 @@ class PostHogIntegration(Integration):
if event.get("tags", {}).get(POSTHOG_ID_TAG):
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
event["tags"]["PostHog URL"] = (
f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
f"{host or DEFAULT_HOST}/person/{posthog_distinct_id}"
)
properties = {
@@ -52,6 +52,6 @@ class PostHogIntegration(Integration):
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
)
posthog.capture(posthog_distinct_id, "$exception", properties)
capture(posthog_distinct_id, "$exception", properties)
return event
+128
View File
@@ -26,6 +26,11 @@ try:
ResponseOutputMessage,
ResponseOutputText,
ResponseUsage,
ParsedResponse,
)
from openai.types.responses.parsed_response import (
ParsedResponseOutputMessage,
ParsedResponseOutputText,
)
from posthog.ai.openai import OpenAI
@@ -115,6 +120,59 @@ def mock_openai_response_with_responses_api():
)
@pytest.fixture
def mock_parsed_response():
return ParsedResponse(
id="test",
model="gpt-4o-2024-08-06",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ParsedResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ParsedResponseOutputText(
type="output_text",
text='{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
annotations=[],
parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
)
],
)
],
output_parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=15,
output_tokens=20,
input_tokens_details={"prompt_tokens": 15, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 5},
total_tokens=35,
),
user=None,
metadata={},
)
@pytest.fixture
def mock_embedding_response():
return CreateEmbeddingResponse(
@@ -646,3 +704,73 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_responses_parse(mock_client, mock_parsed_response):
with patch(
"openai.resources.responses.Responses.parse",
return_value=mock_parsed_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
],
text={
"format": {
"type": "json_schema",
"json_schema": {
"name": "event",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"date": {"type": "string"},
"participants": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["name", "date", "participants"],
},
},
}
},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_parsed_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-2024-08-06"
assert props["$ai_input"] == [
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
}
]
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 20
assert props["$ai_reasoning_tokens"] == 5
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
@@ -1,20 +1,44 @@
from posthog.exception_integrations.django import DjangoRequestExtractor
from django.test import RequestFactory
from django.conf import settings
from django.core.management import call_command
import django
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
# setup a test app
if not settings.configured:
settings.configure(
SECRET_KEY="test",
DEFAULT_CHARSET="utf-8",
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
)
django.setup()
call_command("migrate", verbosity=0, interactive=False)
def mock_request_factory(override_headers):
class Request:
META = {}
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
headers = {
factory = RequestFactory(
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Referrer": "http://example.com",
"X-Forwarded-For": "193.4.5.12",
**(override_headers or {}),
}
)
return Request()
request = factory.get("/api/endpoint")
return request
def test_request_extractor_with_no_trace():
@@ -25,6 +49,7 @@ def test_request_extractor_with_no_trace():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
}
@@ -32,12 +57,14 @@ def test_request_extractor_with_trace():
request = mock_request_factory(
{"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": None,
"$request_path": "/api/endpoint",
}
@@ -54,6 +81,7 @@ def test_request_extractor_with_tracestate():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": "1234",
"$request_path": "/api/endpoint",
}
@@ -67,4 +95,27 @@ def test_request_extractor_with_complicated_tracestate():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": "alohaMountainsXUYZ",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_request_user():
from django.contrib.auth.models import User
user = User.objects.create_user(
username="test", email="test@posthog.com", password="top_secret"
)
request = mock_request_factory(None)
request.user = user
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
"email": "test@posthog.com",
"$user_id": "1",
}
+171
View File
@@ -0,0 +1,171 @@
import unittest
import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestClient(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
cls.client_post_patcher.start()
cls.consumer_post_patcher.start()
@classmethod
def tearDownClass(cls):
cls.client_post_patcher.stop()
cls.consumer_post_patcher.stop()
def set_fail(self, e, batch):
"""Mark the failure handler"""
print("FAIL", e, batch) # noqa: T201
self.failed = True
def setUp(self):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
def test_before_send_callback_modifies_event(self):
"""Test that before_send callback can modify events."""
processed_events = []
def my_before_send(event):
processed_events.append(event.copy())
if "properties" not in event:
event["properties"] = {}
event["properties"]["processed_by_before_send"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
)
success, msg = client.capture("user1", "test_event", {"original": "value"})
self.assertTrue(success)
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
self.assertEqual(msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
def test_before_send_callback_drops_event(self):
"""Test that before_send callback can drop events by returning None."""
def drop_test_events(event):
if event.get("event") == "test_drop_me":
return None
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
)
# Event should be dropped
success, msg = client.capture("user1", "test_drop_me")
self.assertTrue(success)
self.assertIsNone(msg)
# Event should go through
success, msg = client.capture("user1", "keep_me")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "keep_me")
def test_before_send_callback_handles_exceptions(self):
"""Test that exceptions in before_send don't crash the client."""
def buggy_before_send(event):
raise ValueError("Oops!")
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
)
success, msg = client.capture("user1", "robust_event")
# Event should still be sent despite the exception
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "robust_event")
def test_before_send_callback_works_with_all_event_types(self):
"""Test that before_send works with capture, identify, set, etc."""
def add_marker(event):
if "properties" not in event:
event["properties"] = {}
event["properties"]["marked"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
)
# Test capture
success, msg = client.capture("user1", "event")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test identify
success, msg = client.identify("user1", {"trait": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test set
success, msg = client.set("user1", {"prop": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test page
success, msg = client.page("user1", "https://example.com")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
def test_before_send_callback_disabled_when_none(self):
"""Test that client works normally when before_send is None."""
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
success, msg = client.capture("user1", "normal_event")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "normal_event")
def test_before_send_callback_pii_scrubbing_example(self):
"""Test a realistic PII scrubbing use case."""
def scrub_pii(event):
properties = event.get("properties", {})
# Mask email but keep domain
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
else:
properties["email"] = "***"
# Remove credit card
properties.pop("credit_card", None)
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
)
success, msg = client.capture(
"user1",
"form_submit",
{
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", msg["properties"])
self.assertEqual(msg["properties"]["form_name"], "contact")
+1 -79
View File
@@ -1,4 +1,3 @@
import hashlib
import time
import unittest
from datetime import datetime
@@ -8,7 +7,7 @@ import mock
import six
from parameterized import parameterized
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
from posthog.client import Client
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, LegacyFlagMetadata
@@ -1462,80 +1461,3 @@ class TestClient(unittest.TestCase):
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_get_flags_decision_rollout(self, patch_flags, patch_decide):
# Set up mock responses
decide_response = {
"featureFlags": {"flag1": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
flags_response = {
"featureFlags": {"flag2": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
patch_decide.return_value = decide_response
patch_flags.return_value = flags_response
client = Client(FAKE_TEST_API_KEY)
# Test 100% rollout - should use flags
with mock.patch(
"posthog.client.is_token_in_rollout", return_value=True
) as mock_rollout:
client.get_flags_decision("distinct_id")
mock_rollout.assert_called_with(
FAKE_TEST_API_KEY,
1,
included_hashes=INCLUDED_HASHES,
excluded_hashes=EXCLUDED_HASHES,
)
patch_flags.assert_called_once()
patch_decide.assert_not_called()
def test_token_rollout_calculation(self):
# Test specific hash inclusion
token = "test_token"
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
included_hashes = {token_hash}
# Should be included due to specific hash, even with 0% rollout
self.assertTrue(
expr=is_token_in_rollout(
token, percentage=0.0, included_hashes=included_hashes
)
)
# Should not be included with 0% rollout and no specific hash
self.assertFalse(is_token_in_rollout(token, percentage=0.0))
# Should be included with 100% rollout regardless of specific hash
self.assertTrue(is_token_in_rollout(token, percentage=1.0))
self.assertTrue(
is_token_in_rollout(token, percentage=1.0, included_hashes=included_hashes)
)
# Test deterministic behavior - same token should always give same result
hash_float = int(token_hash[:8], 16) / 0xFFFFFFFF
percentage = hash_float + 0.1 # Just above the hash value
self.assertTrue(is_token_in_rollout(token, percentage))
self.assertFalse(
is_token_in_rollout(token, percentage - 0.2)
) # Just below the hash value
# Test that the token exclusion works correctly
self.assertFalse(
is_token_in_rollout(token, percentage=1.0, excluded_hashes={token_hash})
)
# Should work for other specific token hashes
# Include our API key
self.assertTrue(
is_token_in_rollout(
"sTMFPsFhdP1Ssg", percentage=0.1, included_hashes=INCLUDED_HASHES
)
)
+5 -1
View File
@@ -1,9 +1,13 @@
import json
from dataclasses import dataclass
from typing import Any, List, Optional, TypedDict, Union, cast
from typing import Any, Callable, List, Optional, TypedDict, Union, cast
FlagValue = Union[bool, str]
# Type alias for the before_send callback function
# Takes an event dictionary and returns the modified event or None to drop it
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
@dataclass(frozen=True)
class FlagReason:
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "4.3.2"
VERSION = "4.7.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+10 -5
View File
@@ -8,13 +8,14 @@ dynamic = ["version"]
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT License" }
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -35,6 +36,8 @@ Homepage = "https://github.com/posthog/posthog-python"
Repository = "https://github.com/posthog/posthog-python"
[project.optional-dependencies]
sentry = ["sentry-sdk", "django"]
langchain = ["langchain>=0.2.0"]
dev = [
"django-stubs",
"lxml",
@@ -48,6 +51,12 @@ dev = [
"pre-commit",
"pydantic",
"ruff",
"setuptools",
"packaging",
"wheel",
"twine",
"tomli",
"tomli_w",
]
test = [
"mock>=2.0.0",
@@ -67,8 +76,6 @@ test = [
"pydantic",
"parameterized>=0.8.1",
]
sentry = ["sentry-sdk", "django"]
langchain = ["langchain>=0.2.0"]
[tool.setuptools]
packages = [
@@ -83,8 +90,6 @@ packages = [
"posthog.exception_integrations",
]
license-files = []
[tool.setuptools.dynamic]
version = { attr = "posthog.version.VERSION" }
-12
View File
@@ -1,12 +0,0 @@
[bdist_wheel]
universal = 1
[tool:pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
[flake8]
# ignore E501 for line length
# ignore W503 for line break before binary operator
ignore = E501,W503
max-line-length = 120
-1
View File
@@ -28,7 +28,6 @@ setup(
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
license="MIT License",
description="Integrate PostHog into any python application.",
long_description=long_description,
+34 -2
View File
@@ -1,5 +1,8 @@
import os
import sys
import tomli
import tomli_w
import shutil
try:
from setuptools import setup
@@ -7,9 +10,39 @@ except ImportError:
from distutils.core import setup
# Don't import analytics-python module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
from version import VERSION # noqa: E402
# Copy the original pyproject.toml as backup
shutil.copy("pyproject.toml", "pyproject.toml.backup")
# Read the original pyproject.toml
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)
# Override specific values
config["project"]["name"] = "posthoganalytics"
config["tool"]["setuptools"]["dynamic"]["version"] = {
"attr": "posthoganalytics.version.VERSION"
}
# Rename packages from posthog.* to posthoganalytics.*
if "packages" in config["tool"]["setuptools"]:
new_packages = []
for package in config["tool"]["setuptools"]["packages"]:
if package == "posthog":
new_packages.append("posthoganalytics")
elif package.startswith("posthog."):
new_packages.append(package.replace("posthog.", "posthoganalytics.", 1))
else:
new_packages.append(package)
config["tool"]["setuptools"]["packages"] = new_packages
# Overwrite the original pyproject.toml
with open("pyproject.toml", "wb") as f:
tomli_w.dump(config, f)
long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
@@ -28,7 +61,6 @@ setup(
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
license="MIT License",
description="Integrate PostHog into any python application.",
long_description=long_description,
Generated
+3517
View File
File diff suppressed because it is too large Load Diff