Compare commits

..
Author SHA1 Message Date
Georgiy TarasovandGitHub 52df246a3e feat(ai): langchain cached and reasoning tokens (#258)
* fix: reasoning and cached tokens

* test: new flows

* fix: missing field

* chore: bump

* fix: make sure we send write/read/reasoning tokens
2025-06-13 15:02:06 +02:00
Phil HaackandGitHub f1f9ecf7a4 Add flags project board workflow (#259) 2025-06-12 16:33:42 +00:00
Oliver BrowneandGitHub 9db1b7e9f3 fix: change scoped export, add capturing param (#257)
* change export, add capturing param

* capturing -> capture_exceptions
2025-06-12 10:29:32 +01:00
Peter KirkhamandGitHub 01751d1205 feat: add support for parse via responses (#256) 2025-06-11 05:39:24 +01:00
David NewellandGitHub 4426dd9d27 remove 'import posthog' (#255) 2025-06-10 11:05:22 +01:00
David NewellandGitHub bf0d7efbfe fix: makefile import (#254) 2025-06-09 19:20:31 +01:00
David NewellandGitHub f17ebfa12b feat: more django context (#252) 2025-06-09 14:32:42 +01:00
Paul D'AmbraandGitHub 800527da43 feat: add before_send callback (#249) 2025-06-09 13:56:46 +01:00
Paul D'AmbraandGitHub 0d29fb7be3 fix changelog to match pypi (#253) 2025-06-09 12:09:15 +01:00
Paul D'AmbraandGitHub 24d89806cb chore: more fiddling to get release working (#251)
* chore: more fiddling to get release working

* fix

* fix
2025-06-09 11:50:30 +01:00
Paul D'AmbraandGitHub a2105f6e95 chore: use uv run when releasing (#250) 2025-06-09 10:11:12 +00:00
Paul D'AmbraandGitHub 3171193d75 fix: makefile for posthog_analytics release (#248) 2025-06-09 10:51:04 +01:00
Paul D'AmbraandGitHub 1db6e45258 chore: pyproject and CI update (#247) 2025-06-07 14:08:47 +03:00
Dylan MartinandGitHub 1daa8a8053 chore(flags): roll everyone onto /flags (#246) 2025-06-06 16:36:10 -07:00
28 changed files with 4747 additions and 316 deletions
@@ -0,0 +1,17 @@
# 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.
# It is used to update the feature flags project board with the pull request information.
name: Call Feature Flags Project Workflow
on:
pull_request:
types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened]
jobs:
call-flags-project:
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
with:
pr_number: ${{ github.event.pull_request.number }}
pr_node_id: ${{ github.event.pull_request.node_id }}
is_draft: ${{ github.event.pull_request.draft }}
secrets: inherit
+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"})
```
+45 -9
View File
@@ -1,10 +1,48 @@
## 4.3.3 - 2025-06-06
## 4.9.0 - 2025-06-13
Add `setup()` function to initialise default client
- feat(ai): track reasoning and cache tokens in the LangChain callback
## 4.8.0 - 2025-06-10
- fix: export scoped, rather than tracked, decorator
- feat: allow use of contexts without error tracking
## 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:
1. Add context management:
- New context manager with `posthog.new_context()`
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
@@ -12,12 +50,10 @@ Add context management:
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
- Automatic deduplication of exceptions to ensure each exception is only captured once
## 4.2.1 - 2025-6-05
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
```
+1 -1
View File
@@ -15,7 +15,7 @@ new_context = new_context
tag = tag
get_tags = get_tags
clear_tags = clear_tags
tracked = scoped
scoped = scoped
"""Settings."""
api_key = None # type: Optional[str]
+62 -9
View File
@@ -14,7 +14,6 @@ from typing import (
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
@@ -569,9 +568,14 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_is_error"] = True
else:
# Add usage
input_tokens, output_tokens = _parse_usage(output)
event_properties["$ai_input_tokens"] = input_tokens
event_properties["$ai_output_tokens"] = output_tokens
usage = _parse_usage(output)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
usage.cache_write_tokens
)
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
# Generation results
generation_result = output.generations[-1]
@@ -647,9 +651,18 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
return message_dict
@dataclass
class ModelUsage:
input_tokens: Optional[int]
output_tokens: Optional[int]
cache_write_tokens: Optional[int]
cache_read_tokens: Optional[int]
reasoning_tokens: Optional[int]
def _parse_usage_model(
usage: Union[BaseModel, Dict],
) -> Tuple[Union[int, None], Union[int, None]]:
usage: Union[BaseModel, dict],
) -> ModelUsage:
if isinstance(usage, BaseModel):
usage = usage.__dict__
@@ -657,15 +670,23 @@ def _parse_usage_model(
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
("input_tokens", "input"),
("output_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
("prompt_token_count", "input"),
("candidates_token_count", "output"),
("cached_content_token_count", "cache_read"),
("thoughts_token_count", "reasoning"),
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
("inputTokenCount", "input"),
("outputTokenCount", "output"),
("cacheCreationInputTokenCount", "cache_write"),
("cacheReadInputTokenCount", "cache_read"),
# Bedrock Anthropic
("prompt_tokens", "input"),
("completion_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# langchain-ibm https://pypi.org/project/langchain-ibm/
("input_token_count", "input"),
("generated_token_count", "output"),
@@ -683,13 +704,45 @@ def _parse_usage_model(
parsed_usage[type_key] = final_count
return parsed_usage.get("input"), parsed_usage.get("output")
# Caching (OpenAI & langchain 0.3.9+)
if "input_token_details" in usage and isinstance(
usage["input_token_details"], dict
):
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
# Reasoning (OpenAI & langchain 0.3.9+)
if "output_token_details" in usage and isinstance(
usage["output_token_details"], dict
):
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
field_mapping = {
"input": "input_tokens",
"output": "output_tokens",
"cache_write": "cache_write_tokens",
"cache_read": "cache_read_tokens",
"reasoning": "reasoning_tokens",
}
return ModelUsage(
**{
dataclass_key: parsed_usage.get(mapped_key) or 0
for mapped_key, dataclass_key in field_mapping.items()
},
)
def _parse_usage(response: LLMResult):
def _parse_usage(response: LLMResult) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
llm_usage: ModelUsage = ModelUsage(
input_tokens=None,
output_tokens=None,
cache_write_tokens=None,
cache_read_tokens=None,
reasoning_tokens=None,
)
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
+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)
+29 -24
View File
@@ -12,34 +12,37 @@ def _get_current_context() -> Dict[str, Any]:
@contextmanager
def new_context(fresh=False):
def new_context(fresh=False, capture_exceptions=True):
"""
Create a new context scope that will be active for the duration of the with block.
Any tags set within this scope will be isolated to this context. Any exceptions raised
Create a new context scope that will be active for the duration of the with block.
Any tags set within this scope will be isolated to this context. Any exceptions raised
or events captured within the context will be tagged with the context tags.
Args:
fresh: Whether to start with a fresh context (default: False).
If False, inherits tags from parent context.
If True, starts with no tags.
Args:
fresh: Whether to start with a fresh context (default: False).
If False, inherits tags from parent context.
If True, starts with no tags.
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
If True, captures exceptions and tags them with the context tags before propagating them.
If False, exceptions will propagate without being tagged or captured.
Examples:
# Inherit parent context tags
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
Examples:
# Inherit parent context tags
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
# Start with fresh context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
# Start with fresh context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
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 +52,8 @@ def new_context(fresh=False):
try:
yield
except Exception as e:
posthog.capture_exception(e)
if capture_exceptions:
capture_exception(e)
raise
finally:
_context_stack.reset(token)
@@ -88,13 +92,14 @@ def clear_tags() -> None:
F = TypeVar("F", bound=Callable[..., Any])
def scoped(fresh=False):
def scoped(fresh=False, capture_exceptions=True):
"""
Decorator that creates a new context for the function. Simply wraps
the function in a with posthog.new_context(): block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Example:
@posthog.scoped()
@@ -114,7 +119,7 @@ def scoped(fresh=False):
@wraps(func)
def wrapper(*args, **kwargs):
with new_context(fresh=fresh):
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
return func(*args, **kwargs)
return cast(F, wrapper)
+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
+256 -7
View File
@@ -1378,11 +1378,11 @@ def test_langgraph_agent(mock_client):
)
graph.invoke(inputs, config={"callbacks": [cb]})
calls = [call[1] for call in mock_client.capture.call_args_list]
assert len(calls) == 21
assert len(calls) == 15
for call in calls:
assert call["properties"]["$ai_trace_id"] == "test-trace-id"
assert len([call for call in calls if call["event"] == "$ai_generation"]) == 2
assert len([call for call in calls if call["event"] == "$ai_span"]) == 18
assert len([call for call in calls if call["event"] == "$ai_span"]) == 12
assert len([call for call in calls if call["event"] == "$ai_trace"]) == 1
@@ -1435,11 +1435,13 @@ def test_span_set_parent_ids_for_third_level_run(mock_client, trace_id):
assert mock_client.capture.call_count == 3
span2, span1, trace = [
call[1]["properties"] for call in mock_client.capture.call_args_list
]
assert span2["$ai_parent_id"] == span1["$ai_span_id"]
assert span1["$ai_parent_id"] == trace["$ai_trace_id"]
calls = mock_client.capture.call_args_list
span_props_2 = calls[0][1]["properties"]
span_props_1 = calls[1][1]["properties"]
trace_props = calls[2][1]["properties"]
assert span_props_2["$ai_parent_id"] == span_props_1["$ai_span_id"]
assert span_props_1["$ai_parent_id"] == trace_props["$ai_trace_id"]
def test_captures_error_with_details_in_span(mock_client):
@@ -1478,3 +1480,250 @@ def test_captures_error_without_details_in_span(mock_client):
== "ValueError"
)
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"]
def test_openai_reasoning_tokens(mock_client):
"""Test that OpenAI reasoning tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think step by step about this problem")]
)
# Mock response with reasoning tokens in output_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me think through this step by step...",
usage_metadata={
"input_tokens": 10,
"output_tokens": 25,
"total_tokens": 35,
"output_token_details": {"reasoning": 15}, # 15 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me think through this step by step..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 10
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_reasoning_tokens"] == 15
def test_anthropic_cache_write_and_read_tokens(mock_client):
"""Test that Anthropic cache creation and read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages([("user", "Analyze this large document")])
# First call with cache creation
model_write = FakeMessagesListChatModel(
responses=[
AIMessage(
content="I've analyzed the document and cached the context.",
usage_metadata={
"total_tokens": 1050,
"input_tokens": 1000,
"output_tokens": 50,
"cache_creation_input_tokens": 800, # Anthropic cache write
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model_write
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "I've analyzed the document and cached the context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 1000
assert generation_props["$ai_output_tokens"] == 50
assert generation_props["$ai_cache_creation_input_tokens"] == 800
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
# Reset mock for second call
mock_client.reset_mock()
# Second call with cache read
model_read = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Using cached analysis to provide quick response.",
usage_metadata={
"input_tokens": 200,
"output_tokens": 30,
"total_tokens": 1030,
"cache_read_input_tokens": 800, # Anthropic cache read
},
)
]
)
chain = prompt | model_read
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Using cached analysis to provide quick response."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 200
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_read_tokens(mock_client):
"""Test that OpenAI cache read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Use the cached prompt for this request")]
)
# Mock response with cache read tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response using cached prompt context.",
usage_metadata={
"input_tokens": 150,
"output_tokens": 40,
"total_tokens": 190,
"input_token_details": {
"cache_read": 100, # 100 tokens read from cache
"cache_creation": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response using cached prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_creation_tokens(mock_client):
"""Test that OpenAI cache creation tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Create a cache for this large prompt context")]
)
# Mock response with cache creation tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Created cache for the prompt context.",
usage_metadata={
"input_tokens": 2000,
"output_tokens": 25,
"total_tokens": 2025,
"input_token_details": {
"cache_creation": 1500, # 1500 tokens written to cache
"cache_read": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Created cache for the prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 2000
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_cache_creation_input_tokens"] == 1500
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_combined_reasoning_and_cache_tokens(mock_client):
"""Test that both reasoning tokens and cache tokens can be captured together."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think through this cached problem")]
)
# Mock response with both reasoning and cache tokens
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me reason through this using cached context...",
usage_metadata={
"input_tokens": 500,
"output_tokens": 100,
"total_tokens": 600,
"input_token_details": {"cache_read": 300, "cache_creation": 0},
"output_token_details": {"reasoning": 60}, # 60 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me reason through this using cached context..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 500
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 60
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
def test_openai_reasoning_tokens(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
cb = CallbackHandler(
mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id"
)
model.invoke("what is the weather in sf", config={"callbacks": [cb]})
call = mock_client.capture.call_args_list[0][1]
assert call["properties"]["$ai_reasoning_tokens"] is not None
assert call["properties"]["$ai_input_tokens"] is not None
assert call["properties"]["$ai_output_tokens"] is not None
+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.3"
VERSION = "4.9.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+15 -9
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",
@@ -59,16 +68,15 @@ test = [
"django",
"openai",
"anthropic",
"langgraph",
"langchain-community>=0.2.0",
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
]
sentry = ["sentry-sdk", "django"]
langchain = ["langchain>=0.2.0"]
[tool.setuptools]
packages = [
@@ -83,8 +91,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