Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c3cba200 | ||
|
|
9f4ef4f24f | ||
|
|
7aea6b72d3 | ||
|
|
7bb7c90a49 | ||
|
|
c1f668e8bb | ||
|
|
a1b81ee3d9 | ||
|
|
a6fb39902d | ||
|
|
a1583f6627 | ||
|
|
dfa7f70a04 | ||
|
|
d00d69e448 | ||
|
|
a833955ee0 | ||
|
|
58fbe05cb0 | ||
|
|
7a6e185902 | ||
|
|
e9c72e7f8c |
@@ -1,38 +1,43 @@
|
||||
name: 'Release'
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
- workflow_dispatch
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "posthog/version.py"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-20.04
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools wheel twine
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools wheel twine
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
|
||||
+61
-1
@@ -1,3 +1,63 @@
|
||||
## 4.2.0 - 2025-05-22
|
||||
|
||||
Add support for google gemini
|
||||
|
||||
## 4.1.0 - 2025-05-22
|
||||
|
||||
Moved ai openai package to a composition approach over inheritance.
|
||||
|
||||
## 4.0.1 – 2025-04-29
|
||||
|
||||
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
|
||||
2. Clarify Python 3.9+ is required
|
||||
|
||||
## 4.0.0 - 2025-04-24
|
||||
|
||||
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
|
||||
print(result.enabled) # True or False
|
||||
print(result.variant) # 'the-variant-value' or None
|
||||
print(result.payload) # {'foo': 'bar'}
|
||||
print(result.get_value()) # 'the-variant-value' or True or False
|
||||
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
|
||||
```
|
||||
|
||||
Breaking change:
|
||||
|
||||
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
|
||||
|
||||
Before:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
|
||||
```
|
||||
|
||||
## 3.25.0 – 2025-04-15
|
||||
|
||||
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.
|
||||
|
||||
## 3.24.3 – 2025-04-15
|
||||
|
||||
1. Fix hash inclusion/exclusion for flag rollout
|
||||
|
||||
## 3.24.2 – 2025-04-15
|
||||
|
||||
1. Roll out new /flags endpoint to 10% of /decide traffic
|
||||
|
||||
## 3.24.1 – 2025-04-11
|
||||
|
||||
1. Add `log_captured_exceptions` option to proxy setup
|
||||
|
||||
## 3.24.0 – 2025-04-10
|
||||
|
||||
1. Add config option to `log_captured_exceptions`
|
||||
@@ -9,7 +69,7 @@
|
||||
## 3.22.0 – 2025-03-26
|
||||
|
||||
1. Add more information to `$feature_flag_called` events.
|
||||
2. Support for the `/decide?v=3` endpoint which contains more information about feature flags.
|
||||
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
|
||||
|
||||
## 3.21.0 – 2025-03-17
|
||||
|
||||
|
||||
@@ -20,3 +20,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry.
|
||||
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2012 Functional Software, Inc. dba Sentry
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/build
|
||||
#/ Description: Runs linter and mypy
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
flake8 posthog --ignore E501,W503
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/fmt
|
||||
#/ Description: Formats and lints the code
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
if [[ "$1" == "--check" ]]; then
|
||||
black --check .
|
||||
isort --check-only .
|
||||
else
|
||||
black .
|
||||
isort .
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
error() {
|
||||
echo "$@" >&2
|
||||
}
|
||||
|
||||
fatal() {
|
||||
error "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
set_source_and_root_dir() {
|
||||
{ set +x; } 2>/dev/null
|
||||
source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )"
|
||||
root_dir=$(cd "$source_dir" && cd ../ && pwd)
|
||||
cd "$root_dir"
|
||||
}
|
||||
|
||||
ensure_virtual_env() {
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "Virtual environment not activated. Activating now..."
|
||||
if [ ! -f env/bin/activate ]; then
|
||||
echo "Virtual environment not found. Please run 'python -m venv env' first."
|
||||
exit 1
|
||||
fi
|
||||
source env/bin/activate
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/setup
|
||||
#/ Description: Sets up the dependencies needed to develop this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
if [ ! -d "env" ]; then
|
||||
python3 -m venv env
|
||||
fi
|
||||
|
||||
source env/bin/activate
|
||||
pip install -e ".[dev,test]"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/test
|
||||
#/ Description: Runs all the unit tests for this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
ensure_virtual_env
|
||||
|
||||
# Pass through all arguments to pytest
|
||||
pytest "$@"
|
||||
@@ -9,6 +9,7 @@ check_untyped_defs = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
ignore_missing_imports = True
|
||||
exclude = env/.*|venv/.*
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
@@ -26,6 +26,7 @@ super_properties = None # type: Optional[Dict]
|
||||
# Currently alpha, use at your own risk
|
||||
enable_exception_autocapture = False # type: bool
|
||||
exception_autocapture_integrations = [] # type: List[Integrations]
|
||||
log_captured_exceptions = False # type: bool
|
||||
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
|
||||
project_root = None # type: Optional[str]
|
||||
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
|
||||
@@ -593,6 +594,7 @@ def _proxy(method, *args, **kwargs):
|
||||
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
|
||||
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
|
||||
enable_exception_autocapture=enable_exception_autocapture,
|
||||
log_captured_exceptions=log_captured_exceptions,
|
||||
exception_autocapture_integrations=exception_autocapture_integrations,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from .gemini import Client
|
||||
|
||||
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
class _GenAI:
|
||||
Client = Client
|
||||
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = ["Client", "genai"]
|
||||
@@ -0,0 +1,336 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Google Gemini SDK to use this feature: 'pip install google-genai'")
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
A drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
|
||||
|
||||
Usage:
|
||||
client = Client(
|
||||
api_key="your_api_key",
|
||||
posthog_client=posthog_client,
|
||||
posthog_distinct_id="default_user", # Optional defaults
|
||||
posthog_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello world"],
|
||||
posthog_distinct_id="specific_user" # Override default
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
|
||||
posthog_properties: Default properties for all calls (can be overridden per call)
|
||||
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
|
||||
posthog_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self.models = Models(
|
||||
api_key=api_key,
|
||||
posthog_client=posthog_client,
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_properties=posthog_properties,
|
||||
posthog_privacy_mode=posthog_privacy_mode,
|
||||
posthog_groups=posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Models:
|
||||
"""
|
||||
Models interface that mimics genai.Client().models with PostHog tracking.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient # Not None after __init__ validation
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls
|
||||
posthog_properties: Default properties for all calls
|
||||
posthog_privacy_mode: Default privacy mode for all calls
|
||||
posthog_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self._ph_client = posthog_client
|
||||
|
||||
# Store default PostHog settings
|
||||
self._default_distinct_id = posthog_distinct_id
|
||||
self._default_properties = posthog_properties or {}
|
||||
self._default_privacy_mode = posthog_privacy_mode
|
||||
self._default_groups = posthog_groups
|
||||
|
||||
# Handle API key - try parameter first, then environment variables
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
|
||||
)
|
||||
|
||||
self._client = genai.Client(api_key=api_key)
|
||||
self._base_url = "https://generativelanguage.googleapis.com"
|
||||
|
||||
def _merge_posthog_params(
|
||||
self,
|
||||
call_distinct_id: Optional[str],
|
||||
call_trace_id: Optional[str],
|
||||
call_properties: Optional[Dict[str, Any]],
|
||||
call_privacy_mode: Optional[bool],
|
||||
call_groups: Optional[Dict[str, Any]],
|
||||
):
|
||||
"""Merge call-level PostHog parameters with client defaults."""
|
||||
# Use call-level values if provided, otherwise fall back to defaults
|
||||
distinct_id = call_distinct_id if call_distinct_id is not None else self._default_distinct_id
|
||||
privacy_mode = call_privacy_mode if call_privacy_mode is not None else self._default_privacy_mode
|
||||
groups = call_groups if call_groups is not None else self._default_groups
|
||||
|
||||
# Merge properties: default properties + call properties (call properties override)
|
||||
properties = dict(self._default_properties)
|
||||
if call_properties:
|
||||
properties.update(call_properties)
|
||||
|
||||
if call_trace_id is None:
|
||||
call_trace_id = str(uuid.uuid4())
|
||||
|
||||
return distinct_id, call_trace_id, properties, privacy_mode, groups
|
||||
|
||||
def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Generate content using Gemini's API while tracking usage in PostHog.
|
||||
|
||||
This method signature exactly matches genai.Client().models.generate_content()
|
||||
with additional PostHog tracking parameters.
|
||||
|
||||
Args:
|
||||
model: The model to use (e.g., 'gemini-2.0-flash')
|
||||
contents: The input content for generation
|
||||
posthog_distinct_id: ID to associate with the usage event (overrides client default)
|
||||
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
|
||||
posthog_properties: Extra properties to include in the event (merged with client defaults)
|
||||
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
|
||||
posthog_groups: Group analytics properties (overrides client default)
|
||||
**kwargs: Arguments passed to Gemini's generate_content
|
||||
"""
|
||||
# Merge PostHog parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
|
||||
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
|
||||
)
|
||||
|
||||
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
distinct_id,
|
||||
self._ph_client,
|
||||
"gemini",
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
self._base_url,
|
||||
self._client.models.generate_content,
|
||||
**kwargs_with_contents,
|
||||
)
|
||||
|
||||
def _generate_content_streaming(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
response = self._client.models.generate_content_stream(**kwargs_without_stream)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
|
||||
usage_stats = {
|
||||
"input_tokens": getattr(chunk.usage_metadata, "prompt_token_count", 0),
|
||||
"output_tokens": getattr(chunk.usage_metadata, "candidates_token_count", 0),
|
||||
}
|
||||
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
accumulated_content.append(chunk.text)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if trace_id is None:
|
||||
trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "gemini",
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
self._format_input(contents),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": self._base_url,
|
||||
**(properties or {}),
|
||||
}
|
||||
|
||||
if distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._ph_client, "capture"):
|
||||
self._ph_client.capture(
|
||||
distinct_id=distinct_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
def _format_input(self, contents):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
# Merge PostHog parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
|
||||
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
|
||||
)
|
||||
|
||||
return self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
+100
-28
@@ -4,7 +4,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import openai
|
||||
import openai.resources
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
|
||||
|
||||
@@ -29,14 +28,37 @@ class OpenAI(openai.OpenAI):
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
self.responses = WrappedResponses(self)
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses(openai.resources.responses.Responses):
|
||||
_client: OpenAI
|
||||
class WrappedResponses:
|
||||
"""Wrapper for OpenAI responses that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -69,7 +91,7 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -85,7 +107,7 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
final_content = []
|
||||
response = super().create(**kwargs)
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
@@ -195,16 +217,32 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.Chat):
|
||||
_client: OpenAI
|
||||
class WrappedChat:
|
||||
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client)
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions(openai.resources.chat.completions.Completions):
|
||||
_client: OpenAI
|
||||
class WrappedCompletions:
|
||||
"""Wrapper for OpenAI chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -237,7 +275,7 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -257,7 +295,7 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = super().create(**kwargs)
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
@@ -383,8 +421,16 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
_client: OpenAI
|
||||
class WrappedEmbeddings:
|
||||
"""Wrapper for OpenAI embeddings that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -402,6 +448,8 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
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 Embeddings API.
|
||||
|
||||
Returns:
|
||||
@@ -411,7 +459,7 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = super().create(**kwargs)
|
||||
response = self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
@@ -452,24 +500,48 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.Beta):
|
||||
_client: OpenAI
|
||||
class WrappedBeta:
|
||||
"""Wrapper for OpenAI beta features that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.Chat):
|
||||
_client: OpenAI
|
||||
class WrappedBetaChat:
|
||||
"""Wrapper for OpenAI beta chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions):
|
||||
_client: OpenAI
|
||||
class WrappedBetaCompletions:
|
||||
"""Wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
@@ -489,6 +561,6 @@ class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions)
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import openai
|
||||
import openai.resources
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
|
||||
|
||||
@@ -23,19 +22,43 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instance.
|
||||
**openai_config: Additional keyword args (e.g. organization="xxx").
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
self.responses = WrappedResponses(self)
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses(openai.resources.responses.Responses):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedResponses:
|
||||
"""Async wrapper for OpenAI responses that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -68,7 +91,7 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -84,7 +107,7 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
final_content = []
|
||||
response = await super().create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
@@ -186,7 +209,7 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
await self._client._ph_client.capture(
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
@@ -194,16 +217,32 @@ class WrappedResponses(openai.resources.responses.Responses):
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.AsyncChat):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedChat:
|
||||
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client)
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedCompletions:
|
||||
"""Async wrapper for OpenAI chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -237,7 +276,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
self._original.create,
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
@@ -247,21 +286,25 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content = []
|
||||
accumulated_tools = {}
|
||||
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = await super().create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats, accumulated_content, accumulated_tools # noqa: F824
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tools # noqa: F824
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
@@ -280,6 +323,11 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
|
||||
|
||||
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
|
||||
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
if chunk.choices[0].delta and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
@@ -350,6 +398,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
@@ -367,7 +416,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
await self._client._ph_client.capture(
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
@@ -375,8 +424,16 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedEmbeddings:
|
||||
"""Async wrapper for OpenAI embeddings that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -394,8 +451,8 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
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 store input and output in PostHog.
|
||||
posthog_groups: Optional dictionary of groups 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 Embeddings API.
|
||||
|
||||
Returns:
|
||||
@@ -405,7 +462,7 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = await super().create(**kwargs)
|
||||
response = await self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
@@ -446,24 +503,48 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.AsyncBeta):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedBeta:
|
||||
"""Async wrapper for OpenAI beta features that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.AsyncChat):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedBetaChat:
|
||||
"""Async wrapper for OpenAI beta chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncCompletions):
|
||||
_client: AsyncOpenAI
|
||||
class WrappedBetaCompletions:
|
||||
"""Async wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
@@ -483,6 +564,6 @@ class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncComplet
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
try:
|
||||
import openai
|
||||
import openai.resources
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Open AI SDK to use this feature: 'pip install openai'")
|
||||
|
||||
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings
|
||||
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings, WrappedResponses
|
||||
from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
|
||||
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
|
||||
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
|
||||
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
@@ -19,23 +19,70 @@ class AzureOpenAI(openai.AzureOpenAI):
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
"""
|
||||
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = AsyncWrappedChat(self)
|
||||
self.embeddings = AsyncWrappedEmbeddings(self)
|
||||
self.beta = AsyncWrappedBeta(self)
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = AsyncWrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = AsyncWrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = AsyncWrappedBeta(self, self._original_beta)
|
||||
|
||||
# Only add responses if available (newer OpenAI versions)
|
||||
if self._original_responses is not None:
|
||||
self.responses = AsyncWrappedResponses(self, self._original_responses)
|
||||
|
||||
+69
-2
@@ -73,6 +73,21 @@ def get_usage(response, provider: str) -> Dict[str, Any]:
|
||||
"cache_read_input_tokens": cached_tokens,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
}
|
||||
elif provider == "gemini":
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
|
||||
if hasattr(response, "usage_metadata") and response.usage_metadata:
|
||||
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
|
||||
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
return {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
@@ -93,6 +108,8 @@ def format_response(response, provider: str):
|
||||
return format_response_anthropic(response)
|
||||
elif provider == "openai":
|
||||
return format_response_openai(response)
|
||||
elif provider == "gemini":
|
||||
return format_response_gemini(response)
|
||||
return output
|
||||
|
||||
|
||||
@@ -170,6 +187,40 @@ def format_response_openai(response):
|
||||
return output
|
||||
|
||||
|
||||
def format_response_gemini(response):
|
||||
output = []
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
for candidate in response.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
content_text = ""
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content_text += part.text
|
||||
if content_text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content_text,
|
||||
}
|
||||
)
|
||||
elif hasattr(candidate, "text") and candidate.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": candidate.text,
|
||||
}
|
||||
)
|
||||
elif hasattr(response, "text") and response.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": response.text,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def format_tool_calls(response, provider: str):
|
||||
if provider == "anthropic":
|
||||
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
|
||||
@@ -198,6 +249,22 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
if kwargs.get("system") is None:
|
||||
return messages
|
||||
return [{"role": "system", "content": kwargs.get("system")}] + messages
|
||||
elif provider == "gemini":
|
||||
contents = kwargs.get("contents", [])
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
|
||||
# For OpenAI, handle both Chat Completions and Responses API
|
||||
if kwargs.get("messages") is not None:
|
||||
@@ -271,7 +338,7 @@ def call_llm_and_track_usage(
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if response and hasattr(response, "usage"):
|
||||
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
@@ -366,7 +433,7 @@ async def call_llm_and_track_usage_async(
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if response and hasattr(response, "usage"):
|
||||
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
|
||||
+260
-97
@@ -1,4 +1,5 @@
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
import numbers
|
||||
import os
|
||||
@@ -18,14 +19,24 @@ from posthog.exception_capture import ExceptionCapture
|
||||
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||||
from posthog.poller import Poller
|
||||
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
|
||||
from posthog.request import (
|
||||
DEFAULT_HOST,
|
||||
APIError,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
flags,
|
||||
get,
|
||||
remote_config,
|
||||
)
|
||||
from posthog.types import (
|
||||
DecideResponse,
|
||||
FeatureFlag,
|
||||
FeatureFlagResult,
|
||||
FlagMetadata,
|
||||
FlagsAndPayloads,
|
||||
FlagsResponse,
|
||||
FlagValue,
|
||||
normalize_decide_response,
|
||||
normalize_flags_response,
|
||||
to_flags_and_payloads,
|
||||
to_payloads,
|
||||
to_values,
|
||||
@@ -42,6 +53,76 @@ 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():
|
||||
"""
|
||||
@@ -97,6 +178,43 @@ 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."""
|
||||
|
||||
@@ -263,7 +381,7 @@ class Client(object):
|
||||
"""
|
||||
Get feature flag variants for a distinct_id by calling decide.
|
||||
"""
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return to_values(resp_data) or {}
|
||||
|
||||
def get_feature_payloads(
|
||||
@@ -272,7 +390,7 @@ class Client(object):
|
||||
"""
|
||||
Get feature flag payloads for a distinct_id by calling decide.
|
||||
"""
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return to_payloads(resp_data) or {}
|
||||
|
||||
def get_feature_flags_and_payloads(
|
||||
@@ -281,12 +399,15 @@ class Client(object):
|
||||
"""
|
||||
Get feature flags and payloads for a distinct_id by calling decide.
|
||||
"""
|
||||
resp = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
resp = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return to_flags_and_payloads(resp)
|
||||
|
||||
def get_decide(
|
||||
def get_flags_decision(
|
||||
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||||
) -> DecideResponse:
|
||||
) -> FlagsResponse:
|
||||
"""
|
||||
Get feature flags decision, using either flags() or decide() API based on rollout.
|
||||
"""
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
if disable_geoip is None:
|
||||
@@ -304,9 +425,21 @@ class Client(object):
|
||||
"group_properties": group_properties,
|
||||
"disable_geoip": disable_geoip,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
|
||||
|
||||
return normalize_decide_response(resp_data)
|
||||
use_flags = is_token_in_rollout(
|
||||
self.api_key, ROLLOUT_PERCENTAGE, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
|
||||
)
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -807,6 +940,95 @@ class Client(object):
|
||||
return None
|
||||
return bool(response)
|
||||
|
||||
def _get_feature_flag_result(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
override_match_value: Optional[FlagValue] = None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
) -> Optional[FeatureFlagResult]:
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
|
||||
flag_result = None
|
||||
flag_details = None
|
||||
request_id = None
|
||||
|
||||
flag_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
|
||||
flag_was_locally_evaluated = flag_value is not None
|
||||
|
||||
if flag_was_locally_evaluated:
|
||||
lookup_match_value = override_match_value or flag_value
|
||||
payload = self._compute_payload_locally(key, lookup_match_value) if lookup_match_value else None
|
||||
flag_result = FeatureFlagResult.from_value_and_payload(key, lookup_match_value, payload)
|
||||
elif not only_evaluate_locally:
|
||||
try:
|
||||
flag_details, request_id = self._get_feature_flag_details_from_decide(
|
||||
key, distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
flag_result = FeatureFlagResult.from_flag_details(flag_details, override_match_value)
|
||||
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}")
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||||
|
||||
if send_feature_flag_events:
|
||||
self._capture_feature_flag_called(
|
||||
distinct_id,
|
||||
key,
|
||||
flag_result.get_value() if flag_result else None,
|
||||
flag_result.payload if flag_result else None,
|
||||
flag_was_locally_evaluated,
|
||||
groups,
|
||||
disable_geoip,
|
||||
request_id,
|
||||
flag_details,
|
||||
)
|
||||
|
||||
return flag_result
|
||||
|
||||
def get_feature_flag_result(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
) -> Optional[FeatureFlagResult]:
|
||||
"""
|
||||
Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely
|
||||
depending on whether local evaluation is enabled and the flag can be locally evaluated.
|
||||
|
||||
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
|
||||
"""
|
||||
return self._get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
def get_feature_flag(
|
||||
self,
|
||||
key,
|
||||
@@ -826,47 +1048,17 @@ class Client(object):
|
||||
|
||||
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
|
||||
"""
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
feature_flag_result = self.get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
|
||||
|
||||
flag_details = None
|
||||
request_id = None
|
||||
|
||||
flag_was_locally_evaluated = response is not None
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
try:
|
||||
flag_details, request_id = self._get_feature_flag_details_from_decide(
|
||||
key, distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
response = flag_details.get_value() if flag_details else False
|
||||
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||||
|
||||
if send_feature_flag_events:
|
||||
self._capture_feature_flag_called(
|
||||
distinct_id,
|
||||
key,
|
||||
response or False,
|
||||
None,
|
||||
flag_was_locally_evaluated,
|
||||
groups,
|
||||
disable_geoip,
|
||||
request_id,
|
||||
flag_details,
|
||||
)
|
||||
|
||||
return response
|
||||
return feature_flag_result.get_value() if feature_flag_result else None
|
||||
|
||||
def _locally_evaluate_flag(
|
||||
self,
|
||||
@@ -907,7 +1099,7 @@ class Client(object):
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
match_value=None,
|
||||
match_value: Optional[FlagValue] = None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
@@ -915,48 +1107,18 @@ class Client(object):
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
if match_value is None:
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
match_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
|
||||
|
||||
response = None
|
||||
payload = None
|
||||
flag_details = None
|
||||
request_id = None
|
||||
|
||||
if match_value is not None:
|
||||
payload = self._compute_payload_locally(key, match_value)
|
||||
|
||||
flag_was_locally_evaluated = payload is not None
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
try:
|
||||
flag_details, request_id = self._get_feature_flag_details_from_decide(
|
||||
key, distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
payload = flag_details.metadata.payload if flag_details else None
|
||||
response = flag_details.get_value() if flag_details else False
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
if send_feature_flag_events:
|
||||
self._capture_feature_flag_called(
|
||||
distinct_id,
|
||||
key,
|
||||
response or False,
|
||||
payload,
|
||||
flag_was_locally_evaluated,
|
||||
groups,
|
||||
disable_geoip,
|
||||
request_id,
|
||||
flag_details,
|
||||
)
|
||||
|
||||
return payload
|
||||
feature_flag_result = self._get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
override_match_value=match_value,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
return feature_flag_result.payload if feature_flag_result else None
|
||||
|
||||
def _get_feature_flag_details_from_decide(
|
||||
self,
|
||||
@@ -970,7 +1132,7 @@ class Client(object):
|
||||
"""
|
||||
Calls /decide and returns the flag details and request id
|
||||
"""
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
request_id = resp_data.get("requestId")
|
||||
flags = resp_data.get("flags")
|
||||
flag_details = flags.get(key) if flags else None
|
||||
@@ -980,7 +1142,7 @@ class Client(object):
|
||||
self,
|
||||
distinct_id: str,
|
||||
key: str,
|
||||
response: FlagValue,
|
||||
response: Optional[FlagValue],
|
||||
payload: Optional[str],
|
||||
flag_was_locally_evaluated: bool,
|
||||
groups: dict[str, str],
|
||||
@@ -988,7 +1150,7 @@ class Client(object):
|
||||
request_id: Optional[str],
|
||||
flag_details: Optional[FeatureFlag],
|
||||
):
|
||||
feature_flag_reported_key = f"{key}_{str(response)}"
|
||||
feature_flag_reported_key = f"{key}_{'::null::' if response is None else str(response)}"
|
||||
|
||||
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
|
||||
properties: dict[str, Any] = {
|
||||
@@ -999,6 +1161,7 @@ class Client(object):
|
||||
}
|
||||
|
||||
if payload:
|
||||
# if payload is not a string, json serialize it to a string
|
||||
properties["$feature_flag_payload"] = payload
|
||||
|
||||
if request_id:
|
||||
@@ -1101,7 +1264,7 @@ class Client(object):
|
||||
|
||||
if fallback_to_decide and not only_evaluate_locally:
|
||||
try:
|
||||
decide_response = self.get_decide(
|
||||
decide_response = self.get_flags_decision(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
import backoff
|
||||
import monotonic
|
||||
|
||||
from posthog.request import APIError, DatetimeSerializer, batch_post
|
||||
|
||||
@@ -96,11 +96,11 @@ class Consumer(Thread):
|
||||
queue = self.queue
|
||||
items = []
|
||||
|
||||
start_time = monotonic.monotonic()
|
||||
start_time = time.monotonic()
|
||||
total_size = 0
|
||||
|
||||
while len(items) < self.flush_at:
|
||||
elapsed = monotonic.monotonic() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= self.flush_interval:
|
||||
break
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# 💖open source (under MIT License)
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# 💖open source (under MIT License)
|
||||
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
|
||||
# 💖open source (under MIT License)
|
||||
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
||||
|
||||
+7
-1
@@ -52,7 +52,7 @@ def post(
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + path
|
||||
body["api_key"] = api_key
|
||||
data = json.dumps(body, cls=DatetimeSerializer)
|
||||
log.debug("making request: %s", data)
|
||||
log.debug("making request: %s to url: %s", data, url)
|
||||
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
||||
if gzip:
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
@@ -106,6 +106,12 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
|
||||
return _process_response(res, success_message="Feature flags decided successfully")
|
||||
|
||||
|
||||
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
|
||||
"""Post the `kwargs to the flags API endpoint"""
|
||||
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags evaluated successfully")
|
||||
|
||||
|
||||
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
|
||||
"""Get remote config flag value from remote_config API endpoint"""
|
||||
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)
|
||||
|
||||
@@ -3,12 +3,21 @@ import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from anthropic.types import Message, Usage
|
||||
|
||||
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
|
||||
try:
|
||||
from anthropic.types import Message, Usage
|
||||
|
||||
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
|
||||
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
||||
|
||||
# Skip all tests if Anthropic is not available
|
||||
pytestmark = pytest.mark.skipif(not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from google import genai as google_genai
|
||||
|
||||
from posthog.ai.gemini import Client
|
||||
|
||||
GEMINI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GEMINI_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not GEMINI_AVAILABLE, reason="Google Gemini package is not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
mock_client.privacy_mode = False
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response():
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response from Gemini"
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 20
|
||||
mock_usage.candidates_token_count = 10
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.text = "Test response from Gemini"
|
||||
mock_content = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = "Test response from Gemini"
|
||||
mock_content.parts = [mock_part]
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_google_genai_client():
|
||||
"""Mock for the new google-genai Client"""
|
||||
with patch.object(google_genai, "Client") as mock_client_class:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models = MagicMock()
|
||||
mock_client_instance.models = mock_models
|
||||
mock_client_class.return_value = mock_client_instance
|
||||
yield mock_client_instance
|
||||
|
||||
|
||||
def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test the new Client/Models API structure"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Tell me a fun fact about hedgehogs"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_gemini_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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["foo"] == "bar"
|
||||
assert "$ai_trace_id" in props
|
||||
assert props["$ai_latency"] > 0
|
||||
|
||||
|
||||
def test_new_client_streaming_with_generate_content_stream(mock_client, mock_google_genai_client):
|
||||
"""Test the new generate_content_stream method"""
|
||||
|
||||
def mock_streaming_response():
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "Hello "
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 10
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "world!"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 10
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
|
||||
yield mock_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the generate_content_stream method
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = mock_streaming_response()
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Write a short story"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"feature": "streaming"},
|
||||
)
|
||||
|
||||
chunks = list(response)
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].text == "Hello "
|
||||
assert chunks[1].text == "world!"
|
||||
|
||||
# Check that the streaming event was captured
|
||||
assert mock_client.capture.call_count == 1
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["feature"] == "streaming"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test groups functionality with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_groups={"company": "company_123"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["groups"] == {"company": "company_123"}
|
||||
|
||||
|
||||
def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test local privacy mode with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=True,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test global privacy mode with new Client API"""
|
||||
mock_client.privacy_mode = True
|
||||
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_new_client_different_input_formats(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test different input formats with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Test string input
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id")
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Test list input
|
||||
mock_client.capture.reset_mock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = "List item"
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id")
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "List item"}]
|
||||
|
||||
|
||||
def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test model parameters with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
temperature=0.7,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_model_parameters"]["temperature"] == 0.7
|
||||
assert props["$ai_model_parameters"]["max_tokens"] == 100
|
||||
|
||||
|
||||
def test_new_client_default_settings(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test client with default PostHog settings"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Call without overriding defaults
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"])
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "default_user"
|
||||
assert call_args["groups"] == {"company": "acme_corp"}
|
||||
assert props["team"] == "ai"
|
||||
|
||||
|
||||
def test_new_client_override_defaults(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test overriding client defaults per call"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Override defaults in call
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="specific_user",
|
||||
posthog_properties={"feature": "chat", "urgent": True},
|
||||
posthog_privacy_mode=True,
|
||||
posthog_groups={"organization": "special_org"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check overrides
|
||||
assert call_args["distinct_id"] == "specific_user"
|
||||
assert call_args["groups"] == {"organization": "special_org"}
|
||||
assert props["$ai_input"] is None # privacy mode was overridden
|
||||
|
||||
# Check merged properties (defaults + call-specific)
|
||||
assert props["team"] == "ai" # from defaults
|
||||
assert props["feature"] == "chat" # from call
|
||||
assert props["urgent"] is True # from call
|
||||
@@ -8,19 +8,42 @@ from typing import List, Literal, Optional, TypedDict, Union
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from langchain_anthropic.chat_models import ChatAnthropic
|
||||
from langchain_community.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.llms.fake import FakeListLLM, FakeStreamingListLLM
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai.chat_models import ChatOpenAI
|
||||
from langgraph.graph.state import END, START, StateGraph
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
from posthog.ai.langchain import CallbackHandler
|
||||
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
|
||||
try:
|
||||
from langchain_anthropic.chat_models import ChatAnthropic
|
||||
from langchain_community.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.llms.fake import FakeListLLM, FakeStreamingListLLM
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai.chat_models import ChatOpenAI
|
||||
from langgraph.graph.state import END, START, StateGraph
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
from posthog.ai.langchain import CallbackHandler
|
||||
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
|
||||
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
except ImportError:
|
||||
|
||||
class FakeListLLM:
|
||||
pass
|
||||
|
||||
class FakeStreamingListLLM:
|
||||
pass
|
||||
|
||||
class HumanMessage:
|
||||
pass
|
||||
|
||||
class AIMessage:
|
||||
pass
|
||||
|
||||
LANGCHAIN_AVAILABLE = False
|
||||
|
||||
|
||||
# Skip all tests if LangChain is not available
|
||||
pytestmark = pytest.mark.skipif(not LANGCHAIN_AVAILABLE, reason="LangChain package is not available")
|
||||
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
||||
@@ -618,89 +641,57 @@ def test_graph_state(mock_client):
|
||||
assert isinstance(result["messages"][2], AIMessage)
|
||||
assert result["messages"][2].content == "It's a type of greeble."
|
||||
|
||||
assert mock_client.capture.call_count == 12
|
||||
assert mock_client.capture.call_count == 6
|
||||
calls = [call[1] for call in mock_client.capture.call_args_list]
|
||||
|
||||
trace_args = calls[11]
|
||||
trace_props = calls[11]["properties"]
|
||||
# The trace event is captured at the end
|
||||
trace_args = calls[-1]
|
||||
trace_props = calls[-1]["properties"]
|
||||
|
||||
# Events are captured in the reverse order.
|
||||
# Check all trace_ids
|
||||
for call in calls:
|
||||
assert call["properties"]["$ai_trace_id"] == trace_props["$ai_trace_id"]
|
||||
|
||||
# First span, write the state
|
||||
assert calls[0]["event"] == "$ai_span"
|
||||
assert calls[0]["properties"]["$ai_parent_id"] == calls[2]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[0]["properties"]
|
||||
assert calls[0]["properties"]["$ai_input_state"] == initial_state
|
||||
assert calls[0]["properties"]["$ai_output_state"] == initial_state
|
||||
|
||||
# Second span, set the START node
|
||||
assert calls[1]["event"] == "$ai_span"
|
||||
assert calls[1]["properties"]["$ai_parent_id"] == calls[2]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[1]["properties"]
|
||||
assert calls[1]["properties"]["$ai_input_state"] == initial_state
|
||||
assert calls[1]["properties"]["$ai_output_state"] == initial_state
|
||||
|
||||
# Third span, finish initialization
|
||||
assert calls[2]["event"] == "$ai_span"
|
||||
assert "$ai_span_id" in calls[2]["properties"]
|
||||
assert calls[2]["properties"]["$ai_span_name"] == START
|
||||
assert calls[2]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
assert calls[2]["properties"]["$ai_input_state"] == initial_state
|
||||
assert calls[2]["properties"]["$ai_output_state"] == initial_state
|
||||
|
||||
# Fourth span, save the value of fake_plain during its execution
|
||||
# 1. Span, finish initialization
|
||||
second_state = {
|
||||
"messages": [HumanMessage(content="What's a bar?"), AIMessage(content="Let's explore bar.")],
|
||||
"xyz": "abc",
|
||||
}
|
||||
|
||||
assert calls[4]["event"] == "$ai_span"
|
||||
assert calls[4]["properties"]["$ai_parent_id"] == calls[5]["properties"]["$ai_span_id"]
|
||||
# 1. Span - the fake_plain node, which doesn't do anything
|
||||
assert calls[0]["event"] == "$ai_span"
|
||||
assert calls[0]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
assert "$ai_span_id" in calls[0]["properties"]
|
||||
assert calls[0]["properties"]["$ai_span_name"] == "fake_plain"
|
||||
assert calls[0]["properties"]["$ai_input_state"] == initial_state
|
||||
assert calls[0]["properties"]["$ai_output_state"] == second_state
|
||||
|
||||
# 2. Span - the ChatPromptTemplate within fake_llm's FakeMessagesListChatModel
|
||||
assert calls[1]["event"] == "$ai_span"
|
||||
assert calls[1]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[1]["properties"]
|
||||
assert calls[1]["properties"]["$ai_span_name"] == "ChatPromptTemplate"
|
||||
|
||||
# 3. Generation - the FakeMessagesListChatModel within fake_llm's RunnableSequence
|
||||
assert calls[2]["event"] == "$ai_generation"
|
||||
assert calls[2]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[2]["properties"]
|
||||
assert calls[2]["properties"]["$ai_span_name"] == "FakeMessagesListChatModel"
|
||||
|
||||
# 4. Span - RunnableSequence within fake_llm
|
||||
assert calls[3]["event"] == "$ai_span"
|
||||
assert calls[3]["properties"]["$ai_parent_id"] == calls[4]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[3]["properties"]
|
||||
assert calls[4]["properties"]["$ai_input_state"] == second_state
|
||||
assert calls[4]["properties"]["$ai_output_state"] == second_state
|
||||
assert calls[3]["properties"]["$ai_span_name"] == "RunnableSequence"
|
||||
|
||||
# Fifth span, run the fake_plain node
|
||||
assert calls[5]["event"] == "$ai_span"
|
||||
# 5. Span - the fake_llm node
|
||||
assert calls[4]["event"] == "$ai_span"
|
||||
assert calls[4]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
assert "$ai_span_id" in calls[4]["properties"]
|
||||
assert calls[5]["properties"]["$ai_span_name"] == "fake_plain"
|
||||
assert calls[5]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
assert calls[5]["properties"]["$ai_input_state"] == initial_state
|
||||
assert calls[5]["properties"]["$ai_output_state"] == second_state
|
||||
assert calls[4]["properties"]["$ai_span_name"] == "fake_llm"
|
||||
|
||||
# Sixth span, chat prompt template
|
||||
assert calls[6]["event"] == "$ai_span"
|
||||
assert calls[6]["properties"]["$ai_parent_id"] == calls[8]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[6]["properties"]
|
||||
assert calls[6]["properties"]["$ai_span_name"] == "ChatPromptTemplate"
|
||||
|
||||
# 7. Generation, fake_llm
|
||||
assert calls[7]["event"] == "$ai_generation"
|
||||
assert calls[7]["properties"]["$ai_parent_id"] == calls[8]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[7]["properties"]
|
||||
assert calls[7]["properties"]["$ai_span_name"] == "FakeMessagesListChatModel"
|
||||
|
||||
# 8. Span, RunnableSequence
|
||||
assert calls[8]["event"] == "$ai_span"
|
||||
assert calls[8]["properties"]["$ai_parent_id"] == calls[10]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[8]["properties"]
|
||||
assert calls[8]["properties"]["$ai_span_name"] == "RunnableSequence"
|
||||
|
||||
# 9. Span, fake_llm write
|
||||
assert calls[9]["event"] == "$ai_span"
|
||||
assert calls[9]["properties"]["$ai_parent_id"] == calls[10]["properties"]["$ai_span_id"]
|
||||
assert "$ai_span_id" in calls[9]["properties"]
|
||||
|
||||
# 10. Span, fake_llm node
|
||||
assert calls[10]["event"] == "$ai_span"
|
||||
assert calls[10]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
|
||||
assert "$ai_span_id" in calls[10]["properties"]
|
||||
assert calls[10]["properties"]["$ai_span_name"] == "fake_llm"
|
||||
|
||||
# 11. Trace
|
||||
# 6. Trace
|
||||
assert trace_args["event"] == "$ai_trace"
|
||||
assert trace_props["$ai_span_name"] == "LangGraph"
|
||||
|
||||
|
||||
@@ -3,18 +3,27 @@ import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
|
||||
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
|
||||
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
|
||||
from openai.types.embedding import Embedding
|
||||
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
try:
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
|
||||
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
|
||||
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
|
||||
from openai.types.embedding import Embedding
|
||||
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AVAILABLE = False
|
||||
|
||||
# Skip all tests if OpenAI is not available
|
||||
pytestmark = pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI package is not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
+113
-54
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
@@ -7,7 +8,7 @@ import mock
|
||||
import six
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, LegacyFlagMetadata
|
||||
@@ -270,9 +271,9 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(logs.output[0], "ERROR:posthog:test exception\nNoneType: None")
|
||||
self.assertEqual(getattr(logs.records[0], "path"), "one/two/three")
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_basic_capture_with_feature_flags(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
|
||||
@@ -289,11 +290,11 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
|
||||
multivariate_flag = {
|
||||
@@ -379,7 +380,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
|
||||
assert "$feature/beta-feature" not in msg["properties"]
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
# test that flags are not evaluated without local evaluation
|
||||
client.feature_flags = []
|
||||
@@ -411,9 +412,9 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(client.cohorts, {})
|
||||
self.assertIn("PostHog feature flags quota limited", logs.output[0])
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_dont_override_capture_with_local_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_dont_override_capture_with_local_flags(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
|
||||
multivariate_flag = {
|
||||
@@ -486,11 +487,11 @@ class TestClient(unittest.TestCase):
|
||||
assert "$feature/beta-feature" not in msg["properties"]
|
||||
assert "$feature/person-flag" not in msg["properties"]
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
|
||||
@@ -511,8 +512,8 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
patch_decide.assert_called_with(
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
@@ -523,9 +524,9 @@ class TestClient(unittest.TestCase):
|
||||
disable_geoip=True,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(self, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
|
||||
@@ -553,8 +554,8 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
patch_decide.assert_called_with(
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"https://us.i.posthog.com",
|
||||
timeout=12,
|
||||
@@ -565,9 +566,9 @@ class TestClient(unittest.TestCase):
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=False)
|
||||
@@ -584,7 +585,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertTrue("$feature/beta-feature" not in msg["properties"])
|
||||
self.assertTrue("$active_feature_flags" not in msg["properties"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
def test_stringifies_distinct_id(self):
|
||||
# A large number that loses precision in node:
|
||||
@@ -941,29 +942,29 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg, "disabled")
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disabled_with_feature_flags(self, patch_decide):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_disabled_with_feature_flags(self, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
|
||||
|
||||
response = client.get_feature_flag("beta-feature", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
response = client.feature_enabled("beta-feature", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
response = client.get_all_flags("12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
response = client.get_feature_flag_payload("key", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
response = client.get_all_flags_and_payloads("12345")
|
||||
self.assertEqual(response, {"featureFlags": None, "featureFlagPayloads": None})
|
||||
patch_decide.assert_not_called()
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
# no capture calls
|
||||
self.assertTrue(client.queue.empty())
|
||||
@@ -1011,14 +1012,14 @@ class TestClient(unittest.TestCase):
|
||||
client.flush()
|
||||
self.assertTrue("$geoip_disable" not in msg["properties"])
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disable_geoip_default_on_decide(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_disable_geoip_default_on_decide(self, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
|
||||
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1028,9 +1029,9 @@ class TestClient(unittest.TestCase):
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1040,9 +1041,9 @@ class TestClient(unittest.TestCase):
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
client.get_all_flags_and_payloads("all_flags_payloads_id")
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1065,9 +1066,9 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertFalse(client.feature_enabled("example", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_default_properties_get_added_properly(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_default_properties_get_added_properly(self, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
client = Client(FAKE_TEST_API_KEY, host="http://app2.posthog.com", on_error=self.set_fail, disable_geoip=False)
|
||||
@@ -1078,7 +1079,7 @@ class TestClient(unittest.TestCase):
|
||||
person_properties={"x1": "y1"},
|
||||
group_properties={"company": {"x": "y"}},
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1092,7 +1093,7 @@ class TestClient(unittest.TestCase):
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
client.get_feature_flag(
|
||||
"random_key",
|
||||
"some_id",
|
||||
@@ -1104,7 +1105,7 @@ class TestClient(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1118,10 +1119,10 @@ class TestClient(unittest.TestCase):
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
# test nones
|
||||
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
|
||||
patch_decide.assert_called_with(
|
||||
patch_flags.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
@@ -1221,9 +1222,9 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
assert context == expected_context
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_decide_returns_normalized_decide_response(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_decide_returns_normalized_decide_response(self, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False},
|
||||
"featureFlagPayloads": {"beta-feature": '{"some": "data"}'},
|
||||
"errorsWhileComputingFlags": False,
|
||||
@@ -1235,7 +1236,7 @@ class TestClient(unittest.TestCase):
|
||||
groups = {"test_group_type": "test_group_id"}
|
||||
person_properties = {"test_property": "test_value"}
|
||||
|
||||
response = client.get_decide(distinct_id, groups, person_properties)
|
||||
response = client.get_flags_decision(distinct_id, groups, person_properties)
|
||||
|
||||
assert response == {
|
||||
"flags": {
|
||||
@@ -1270,3 +1271,61 @@ 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))
|
||||
|
||||
@@ -69,8 +69,8 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
resp = {"code": "user_in_segment"}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertEqual(reason.condition_index, 0) # default value
|
||||
self.assertEqual(reason.description, "") # default value
|
||||
self.assertIsNone(reason.condition_index) # default value
|
||||
self.assertEqual(reason.description, "")
|
||||
|
||||
# Test with None
|
||||
self.assertIsNone(FlagReason.from_json(None))
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
|
||||
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
def test_from_bool_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]")
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, [1, 2, 3])
|
||||
|
||||
def test_from_false_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}')
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true")
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, True)
|
||||
|
||||
def test_from_none_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}')
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_boolean_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_variant_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control")
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_boolean_flag_details_with_override_false_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_none_flag_details(self):
|
||||
result = FeatureFlagResult.from_flag_details(None)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_flag_details_with_none_payload(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertIsNone(result.payload)
|
||||
|
||||
|
||||
class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||||
cls.capture_patch.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.capture_patch.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)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": "300"},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "variant-1", "rollout_percentage": 50},
|
||||
{"key": "variant-2", "rollout_percentage": 50},
|
||||
]
|
||||
},
|
||||
"payloads": {"variant-1": '{"some": "value"}'},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "distinct_id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, {"some": "value"})
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_payload": {"some": "value"},
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
another_flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(another_flag_result.enabled, True)
|
||||
self.assertEqual(another_flag_result.variant, "variant-2")
|
||||
self.assertEqual(another_flag_result.get_value(), "variant-2")
|
||||
self.assertIsNone(another_flag_result.payload)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"another-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-2",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-2",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id")
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": "variant-1",
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"version": 2,
|
||||
"payload": "[1, 2, 3]",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, [1, 2, 3])
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 2,
|
||||
"$feature_flag_payload": [1, 2, 3],
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "no-person-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/no-person-flag": None,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
+190
-177
@@ -121,9 +121,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "sun"})
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_flag_group_properties(self, patch_get, patch_decide):
|
||||
def test_flag_group_properties(self, patch_get, patch_flags):
|
||||
self.client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -193,10 +193,10 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
group_properties={"company": {"name": "Project Name 2"}},
|
||||
)
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
# Now group type mappings are gone, so fall back to /decide/
|
||||
patch_decide.return_value = {"featureFlags": {"group-flag": "decide-fallback-value"}}
|
||||
patch_flags.return_value = {"featureFlags": {"group-flag": "decide-fallback-value"}}
|
||||
|
||||
self.client.group_type_mapping = {}
|
||||
self.assertEqual(
|
||||
@@ -209,12 +209,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"decide-fallback-value",
|
||||
)
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_flag_with_complex_definition(self, patch_get, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"complex-flag": "decide-fallback-value"}}
|
||||
def test_flag_with_complex_definition(self, patch_get, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"complex-flag": "decide-fallback-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -273,7 +273,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"complex-flag", "some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
|
||||
)
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
# this distinctIDs hash is < rollout %
|
||||
self.assertTrue(
|
||||
@@ -283,7 +283,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
person_properties={"region": "USA", "email": "a@b.com"},
|
||||
)
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
# will fall back on `/decide`, as all properties present for second group, but that group resolves to false
|
||||
self.assertEqual(
|
||||
@@ -294,27 +294,27 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
),
|
||||
"decide-fallback-value",
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
# same as above
|
||||
self.assertEqual(
|
||||
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"doesnt_matter": "1"}),
|
||||
"decide-fallback-value",
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
# this one will need to fall back
|
||||
self.assertEqual(
|
||||
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"region": "USA"}),
|
||||
"decide-fallback-value",
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
# won't need to fall back when all values are present
|
||||
self.assertFalse(
|
||||
@@ -324,12 +324,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
person_properties={"region": "USA", "email": "a@b.com", "name": "X", "doesnt_matter": "1"},
|
||||
)
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_fallback_to_decide(self, patch_get, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
|
||||
def test_feature_flags_fallback_to_decide(self, patch_get, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -373,18 +373,18 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id")
|
||||
|
||||
self.assertEqual(feature_flag_match, "alakazam")
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
# beta-feature2 fallbacks to decide because region property not given with call
|
||||
feature_flag_match = client.get_feature_flag("beta-feature2", "some-distinct-id")
|
||||
|
||||
self.assertEqual(feature_flag_match, "alakazam2")
|
||||
self.assertEqual(patch_decide.call_count, 2)
|
||||
self.assertEqual(patch_flags.call_count, 2)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_dont_fallback_to_decide_when_only_local_evaluation_is_true(self, patch_get, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
|
||||
def test_feature_flags_dont_fallback_to_decide_when_only_local_evaluation_is_true(self, patch_get, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -429,12 +429,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id", only_evaluate_locally=True)
|
||||
|
||||
self.assertEqual(feature_flag_match, None)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
feature_flag_match = client.feature_enabled("beta-feature", "some-distinct-id", only_evaluate_locally=True)
|
||||
|
||||
self.assertEqual(feature_flag_match, None)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
# beta-feature2 should fallback to decide because region property not given with call
|
||||
# but doesn't because only_evaluate_locally is true
|
||||
@@ -444,12 +444,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
feature_flag_match = client.feature_enabled("beta-feature2", "some-distinct-id", only_evaluate_locally=True)
|
||||
self.assertEqual(feature_flag_match, None)
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flag_never_returns_undefined_during_regular_evaluation(self, patch_get, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {}}
|
||||
def test_feature_flag_never_returns_undefined_during_regular_evaluation(self, patch_get, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -474,28 +474,28 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
|
||||
# beta-feature2 falls back to decide, and whatever decide returns is the value
|
||||
self.assertFalse(client.get_feature_flag("beta-feature2", "some-distinct-id"))
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
self.assertFalse(client.feature_enabled("beta-feature2", "some-distinct-id"))
|
||||
self.assertEqual(patch_decide.call_count, 2)
|
||||
self.assertEqual(patch_flags.call_count, 2)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flag_return_none_when_decide_errors_out(self, patch_get, patch_decide):
|
||||
patch_decide.side_effect = APIError(400, "Decide error")
|
||||
def test_feature_flag_return_none_when_decide_errors_out(self, patch_get, patch_flags):
|
||||
patch_flags.side_effect = APIError(400, "Decide error")
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = []
|
||||
|
||||
# beta-feature2 falls back to decide, which on error returns None
|
||||
self.assertIsNone(client.get_feature_flag("beta-feature2", "some-distinct-id"))
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
self.assertIsNone(client.feature_enabled("beta-feature2", "some-distinct-id"))
|
||||
self.assertEqual(patch_decide.call_count, 2)
|
||||
self.assertEqual(patch_flags.call_count, 2)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_experience_continuity_flag_not_evaluated_locally(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "decide-fallback-value"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_experience_continuity_flag_not_evaluated_locally(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "decide-fallback-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -517,12 +517,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
# decide called always because experience_continuity is set
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_with_fallback(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_with_fallback(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2", "disabled-feature": False}
|
||||
} # decide should return the same flags
|
||||
client = self.client
|
||||
@@ -576,13 +576,13 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.get_all_flags("distinct_id"),
|
||||
{"beta-feature": "variant-1", "beta-feature2": "variant-2", "disabled-feature": False},
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_and_payloads_with_fallback(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||||
}
|
||||
@@ -649,26 +649,26 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"beta-feature2": 300,
|
||||
},
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_with_fallback_empty_local_flags(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
client = self.client
|
||||
client.feature_flags = []
|
||||
# beta-feature value overridden by /decide
|
||||
self.assertEqual(
|
||||
client.get_all_flags("distinct_id"), {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||||
}
|
||||
@@ -679,13 +679,13 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
||||
{"beta-feature": 100, "beta-feature2": 300},
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_with_no_fallback(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_with_no_fallback(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
client = self.client
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -720,12 +720,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": True, "disabled-feature": False})
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_and_payloads_with_no_fallback(self, patch_decide, patch_capture):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_and_payloads_with_no_fallback(self, patch_flags, patch_capture):
|
||||
client = self.client
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
@@ -770,13 +770,13 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], {"beta-feature": "new"}
|
||||
)
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
|
||||
client = self.client
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -828,13 +828,13 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.get_all_flags("distinct_id", only_evaluate_locally=True),
|
||||
{"beta-feature": True, "disabled-feature": False},
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
||||
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
|
||||
}
|
||||
@@ -901,12 +901,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.get_all_flags_and_payloads("distinct_id", only_evaluate_locally=True)["featureFlagPayloads"],
|
||||
{"beta-feature": "some-payload"},
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_compute_inactive_flags_locally(self, patch_decide, patch_capture):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_compute_inactive_flags_locally(self, patch_flags, patch_capture):
|
||||
client = self.client
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -941,7 +941,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": True, "disabled-feature": False})
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
# Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%.
|
||||
@@ -978,12 +978,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": False, "disabled-feature": True})
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_decide):
|
||||
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1020,7 +1020,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, False)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
@@ -1036,9 +1036,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
|
||||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1088,7 +1088,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, False)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
@@ -1096,19 +1096,19 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
# even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
|
||||
)
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_for_negated_cohorts(self, patch_get, patch_decide):
|
||||
def test_feature_flags_local_evaluation_for_negated_cohorts(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1160,7 +1160,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, False)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
@@ -1168,23 +1168,23 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
# even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
|
||||
)
|
||||
# since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing2"}
|
||||
)
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@@ -1218,9 +1218,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
client.debug = True
|
||||
self.assertRaises(APIError, client.load_feature_flags)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple(self, patch_get, patch_decide):
|
||||
def test_feature_enabled_simple(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1240,11 +1240,11 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_is_false(self, patch_get, patch_decide):
|
||||
def test_feature_enabled_simple_is_false(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1264,11 +1264,11 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
self.assertFalse(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(self, patch_get, patch_decide):
|
||||
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(self, patch_get, patch_flags):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1288,7 +1288,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_with_project_api_key(self, patch_get):
|
||||
@@ -1312,9 +1312,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_enabled_request_multi_variate(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_feature_enabled_request_multi_variate(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1335,7 +1335,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
|
||||
@@ -1357,9 +1357,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_feature_flag(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_feature_flag(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1386,27 +1386,27 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "variant-1")
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_enabled_doesnt_exist(self, patch_decide, patch_poll):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_feature_enabled_doesnt_exist(self, patch_flags, patch_poll):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
client.feature_flags = []
|
||||
|
||||
patch_decide.return_value = {"featureFlags": {}}
|
||||
patch_flags.return_value = {"featureFlags": {}}
|
||||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
|
||||
patch_decide.side_effect = APIError(401, "decide error")
|
||||
patch_flags.side_effect = APIError(401, "decide error")
|
||||
self.assertIsNone(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_personal_api_key_doesnt_exist(self, patch_decide, patch_poll):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_personal_api_key_doesnt_exist(self, patch_flags, patch_poll):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = []
|
||||
|
||||
patch_decide.return_value = {"featureFlags": {"feature-flag": True}}
|
||||
patch_flags.return_value = {"featureFlags": {"feature-flag": True}}
|
||||
|
||||
self.assertTrue(client.feature_enabled("feature-flag", "distinct_id"))
|
||||
|
||||
@@ -1422,9 +1422,9 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
|
||||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_feature_flag_with_variant_overrides(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_get_feature_flag_with_variant_overrides(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1460,11 +1460,11 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "first-variant")
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_flag_with_clashing_variant_overrides(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_flag_with_clashing_variant_overrides(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1511,11 +1511,11 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"second-variant",
|
||||
)
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_flag_with_invalid_variant_overrides(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_flag_with_invalid_variant_overrides(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1551,11 +1551,11 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "second-variant")
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_flag_with_multiple_variant_overrides(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_flag_with_multiple_variant_overrides(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -1596,10 +1596,10 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "third-variant")
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "another_id"), "second-variant")
|
||||
# decide not called because this can be evaluated locally
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_boolean_feature_flag_payloads_local(self, patch_decide):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_boolean_feature_flag_payloads_local(self, patch_flags):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
@@ -1637,12 +1637,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
),
|
||||
300,
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_boolean_feature_flag_payload_decide(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_boolean_feature_flag_payload_decide(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
|
||||
self.assertEqual(
|
||||
self.client.get_feature_flag_payload(
|
||||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||||
@@ -1656,12 +1656,12 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
),
|
||||
300,
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 2)
|
||||
self.assertEqual(patch_flags.call_count, 2)
|
||||
self.assertEqual(patch_capture.call_count, 1)
|
||||
patch_capture.reset_mock()
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_multivariate_feature_flag_payloads(self, patch_decide):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_multivariate_feature_flag_payloads(self, patch_flags):
|
||||
multivariate_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
@@ -1686,7 +1686,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
|
||||
]
|
||||
},
|
||||
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
|
||||
"payloads": {"first-variant": '"some-payload"', "third-variant": '{"a": "json"}'},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [multivariate_flag]
|
||||
@@ -1711,7 +1711,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
),
|
||||
"some-payload",
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
|
||||
class TestMatchProperties(unittest.TestCase):
|
||||
@@ -1801,15 +1801,16 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertFalse(match_property(property_b, {"key": "three"}))
|
||||
|
||||
def test_match_properties_regex(self):
|
||||
property_a = self.property(key="key", value="\.com$", operator="regex") # noqa: W605
|
||||
property_a = self.property(key="key", value=r"\.com$", operator="regex")
|
||||
self.assertTrue(match_property(property_a, {"key": "value.com"}))
|
||||
self.assertTrue(match_property(property_a, {"key": "value2.com"}))
|
||||
self.assertFalse(match_property(property_a, {"key": "value2com"}))
|
||||
|
||||
self.assertFalse(match_property(property_a, {"key": ".com343tfvalue5"}))
|
||||
self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
|
||||
self.assertFalse(match_property(property_a, {"key": 123}))
|
||||
self.assertFalse(match_property(property_a, {"key": "valuecom"}))
|
||||
self.assertFalse(match_property(property_a, {"key": "value\com"})) # noqa: W605
|
||||
self.assertFalse(match_property(property_a, {"key": r"value\com"}))
|
||||
|
||||
property_b = self.property(key="key", value="3", operator="regex")
|
||||
self.assertTrue(match_property(property_b, {"key": "3"}))
|
||||
@@ -2233,9 +2234,9 @@ class TestRelativeDateParsing(unittest.TestCase):
|
||||
|
||||
class TestCaptureCalls(unittest.TestCase):
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_is_called(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -2326,7 +2327,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
),
|
||||
"decide-value",
|
||||
)
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
self.assertEqual(patch_flags.call_count, 1)
|
||||
self.assertEqual(patch_capture.call_count, 1)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id2",
|
||||
@@ -2342,9 +2343,9 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_is_called_with_flag_details(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"decide-flag": {
|
||||
"key": "decide-flag",
|
||||
@@ -2357,7 +2358,21 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
},
|
||||
}
|
||||
},
|
||||
"false-flag": {
|
||||
"key": "false-flag",
|
||||
"enabled": False,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"code": "no_matching_condition",
|
||||
"description": "No matching condition",
|
||||
"condition_index": None,
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"version": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
}
|
||||
@@ -2383,9 +2398,9 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_is_called_with_flag_details_and_payload(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called_with_flag_details_and_payload(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"decide-flag-with-payload": {
|
||||
"key": "decide-flag-with-payload",
|
||||
@@ -2408,7 +2423,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
|
||||
self.assertEqual(
|
||||
client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), '{"foo": "bar"}'
|
||||
client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), {"foo": "bar"}
|
||||
)
|
||||
self.assertEqual(patch_capture.call_count, 1)
|
||||
patch_capture.assert_called_with(
|
||||
@@ -2423,15 +2438,15 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
"$feature_flag_payload": '{"foo": "bar"}',
|
||||
"$feature_flag_payload": {"foo": "bar"},
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_is_called_but_does_not_add_all_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called_but_does_not_add_all_flags(self, patch_flags):
|
||||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -2479,9 +2494,9 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_is_called_in_get_feature_flag_payload(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_is_called_in_get_feature_flag_payload(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {
|
||||
"featureFlags": {"person-flag": True},
|
||||
"featureFlagPayloads": {"person-flag": 300},
|
||||
}
|
||||
@@ -2517,8 +2532,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"$feature_flag_payload": 300,
|
||||
"locally_evaluated": False,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
},
|
||||
groups={},
|
||||
@@ -2527,7 +2541,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
|
||||
# Reset mocks for further tests
|
||||
patch_capture.reset_mock()
|
||||
patch_decide.reset_mock()
|
||||
patch_flags.reset_mock()
|
||||
|
||||
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
|
||||
client.get_feature_flag_payload(
|
||||
@@ -2549,8 +2563,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"$feature_flag_payload": 300,
|
||||
"locally_evaluated": False,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
},
|
||||
groups={},
|
||||
@@ -2560,9 +2573,9 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.reset_mock()
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
|
||||
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -2603,8 +2616,8 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
|
||||
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_capture_multiple_users_doesnt_out_of_memory(self, patch_decide, patch_capture):
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_multiple_users_doesnt_out_of_memory(self, patch_flags, patch_capture):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
@@ -4724,7 +4737,7 @@ class TestConsistency(unittest.TestCase):
|
||||
else:
|
||||
self.assertFalse(feature_flag_match)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_feature_flag_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {"featureFlags": {}} # Ensure decide returns empty flags
|
||||
|
||||
@@ -4745,7 +4758,7 @@ class TestConsistency(unittest.TestCase):
|
||||
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_feature_flag_payload_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
@@ -4772,7 +4785,7 @@ class TestConsistency(unittest.TestCase):
|
||||
self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
|
||||
self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
|
||||
@@ -7,7 +7,7 @@ from posthog.types import (
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
LegacyFlagMetadata,
|
||||
normalize_decide_response,
|
||||
normalize_flags_response,
|
||||
to_flags_and_payloads,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestTypes(unittest.TestCase):
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_decide_response(resp)
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
@@ -56,7 +56,7 @@ class TestTypes(unittest.TestCase):
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_decide_response(resp)
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
@@ -75,7 +75,7 @@ class TestTypes(unittest.TestCase):
|
||||
# Test legacy response with boolean flag
|
||||
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
|
||||
|
||||
result = normalize_decide_response(resp)
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
self.assertIn("requestId", result)
|
||||
self.assertIsNone(result["requestId"])
|
||||
@@ -168,7 +168,7 @@ class TestTypes(unittest.TestCase):
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
}
|
||||
|
||||
normalized = normalize_decide_response(resp)
|
||||
normalized = normalize_flags_response(resp)
|
||||
result = to_flags_and_payloads(normalized)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestUtils(unittest.TestCase):
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.utcnow(),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
|
||||
|
||||
+108
-13
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, TypedDict, Union, cast
|
||||
|
||||
@@ -7,7 +8,7 @@ FlagValue = Union[bool, str]
|
||||
@dataclass(frozen=True)
|
||||
class FlagReason:
|
||||
code: str
|
||||
condition_index: int
|
||||
condition_index: Optional[int]
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
@@ -16,7 +17,7 @@ class FlagReason:
|
||||
return None
|
||||
return cls(
|
||||
code=resp.get("code", ""),
|
||||
condition_index=resp.get("condition_index", 0),
|
||||
condition_index=resp.get("condition_index"),
|
||||
description=resp.get("description", ""),
|
||||
)
|
||||
|
||||
@@ -90,7 +91,7 @@ class FeatureFlag:
|
||||
)
|
||||
|
||||
|
||||
class DecideResponse(TypedDict, total=False):
|
||||
class FlagsResponse(TypedDict, total=False):
|
||||
flags: dict[str, FeatureFlag]
|
||||
errorsWhileComputingFlags: bool
|
||||
requestId: str
|
||||
@@ -102,15 +103,109 @@ class FlagsAndPayloads(TypedDict, total=True):
|
||||
featureFlagPayloads: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
def normalize_decide_response(resp: Any) -> DecideResponse:
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlagResult:
|
||||
"""
|
||||
Normalize the response from the decide API endpoint into a v4 DecideResponse.
|
||||
The result of calling a feature flag which includes the flag result, variant, and payload.
|
||||
|
||||
Attributes:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
enabled (bool): Whether the feature flag is enabled for the current context.
|
||||
variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise.
|
||||
payload (Optional[Any]): Additional data associated with the feature flag, if any.
|
||||
reason (Optional[str]): A description of why the flag was enabled or disabled, if available.
|
||||
"""
|
||||
|
||||
key: str
|
||||
enabled: bool
|
||||
variant: Optional[str]
|
||||
payload: Optional[Any]
|
||||
reason: Optional[str]
|
||||
|
||||
def get_value(self) -> FlagValue:
|
||||
"""
|
||||
Returns the value of the flag. This is the variant if it exists, otherwise the enabled value.
|
||||
This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event.
|
||||
|
||||
Returns:
|
||||
FlagValue: Either a string variant or boolean value representing the flag's state.
|
||||
"""
|
||||
return self.variant or self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_value_and_payload(
|
||||
cls, key: str, value: Union[FlagValue, None], payload: Any
|
||||
) -> Union["FeatureFlagResult", None]:
|
||||
"""
|
||||
Creates a FeatureFlagResult from a flag value and payload.
|
||||
|
||||
Args:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
value (Union[FlagValue, None]): The value of the flag (string variant or boolean).
|
||||
payload (Any): Additional data associated with the feature flag.
|
||||
|
||||
Returns:
|
||||
Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
|
||||
return cls(
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=json.loads(payload) if isinstance(payload, str) else payload,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_flag_details(
|
||||
cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None
|
||||
) -> "FeatureFlagResult | None":
|
||||
"""
|
||||
Create a FeatureFlagResult from a FeatureFlag object.
|
||||
|
||||
Args:
|
||||
details (Union[FeatureFlag, None]): The FeatureFlag object to convert.
|
||||
override_match_value (Optional[FlagValue]): If provided, this value will be used to populate
|
||||
the enabled and variant fields instead of the values from the FeatureFlag.
|
||||
|
||||
Returns:
|
||||
FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None.
|
||||
"""
|
||||
|
||||
if details is None:
|
||||
return None
|
||||
|
||||
if override_match_value is not None:
|
||||
enabled, variant = (
|
||||
(True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None)
|
||||
)
|
||||
else:
|
||||
enabled, variant = (details.enabled, details.variant)
|
||||
|
||||
return cls(
|
||||
key=details.key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=(
|
||||
json.loads(details.metadata.payload)
|
||||
if isinstance(details.metadata.payload, str)
|
||||
else details.metadata.payload
|
||||
),
|
||||
reason=details.reason.description if details.reason else None,
|
||||
)
|
||||
|
||||
|
||||
def normalize_flags_response(resp: Any) -> FlagsResponse:
|
||||
"""
|
||||
Normalize the response from the decide or flags API endpoint into a FlagsResponse.
|
||||
|
||||
Args:
|
||||
resp: A v3 or v4 response from the decide API endpoint.
|
||||
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
|
||||
|
||||
Returns:
|
||||
A DecideResponse containing feature flags and their details.
|
||||
A FlagsResponse containing feature flags and their details.
|
||||
"""
|
||||
if "requestId" not in resp:
|
||||
resp["requestId"] = None
|
||||
@@ -133,16 +228,16 @@ def normalize_decide_response(resp: Any) -> DecideResponse:
|
||||
for key, value in featureFlags.items():
|
||||
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
|
||||
resp["flags"] = flags
|
||||
return cast(DecideResponse, resp)
|
||||
return cast(FlagsResponse, resp)
|
||||
|
||||
|
||||
def to_flags_and_payloads(resp: DecideResponse) -> FlagsAndPayloads:
|
||||
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
|
||||
"""
|
||||
Convert a DecideResponse into a FlagsAndPayloads object which is a
|
||||
Convert a FlagsResponse into a FlagsAndPayloads object which is a
|
||||
dict of feature flags and their payloads. This is needed by certain
|
||||
functions in the client.
|
||||
Args:
|
||||
resp: A DecideResponse containing feature flags and their payloads.
|
||||
resp: A FlagsResponse containing feature flags and their payloads.
|
||||
|
||||
Returns:
|
||||
A tuple containing:
|
||||
@@ -152,7 +247,7 @@ def to_flags_and_payloads(resp: DecideResponse) -> FlagsAndPayloads:
|
||||
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
|
||||
|
||||
|
||||
def to_values(response: DecideResponse) -> Optional[dict[str, FlagValue]]:
|
||||
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
@@ -160,7 +255,7 @@ def to_values(response: DecideResponse) -> Optional[dict[str, FlagValue]]:
|
||||
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
|
||||
|
||||
|
||||
def to_payloads(response: DecideResponse) -> Optional[dict[str, str]]:
|
||||
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.24.0"
|
||||
VERSION = "4.2.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -3,3 +3,4 @@ universal = 1
|
||||
|
||||
[tool:pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
|
||||
@@ -12,14 +12,15 @@ from version import VERSION
|
||||
|
||||
long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
|
||||
This package requires Python 3.9 or higher.
|
||||
"""
|
||||
|
||||
install_requires = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"monotonic>=1.5",
|
||||
"python-dateutil>=2.2",
|
||||
"backoff>=1.10.0",
|
||||
"python-dateutil>2.1",
|
||||
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
|
||||
]
|
||||
|
||||
@@ -57,6 +58,7 @@ extras_require = {
|
||||
"langchain-community>=0.2.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
"langchain-anthropic>=0.2.0",
|
||||
"google-genai",
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
],
|
||||
@@ -79,6 +81,7 @@ setup(
|
||||
"posthog.ai.langchain",
|
||||
"posthog.ai.openai",
|
||||
"posthog.ai.anthropic",
|
||||
"posthog.ai.gemini",
|
||||
"posthog.test",
|
||||
"posthog.sentry",
|
||||
"posthog.exception_integrations",
|
||||
|
||||
+6
-13
@@ -12,14 +12,15 @@ from version import VERSION
|
||||
|
||||
long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
|
||||
This package requires Python 3.9 or higher.
|
||||
"""
|
||||
|
||||
install_requires = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"monotonic>=1.5",
|
||||
"python-dateutil>=2.2",
|
||||
"backoff>=1.10.0",
|
||||
"python-dateutil>2.1",
|
||||
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
|
||||
]
|
||||
|
||||
@@ -40,6 +41,7 @@ setup(
|
||||
"posthoganalytics.ai.langchain",
|
||||
"posthoganalytics.ai.openai",
|
||||
"posthoganalytics.ai.anthropic",
|
||||
"posthoganalytics.ai.gemini",
|
||||
"posthoganalytics.test",
|
||||
"posthoganalytics.sentry",
|
||||
"posthoganalytics.exception_integrations",
|
||||
@@ -58,19 +60,10 @@ setup(
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 2",
|
||||
"Programming Language :: Python :: 2.6",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.2",
|
||||
"Programming Language :: Python :: 3.3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user