Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c99714c3 | ||
|
|
7dc4cbb16b | ||
|
|
4cda646f03 | ||
|
|
ea4e7fa16d | ||
|
|
57a3e7470f | ||
|
|
5e0f9e35c1 | ||
|
|
337f7da7c5 | ||
|
|
31652d5ec3 | ||
|
|
6764c786a4 | ||
|
|
1b57a96509 | ||
|
|
38683e8550 | ||
|
|
a5c8f62a63 | ||
|
|
e480b88dce | ||
|
|
3ff2a8599d | ||
|
|
a3cf4ad5fb | ||
|
|
cec532f241 | ||
|
|
415508087f | ||
|
|
994003fc42 | ||
|
|
319b3807f3 | ||
|
|
5e7314f89d | ||
|
|
8f43bbc613 | ||
|
|
eb07aafaa3 | ||
|
|
0f8b10bb09 | ||
|
|
45dc933b9c | ||
|
|
2835af49cb | ||
|
|
54506e5a7c | ||
|
|
bcf5b27083 | ||
|
|
0b6ff2e8d3 |
@@ -13,10 +13,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
@@ -36,25 +36,32 @@ jobs:
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 posthog --ignore E501
|
||||
flake8 posthog --ignore E501,W503
|
||||
|
||||
- name: Check import order with isort
|
||||
run: |
|
||||
isort --check-only .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
|
||||
tests:
|
||||
name: Python tests
|
||||
name: Python ${{ matrix.python-version }} tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
|
||||
@@ -1,3 +1,77 @@
|
||||
## 3.18.1 – 2025-03-03
|
||||
|
||||
1. Improve quota-limited feature flag logs
|
||||
|
||||
## 3.18.0 - 2025-02-28
|
||||
|
||||
1. Add support for Azure OpenAI.
|
||||
|
||||
## 3.17.0 - 2025-02-27
|
||||
|
||||
1. The LangChain handler now captures tools in `$ai_generation` events, in property `$ai_tools`. This allows for displaying tools provided to the LLM call in PostHog UI. Note that support for `$ai_tools` in OpenAI and Anthropic SDKs is coming soon.
|
||||
|
||||
## 3.16.0 - 2025-02-26
|
||||
|
||||
1. feat: add some platform info to events (#198)
|
||||
|
||||
## 3.15.1 - 2025-02-23
|
||||
|
||||
1. Fix async client support for OpenAI.
|
||||
|
||||
## 3.15.0 - 2025-02-19
|
||||
|
||||
1. Support quota-limited feature flags
|
||||
|
||||
## 3.14.2 - 2025-02-19
|
||||
|
||||
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
|
||||
|
||||
## 3.14.1 - 2025-02-18
|
||||
|
||||
1. Add support for Bedrock Anthropic Usage
|
||||
|
||||
## 3.13.0 - 2025-02-12
|
||||
|
||||
1. Automatically retry connection errors
|
||||
|
||||
## 3.12.1 - 2025-02-11
|
||||
|
||||
1. Fix mypy support for 3.12.0
|
||||
2. Deprecate `is_simple_flag`
|
||||
|
||||
## 3.12.0 - 2025-02-11
|
||||
|
||||
1. Add support for OpenAI beta parse API.
|
||||
2. Deprecate `context` parameter
|
||||
|
||||
## 3.11.1 - 2025-02-06
|
||||
|
||||
1. Fix LangChain callback handler to capture parent run ID.
|
||||
|
||||
## 3.11.0 - 2025-01-28
|
||||
|
||||
1. Add the `$ai_span` event to the LangChain callback handler to capture the input and output of intermediary chains.
|
||||
|
||||
> LLM observability naming change: event property `$ai_trace_name` is now `$ai_span_name`.
|
||||
|
||||
2. Fix serialiazation of Pydantic models in methods.
|
||||
|
||||
## 3.10.0 - 2025-01-24
|
||||
|
||||
1. Add `$ai_error` and `$ai_is_error` properties to LangChain callback handler, OpenAI, and Anthropic.
|
||||
|
||||
## 3.9.3 - 2025-01-23
|
||||
|
||||
1. Fix capturing of multiple traces in the LangChain callback handler.
|
||||
|
||||
## 3.9.2 - 2025-01-22
|
||||
|
||||
1. Fix importing of LangChain callback handler under certain circumstances.
|
||||
|
||||
## 3.9.0 - 2025-01-22
|
||||
|
||||
1. Add `$ai_trace` event emission to LangChain callback handler.
|
||||
|
||||
## 3.8.4 - 2025-01-17
|
||||
|
||||
1. Add Anthropic support for LLM Observability.
|
||||
|
||||
@@ -17,11 +17,13 @@ release_analytics:
|
||||
rm -rf posthoganalytics
|
||||
mkdir posthoganalytics
|
||||
cp -r posthog/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
rm -rf posthog
|
||||
python setup_analytics.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
mkdir posthog
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
cp -r posthoganalytics/* posthog/
|
||||
rm -rf posthoganalytics
|
||||
|
||||
@@ -10,8 +10,10 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
|
||||
### Testing Locally
|
||||
|
||||
1. Run `python3 -m venv env` (creates virtual environment called "env")
|
||||
* or `uv venv env`
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
* or `uv pip install -e ".[test]"`
|
||||
4. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
|
||||
|
||||
+10
-4
@@ -1,10 +1,15 @@
|
||||
# PostHog Python library example
|
||||
|
||||
# Import the library
|
||||
# import time
|
||||
import argparse
|
||||
|
||||
import posthog
|
||||
|
||||
# Add argument parsing
|
||||
parser = argparse.ArgumentParser(description="PostHog Python library example")
|
||||
parser.add_argument(
|
||||
"--flag", default="person-on-events-enabled", help="Feature flag key to check (default: person-on-events-enabled)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
@@ -18,7 +23,7 @@ posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
args.flag, # Use the flag from command line arguments
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
@@ -96,6 +101,7 @@ print(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
)
|
||||
)
|
||||
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
|
||||
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import posthog
|
||||
from posthog.ai.openai import AsyncOpenAI, OpenAI
|
||||
|
||||
# Example credentials - replace these with your own or use environment variables
|
||||
posthog.project_api_key = os.getenv("POSTHOG_PROJECT_API_KEY", "your-project-api-key")
|
||||
posthog.personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "your-personal-api-key")
|
||||
posthog.host = os.getenv("POSTHOG_HOST", "http://localhost:8000") # Or https://app.posthog.com
|
||||
posthog.debug = True
|
||||
# change this to False to see usage events
|
||||
@@ -32,9 +34,10 @@ def main_sync():
|
||||
|
||||
try:
|
||||
basic_openai_call(distinct_id, trace_id, properties, groups)
|
||||
streaming_openai_call(distinct_id, trace_id, properties, groups)
|
||||
embedding_openai_call(distinct_id, trace_id, properties, groups)
|
||||
image_openai_call()
|
||||
# streaming_openai_call(distinct_id, trace_id, properties, groups)
|
||||
# embedding_openai_call(distinct_id, trace_id, properties, groups)
|
||||
# image_openai_call()
|
||||
# beta_openai_call(distinct_id, trace_id, properties, groups)
|
||||
except Exception as e:
|
||||
print("Error during OpenAI call:", str(e))
|
||||
|
||||
@@ -187,10 +190,32 @@ async def embedding_async_openai_call(posthog_distinct_id, posthog_trace_id, pos
|
||||
return response
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
participants: list[str]
|
||||
|
||||
|
||||
def beta_openai_call(distinct_id, trace_id, properties, groups):
|
||||
response = openai_client.beta.chat.completions.parse(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "Extract the event information."},
|
||||
{"role": "user", "content": "Alice and Bob are going to a science fair on Friday."},
|
||||
],
|
||||
response_format=CalendarEvent,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
posthog_groups=groups,
|
||||
)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
# HOW TO RUN:
|
||||
# comment out one of these to run the other
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_sync()
|
||||
|
||||
# asyncio.run(main_async())
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/utils.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
|
||||
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
|
||||
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
|
||||
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment]
|
||||
posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef]
|
||||
posthog/consumer.py:0: error: Need type annotation for "items" (hint: "items: list[<type>] = ...") [var-annotated]
|
||||
posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator]
|
||||
posthog/consumer.py:0: note: Right operand is of type "int | str"
|
||||
posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator]
|
||||
posthog/consumer.py:0: note: Left operand is of type "int | str"
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil.relativedelta" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore]
|
||||
posthog/client.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-python-dateutil"
|
||||
posthog/client.py:0: note: (or run "mypy --install-types" to install all missing stub packages)
|
||||
posthog/client.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
|
||||
posthog/client.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-six"
|
||||
posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef]
|
||||
posthog/client.py:0: error: Need type annotation for "queue" [var-annotated]
|
||||
posthog/client.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "capture" [call-arg]
|
||||
posthog/__init__.py:0: note: "capture" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
example.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Unpacked dict entry 11 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Unpacked dict entry 8 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Unpacked dict entry 11 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Unpacked dict entry 8 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
llm_observability_examples.py:0: error: Argument "posthog_client" to "OpenAI" has incompatible type Module; expected "Client" [arg-type]
|
||||
llm_observability_examples.py:0: error: Argument "posthog_client" to "AsyncOpenAI" has incompatible type Module; expected "Client" [arg-type]
|
||||
@@ -0,0 +1,38 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
plugins =
|
||||
pydantic.mypy
|
||||
strict_optional = True
|
||||
no_implicit_optional = True
|
||||
warn_unused_ignores = True
|
||||
check_untyped_defs = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sentry_sdk.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-posthog.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-posthog.*.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-openai.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain_core.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-anthropic.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpx.*]
|
||||
ignore_missing_imports = True
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime # noqa: F401
|
||||
import warnings
|
||||
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
|
||||
|
||||
from posthog.client import Client
|
||||
@@ -64,6 +65,14 @@ def capture(
|
||||
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"capture",
|
||||
distinct_id=distinct_id,
|
||||
@@ -102,6 +111,14 @@ def identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"identify",
|
||||
distinct_id=distinct_id,
|
||||
@@ -137,6 +154,14 @@ def set(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"set",
|
||||
distinct_id=distinct_id,
|
||||
@@ -172,6 +197,14 @@ def set_once(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"set_once",
|
||||
distinct_id=distinct_id,
|
||||
@@ -208,6 +241,14 @@ def group_identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
@@ -245,6 +286,14 @@ def alias(
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
@@ -288,6 +337,14 @@ def capture_exception(
|
||||
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"capture_exception",
|
||||
exception=exception,
|
||||
@@ -435,6 +492,26 @@ def get_feature_flag_payload(
|
||||
)
|
||||
|
||||
|
||||
def get_remote_config_payload(
|
||||
key, # type: str
|
||||
):
|
||||
"""Get the payload for a remote config feature flag.
|
||||
|
||||
Args:
|
||||
key: The key of the feature flag
|
||||
|
||||
Returns:
|
||||
The payload associated with the feature flag. If payload is encrypted, the return value will decrypted
|
||||
|
||||
Note:
|
||||
Requires personal_api_key to be set for authentication
|
||||
"""
|
||||
return _proxy(
|
||||
"get_remote_config_payload",
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups={},
|
||||
|
||||
+416
-110
@@ -5,58 +5,95 @@ except ImportError:
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain.schema.agent import AgentAction, AgentFinish
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.messages import AIMessage, BaseMessage, FunctionMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog import default_client
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.client import Client
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
class RunMetadata(TypedDict, total=False):
|
||||
messages: Union[List[Dict[str, Any]], List[str]]
|
||||
provider: str
|
||||
model: str
|
||||
model_params: Dict[str, Any]
|
||||
base_url: str
|
||||
@dataclass
|
||||
class SpanMetadata:
|
||||
name: str
|
||||
"""Name of the run: chain name, model name, etc."""
|
||||
start_time: float
|
||||
end_time: float
|
||||
"""Start time of the run."""
|
||||
end_time: Optional[float]
|
||||
"""End time of the run."""
|
||||
input: Optional[Any]
|
||||
"""Input of the run: messages, prompt variables, etc."""
|
||||
|
||||
@property
|
||||
def latency(self) -> float:
|
||||
if not self.end_time:
|
||||
return 0
|
||||
return self.end_time - self.start_time
|
||||
|
||||
|
||||
RunStorage = Dict[UUID, RunMetadata]
|
||||
@dataclass
|
||||
class GenerationMetadata(SpanMetadata):
|
||||
provider: Optional[str] = None
|
||||
"""Provider of the run: OpenAI, Anthropic"""
|
||||
model: Optional[str] = None
|
||||
"""Model used in the run"""
|
||||
model_params: Optional[Dict[str, Any]] = None
|
||||
"""Model parameters of the run: temperature, max_tokens, etc."""
|
||||
base_url: Optional[str] = None
|
||||
"""Base URL of the provider's API used in the run."""
|
||||
tools: Optional[List[Dict[str, Any]]] = None
|
||||
"""Tools provided to the model."""
|
||||
|
||||
|
||||
RunMetadata = Union[SpanMetadata, GenerationMetadata]
|
||||
RunMetadataStorage = Dict[UUID, RunMetadata]
|
||||
|
||||
|
||||
class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
A callback handler for LangChain that sends events to PostHog LLM Observability.
|
||||
The PostHog LLM observability callback handler for LangChain.
|
||||
"""
|
||||
|
||||
_client: Client
|
||||
"""PostHog client instance."""
|
||||
|
||||
_distinct_id: Optional[Union[str, int, float, UUID]]
|
||||
"""Distinct ID of the user to associate the trace with."""
|
||||
|
||||
_trace_id: Optional[Union[str, int, float, UUID]]
|
||||
"""Global trace ID to be sent with every event. Otherwise, the top-level run ID is used."""
|
||||
|
||||
_trace_input: Optional[Any]
|
||||
"""The input at the start of the trace. Any JSON object."""
|
||||
|
||||
_trace_name: Optional[str]
|
||||
"""Name of the trace, exposed in the UI."""
|
||||
|
||||
_properties: Optional[Dict[str, Any]]
|
||||
"""Global properties to be sent with every event."""
|
||||
_runs: RunStorage
|
||||
|
||||
_runs: RunMetadataStorage
|
||||
"""Mapping of run IDs to run metadata as run metadata is only available on the start of generation."""
|
||||
|
||||
_parent_tree: Dict[UUID, UUID]
|
||||
"""
|
||||
A dictionary that maps chain run IDs to their parent chain run IDs (parent pointer tree),
|
||||
@@ -65,7 +102,8 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
client: Optional[Client] = None,
|
||||
*,
|
||||
distinct_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
trace_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
@@ -81,7 +119,10 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
privacy_mode: Whether to redact the input and output of the trace.
|
||||
groups: Optional additional PostHog groups to use for the trace.
|
||||
"""
|
||||
self._client = client
|
||||
posthog_client = client or default_client
|
||||
if posthog_client is None:
|
||||
raise ValueError("PostHog client is required")
|
||||
self._client = posthog_client
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
@@ -97,9 +138,34 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._log_debug_event("on_chain_start", run_id, parent_run_id, inputs=inputs)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, inputs, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
outputs: Dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_chain_end", run_id, parent_run_id, outputs=outputs)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, outputs)
|
||||
|
||||
def on_chain_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_chain_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
@@ -110,9 +176,10 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._log_debug_event("on_chat_model_start", run_id, parent_run_id, messages=messages)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
input = [_convert_message_to_dict(message) for row in messages for message in row]
|
||||
self._set_run_metadata(serialized, run_id, input, **kwargs)
|
||||
self._set_llm_metadata(serialized, run_id, input, **kwargs)
|
||||
|
||||
def on_llm_start(
|
||||
self,
|
||||
@@ -123,19 +190,20 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_llm_start", run_id, parent_run_id, prompts=prompts)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_run_metadata(serialized, run_id, prompts, **kwargs)
|
||||
self._set_llm_metadata(serialized, run_id, prompts, **kwargs)
|
||||
|
||||
def on_chain_end(
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
outputs: Dict[str, Any],
|
||||
token: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._pop_parent_of_run(run_id)
|
||||
) -> Any:
|
||||
"""Run on new LLM token. Only available when streaming is enabled."""
|
||||
self._log_debug_event("on_llm_new_token", run_id, parent_run_id, token=token)
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
@@ -143,61 +211,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
|
||||
"""
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
|
||||
latency = run.get("end_time", 0) - run.get("start_time", 0)
|
||||
input_tokens, output_tokens = _parse_usage(response)
|
||||
|
||||
generation_result = response.generations[-1]
|
||||
if isinstance(generation_result[-1], ChatGeneration):
|
||||
output = [
|
||||
_convert_message_to_dict(cast(ChatGeneration, generation).message) for generation in generation_result
|
||||
]
|
||||
else:
|
||||
output = [_extract_raw_esponse(generation) for generation in generation_result]
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": run.get("provider"),
|
||||
"$ai_model": run.get("model"),
|
||||
"$ai_model_parameters": run.get("model_params"),
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.get("messages")),
|
||||
"$ai_output_choices": with_privacy_mode(self._client, self._privacy_mode, output),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": input_tokens,
|
||||
"$ai_output_tokens": output_tokens,
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": run.get("base_url"),
|
||||
**self._properties,
|
||||
}
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
|
||||
def on_chain_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._pop_parent_of_run(run_id)
|
||||
self._log_debug_event("on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs)
|
||||
self._pop_run_and_capture_generation(run_id, parent_run_id, response)
|
||||
|
||||
def on_llm_error(
|
||||
self,
|
||||
@@ -205,35 +225,109 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_generation(run_id, parent_run_id, error)
|
||||
|
||||
latency = run.get("end_time", 0) - run.get("start_time", 0)
|
||||
event_properties = {
|
||||
"$ai_provider": run.get("provider"),
|
||||
"$ai_model": run.get("model"),
|
||||
"$ai_model_parameters": run.get("model_params"),
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.get("messages")),
|
||||
"$ai_http_status": _get_http_status(error),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": run.get("base_url"),
|
||||
**self._properties,
|
||||
}
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
def on_tool_start(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_end", run_id, parent_run_id, output=output)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, output)
|
||||
|
||||
def on_tool_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_retriever_start(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
query: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_retriever_end(
|
||||
self,
|
||||
documents: Sequence[Document],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_retriever_end", run_id, parent_run_id, documents=documents)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, documents)
|
||||
|
||||
def on_retriever_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run when Retriever errors."""
|
||||
self._log_debug_event("on_retriever_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_agent_action(
|
||||
self,
|
||||
action: AgentAction,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run on agent action."""
|
||||
self._log_debug_event("on_agent_action", run_id, parent_run_id, action=action)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(None, action, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_agent_finish(
|
||||
self,
|
||||
finish: AgentFinish,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_agent_finish", run_id, parent_run_id, finish=finish)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, finish)
|
||||
|
||||
def _set_parent_of_run(self, run_id: UUID, parent_run_id: Optional[UUID] = None):
|
||||
"""
|
||||
@@ -260,7 +354,19 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
id = self._parent_tree[id]
|
||||
return id
|
||||
|
||||
def _set_run_metadata(
|
||||
def _set_trace_or_span_metadata(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
input: Any,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
default_name = "trace" if parent_run_id is None else "span"
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or default_name
|
||||
self._runs[run_id] = SpanMetadata(name=run_name, input=input, start_time=time.time(), end_time=None)
|
||||
|
||||
def _set_llm_metadata(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
run_id: UUID,
|
||||
@@ -269,24 +375,24 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
invocation_params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
run: RunMetadata = {
|
||||
"messages": messages,
|
||||
"start_time": time.time(),
|
||||
}
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
|
||||
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
|
||||
if isinstance(invocation_params, dict):
|
||||
run["model_params"] = get_model_params(invocation_params)
|
||||
generation.model_params = get_model_params(invocation_params)
|
||||
if tools := invocation_params.get("tools"):
|
||||
generation.tools = tools
|
||||
if isinstance(metadata, dict):
|
||||
if model := metadata.get("ls_model_name"):
|
||||
run["model"] = model
|
||||
generation.model = model
|
||||
if provider := metadata.get("ls_provider"):
|
||||
run["provider"] = provider
|
||||
generation.provider = provider
|
||||
try:
|
||||
base_url = serialized["kwargs"]["openai_api_base"]
|
||||
if base_url is not None:
|
||||
run["base_url"] = base_url
|
||||
generation.base_url = base_url
|
||||
except KeyError:
|
||||
pass
|
||||
self._runs[run_id] = run
|
||||
self._runs[run_id] = generation
|
||||
|
||||
def _pop_run_metadata(self, run_id: UUID) -> Optional[RunMetadata]:
|
||||
end_time = time.time()
|
||||
@@ -295,15 +401,168 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
except KeyError:
|
||||
log.warning(f"No run metadata found for run {run_id}")
|
||||
return None
|
||||
run["end_time"] = end_time
|
||||
run.end_time = end_time
|
||||
return run
|
||||
|
||||
def _get_trace_id(self, run_id: UUID):
|
||||
trace_id = self._trace_id or self._find_root_run(run_id)
|
||||
if not trace_id:
|
||||
trace_id = uuid.uuid4()
|
||||
return run_id
|
||||
return trace_id
|
||||
|
||||
def _get_parent_run_id(self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]):
|
||||
"""
|
||||
Replace the parent run ID with the trace ID for second level runs when a custom trace ID is set.
|
||||
"""
|
||||
if parent_run_id is not None and parent_run_id not in self._parent_tree:
|
||||
return trace_id
|
||||
return parent_run_id
|
||||
|
||||
def _pop_run_and_capture_trace_or_span(self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
if isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is a generation, but attempted to be captured as a trace or span.")
|
||||
return
|
||||
self._capture_trace_or_span(
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
outputs,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_trace_or_span(
|
||||
self,
|
||||
trace_id: Any,
|
||||
run_id: UUID,
|
||||
run: SpanMetadata,
|
||||
outputs: Any,
|
||||
parent_run_id: Optional[UUID],
|
||||
):
|
||||
event_name = "$ai_trace" if parent_run_id is None else "$ai_span"
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_input_state": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_span_name": run.name,
|
||||
"$ai_span_id": run_id,
|
||||
}
|
||||
if parent_run_id is not None:
|
||||
event_properties["$ai_parent_id"] = parent_run_id
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if isinstance(outputs, BaseException):
|
||||
event_properties["$ai_error"] = _stringify_exception(outputs)
|
||||
event_properties["$ai_is_error"] = True
|
||||
elif outputs is not None:
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(self._client, self._privacy_mode, outputs)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or run_id,
|
||||
event=event_name,
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
|
||||
def _pop_run_and_capture_generation(
|
||||
self,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID],
|
||||
response: Union[LLMResult, BaseException],
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
if not isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is not a generation, but attempted to be captured as a generation.")
|
||||
return
|
||||
self._capture_generation(
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
response,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_generation(
|
||||
self,
|
||||
trace_id: Any,
|
||||
run_id: UUID,
|
||||
run: GenerationMetadata,
|
||||
output: Union[LLMResult, BaseException],
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
):
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_span_id": run_id,
|
||||
"$ai_span_name": run.name,
|
||||
"$ai_parent_id": parent_run_id,
|
||||
"$ai_provider": run.provider,
|
||||
"$ai_model": run.model,
|
||||
"$ai_model_parameters": run.model_params,
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_base_url": run.base_url,
|
||||
}
|
||||
if run.tools:
|
||||
event_properties["$ai_tools"] = run.tools
|
||||
|
||||
if isinstance(output, BaseException):
|
||||
event_properties["$ai_http_status"] = _get_http_status(output)
|
||||
event_properties["$ai_error"] = _stringify_exception(output)
|
||||
event_properties["$ai_is_error"] = True
|
||||
else:
|
||||
# Add usage
|
||||
input_tokens, output_tokens = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = input_tokens
|
||||
event_properties["$ai_output_tokens"] = output_tokens
|
||||
|
||||
# Generation results
|
||||
generation_result = output.generations[-1]
|
||||
if isinstance(generation_result[-1], ChatGeneration):
|
||||
completions = [
|
||||
_convert_message_to_dict(cast(ChatGeneration, generation).message)
|
||||
for generation in generation_result
|
||||
]
|
||||
else:
|
||||
completions = [_extract_raw_esponse(generation) for generation in generation_result]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(self._client, self._privacy_mode, completions)
|
||||
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
|
||||
def _log_debug_event(
|
||||
self,
|
||||
event_name: str,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
log.debug(
|
||||
f"Event: {event_name}, run_id: {str(run_id)[:5]}, parent_run_id: {str(parent_run_id)[:5]}, kwargs: {kwargs}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_raw_esponse(last_response):
|
||||
"""Extract the response from the last response of the LLM call."""
|
||||
@@ -339,7 +598,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
return message_dict
|
||||
|
||||
|
||||
def _parse_usage_model(usage: Union[BaseModel, Dict]) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
def _parse_usage_model(
|
||||
usage: Union[BaseModel, Dict],
|
||||
) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
@@ -353,6 +614,9 @@ def _parse_usage_model(usage: Union[BaseModel, Dict]) -> Tuple[Union[int, None],
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
# Bedrock Anthropic
|
||||
("prompt_tokens", "input"),
|
||||
("completion_tokens", "output"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
@@ -383,6 +647,10 @@ def _parse_usage(response: LLMResult):
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"])
|
||||
break
|
||||
|
||||
for generation_chunk in generation:
|
||||
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
|
||||
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
|
||||
@@ -417,3 +685,41 @@ def _get_http_status(error: BaseException) -> int:
|
||||
# Google: https://github.com/googleapis/python-api-core/blob/main/google/api_core/exceptions.py
|
||||
status_code = getattr(error, "status_code", getattr(error, "code", 0))
|
||||
return status_code
|
||||
|
||||
|
||||
def _get_langchain_run_name(serialized: Optional[Dict[str, Any]], **kwargs: Any) -> Optional[str]:
|
||||
"""Retrieve the name of a serialized LangChain runnable.
|
||||
|
||||
The prioritization for the determination of the run name is as follows:
|
||||
- The value assigned to the "name" key in `kwargs`.
|
||||
- The value assigned to the "name" key in `serialized`.
|
||||
- The last entry of the value assigned to the "id" key in `serialized`.
|
||||
- "<unknown>".
|
||||
|
||||
Args:
|
||||
serialized (Optional[Dict[str, Any]]): A dictionary containing the runnable's serialized data.
|
||||
**kwargs (Any): Additional keyword arguments, potentially including the 'name' override.
|
||||
|
||||
Returns:
|
||||
str: The determined name of the Langchain runnable.
|
||||
"""
|
||||
if "name" in kwargs and kwargs["name"] is not None:
|
||||
return kwargs["name"]
|
||||
if serialized is None:
|
||||
return None
|
||||
try:
|
||||
return serialized["name"]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return serialized["id"][-1]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _stringify_exception(exception: BaseException) -> str:
|
||||
description = str(exception)
|
||||
if description:
|
||||
return f"{exception.__class__.__name__}: {description}"
|
||||
return exception.__class__.__name__
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .openai import OpenAI
|
||||
from .openai_async import AsyncOpenAI
|
||||
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
|
||||
|
||||
__all__ = ["OpenAI", "AsyncOpenAI"]
|
||||
__all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
|
||||
|
||||
@@ -31,6 +31,7 @@ class OpenAI(openai.OpenAI):
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.Chat):
|
||||
@@ -249,3 +250,45 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.Beta):
|
||||
_client: OpenAI
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.Chat):
|
||||
_client: OpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions):
|
||||
_client: OpenAI
|
||||
|
||||
def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.AsyncChat):
|
||||
@@ -72,6 +73,8 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
@@ -248,3 +251,45 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.AsyncBeta):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.AsyncChat):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncCompletions):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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_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.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AzureOpenAI(openai.AzureOpenAI):
|
||||
"""
|
||||
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
|
||||
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
"""
|
||||
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = AsyncWrappedChat(self)
|
||||
self.embeddings = AsyncWrappedEmbeddings(self)
|
||||
self.beta = AsyncWrappedBeta(self)
|
||||
@@ -116,12 +116,17 @@ def call_llm_and_track_usage(
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(exc, "status_code", 0) # default to 0 becuase its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
@@ -149,6 +154,7 @@ def call_llm_and_track_usage(
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
@@ -186,12 +192,17 @@ async def call_llm_and_track_usage_async(
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(exc, "status_code", 0) # default to 0 because its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
@@ -219,6 +230,7 @@ async def call_llm_and_track_usage_async(
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
|
||||
+161
-25
@@ -2,10 +2,14 @@ import atexit
|
||||
import logging
|
||||
import numbers
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import distro # For Linux OS detection
|
||||
from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
|
||||
@@ -14,7 +18,7 @@ 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
|
||||
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -28,6 +32,60 @@ ID_TYPES = (numbers.Number, string_types, UUID)
|
||||
MAX_DICT_SIZE = 50_000
|
||||
|
||||
|
||||
def get_os_info():
|
||||
"""
|
||||
Returns standardized OS name and version information.
|
||||
Similar to how user agent parsing works in JS.
|
||||
"""
|
||||
os_name = ""
|
||||
os_version = ""
|
||||
|
||||
platform_name = sys.platform
|
||||
|
||||
if platform_name.startswith("win"):
|
||||
os_name = "Windows"
|
||||
if hasattr(platform, "win32_ver"):
|
||||
win_version = platform.win32_ver()[0]
|
||||
if win_version:
|
||||
os_version = win_version
|
||||
|
||||
elif platform_name == "darwin":
|
||||
os_name = "Mac OS X"
|
||||
if hasattr(platform, "mac_ver"):
|
||||
mac_version = platform.mac_ver()[0]
|
||||
if mac_version:
|
||||
os_version = mac_version
|
||||
|
||||
elif platform_name.startswith("linux"):
|
||||
os_name = "Linux"
|
||||
linux_info = distro.info()
|
||||
if linux_info["version"]:
|
||||
os_version = linux_info["version"]
|
||||
|
||||
elif platform_name.startswith("freebsd"):
|
||||
os_name = "FreeBSD"
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
else:
|
||||
os_name = platform_name
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
return os_name, os_version
|
||||
|
||||
|
||||
def system_context() -> dict[str, Any]:
|
||||
os_name, os_version = get_os_info()
|
||||
|
||||
return {
|
||||
"$python_runtime": platform.python_implementation(),
|
||||
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
|
||||
"$os": os_name,
|
||||
"$os_version": os_version,
|
||||
}
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Create a new PostHog client."""
|
||||
|
||||
@@ -147,14 +205,19 @@ class Client(object):
|
||||
consumer.start()
|
||||
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$identify",
|
||||
@@ -218,8 +281,15 @@ class Client(object):
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = {**(properties or {}), **system_context()}
|
||||
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
require("event", event, string_types)
|
||||
@@ -227,7 +297,6 @@ class Client(object):
|
||||
msg = {
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"uuid": uuid,
|
||||
@@ -245,7 +314,7 @@ class Client(object):
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
|
||||
elif self.feature_flags:
|
||||
elif self.feature_flags and event != "$feature_flag_called":
|
||||
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
|
||||
@@ -264,14 +333,19 @@ class Client(object):
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$set",
|
||||
@@ -281,14 +355,19 @@ class Client(object):
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set_once": properties,
|
||||
"event": "$set_once",
|
||||
@@ -308,8 +387,13 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
distinct_id=None,
|
||||
):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("group_type", group_type, ID_TYPES)
|
||||
require("group_key", group_key, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
@@ -328,14 +412,18 @@ class Client(object):
|
||||
},
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
context = context or {}
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -346,7 +434,6 @@ class Client(object):
|
||||
"alias": distinct_id,
|
||||
},
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"event": "$create_alias",
|
||||
"distinct_id": previous_id,
|
||||
}
|
||||
@@ -356,9 +443,14 @@ class Client(object):
|
||||
def page(
|
||||
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
@@ -369,7 +461,6 @@ class Client(object):
|
||||
"event": "$pageview",
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -386,6 +477,13 @@ class Client(object):
|
||||
uuid=None,
|
||||
groups=None,
|
||||
):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
|
||||
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
|
||||
try:
|
||||
@@ -446,7 +544,6 @@ class Client(object):
|
||||
timestamp = datetime.now(tz=tzutc())
|
||||
|
||||
require("timestamp", timestamp, datetime)
|
||||
require("context", msg["context"], dict)
|
||||
|
||||
# add common
|
||||
timestamp = guess_timezone(timestamp)
|
||||
@@ -561,6 +658,21 @@ class Client(object):
|
||||
"To use feature flags, please set a personal_api_key "
|
||||
"More information: https://posthog.com/docs/api/overview",
|
||||
)
|
||||
elif e.status == 402:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
|
||||
)
|
||||
# Reset all feature flag data when quota limited
|
||||
self.feature_flags = []
|
||||
self.feature_flags_by_key = {}
|
||||
self.group_type_mapping = {}
|
||||
self.cohorts = {}
|
||||
|
||||
if self.debug:
|
||||
raise APIError(
|
||||
status=402,
|
||||
message="PostHog feature flags quota limited",
|
||||
)
|
||||
else:
|
||||
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||||
except Exception as e:
|
||||
@@ -683,7 +795,6 @@ class Client(object):
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
|
||||
# If loading in previous line failed
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
if flag["key"] == key:
|
||||
@@ -702,6 +813,7 @@ class Client(object):
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
|
||||
continue
|
||||
break
|
||||
|
||||
flag_was_locally_evaluated = response is not None
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
@@ -783,7 +895,7 @@ class Client(object):
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
response = responses_and_payloads["featureFlags"].get(key, None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key), None)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
@@ -810,16 +922,40 @@ class Client(object):
|
||||
|
||||
return payload
|
||||
|
||||
def get_remote_config_payload(self, key: str):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
if self.personal_api_key is None:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] You have to specify a personal_api_key to fetch decrypted feature flag payloads."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return remote_config(
|
||||
self.personal_api_key,
|
||||
self.host,
|
||||
key,
|
||||
timeout=self.feature_flags_request_timeout_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}")
|
||||
|
||||
def _compute_payload_locally(self, key, match_value):
|
||||
payload = None
|
||||
|
||||
if self.feature_flags_by_key is None:
|
||||
return payload
|
||||
|
||||
flag_definition = self.feature_flags_by_key.get(key) or {}
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
payload = flag_payloads.get(str(match_value).lower(), None)
|
||||
flag_definition = self.feature_flags_by_key.get(key)
|
||||
if flag_definition:
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
# For boolean flags, convert True to "true"
|
||||
# For multivariate flags, use the variant string as-is
|
||||
lookup_value = "true" if isinstance(match_value, bool) and match_value else str(match_value)
|
||||
payload = flag_payloads.get(lookup_value, None)
|
||||
return payload
|
||||
|
||||
def get_all_flags(
|
||||
|
||||
@@ -793,7 +793,7 @@ def event_from_exception(
|
||||
|
||||
|
||||
def _module_in_list(name, items):
|
||||
# type: (str, Optional[List[str]]) -> bool
|
||||
# type: (str | None, Optional[List[str]]) -> bool
|
||||
if name is None:
|
||||
return False
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from posthog import utils
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
@@ -53,6 +54,9 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
|
||||
flag_conditions = (flag.get("filters") or {}).get("groups") or []
|
||||
is_inconclusive = False
|
||||
cohort_properties = cohort_properties or {}
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
valid_variant_keys = [variant["key"] for variant in flag_variants]
|
||||
|
||||
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
||||
# evaluated first, and the variant override is applied to the first matching condition.
|
||||
@@ -67,9 +71,7 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
|
||||
# the matching variant
|
||||
if is_condition_match(flag, distinct_id, condition, properties, cohort_properties):
|
||||
variant_override = condition.get("variant")
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
|
||||
if variant_override and variant_override in valid_variant_keys:
|
||||
variant = variant_override
|
||||
else:
|
||||
variant = get_matching_variant(flag, distinct_id)
|
||||
@@ -128,8 +130,8 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).lower() in [str(val).lower() for val in value]
|
||||
return str(value).lower() == str(override_value).lower()
|
||||
return str(override_value).casefold() in [str(val).casefold() for val in value]
|
||||
return utils.str_iequals(value, override_value)
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
@@ -140,10 +142,10 @@ def match_property(property, property_values) -> bool:
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return str(value).lower() in str(override_value).lower()
|
||||
return utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "not_icontains":
|
||||
return str(value).lower() not in str(override_value).lower()
|
||||
return not utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
|
||||
+26
-1
@@ -11,7 +11,9 @@ from dateutil.tz import tzutc
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
adapter = requests.adapters.HTTPAdapter(max_retries=2)
|
||||
_session = requests.sessions.Session()
|
||||
_session.mount("https://", adapter)
|
||||
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
@@ -66,7 +68,21 @@ def _process_response(
|
||||
log = logging.getLogger("posthog")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
response = res.json() if return_json else res
|
||||
# Handle quota limited decide responses by raising a specific error
|
||||
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
|
||||
# since most of the other services handle quota limiting in other places in the application.
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "quotaLimited" in response
|
||||
and isinstance(response["quotaLimited"], list)
|
||||
and "feature_flags" in response["quotaLimited"]
|
||||
):
|
||||
log.warning(
|
||||
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
|
||||
)
|
||||
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
|
||||
return response
|
||||
try:
|
||||
payload = res.json()
|
||||
log.debug("received response: %s", payload)
|
||||
@@ -81,6 +97,11 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
|
||||
return _process_response(res, success_message="Feature flags decided 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)
|
||||
|
||||
|
||||
def batch_post(
|
||||
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
) -> requests.Response:
|
||||
@@ -105,6 +126,10 @@ class APIError(Exception):
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
|
||||
@@ -325,3 +325,17 @@ async def test_async_streaming_system_prompt(mock_client, mock_anthropic_stream)
|
||||
{"role": "system", "content": "You must always answer with 'Bar'."},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
|
||||
|
||||
def test_error(mock_client, mock_anthropic_response):
|
||||
with patch("anthropic.resources.Messages.create", side_effect=Exception("Test error")):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
with pytest.raises(Exception):
|
||||
client.messages.create(model="claude-3-opus-20240229", messages=[{"role": "user", "content": "Hello"}])
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_is_error"] is True
|
||||
assert props["$ai_error"] == "Test error"
|
||||
|
||||
@@ -2,3 +2,4 @@ import pytest
|
||||
|
||||
pytest.importorskip("langchain")
|
||||
pytest.importorskip("langchain_community")
|
||||
pytest.importorskip("langgraph")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,3 +173,17 @@ def test_privacy_mode_global(mock_client, mock_openai_response):
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_error(mock_client, mock_openai_response):
|
||||
with patch("openai.resources.chat.completions.Completions.create", side_effect=Exception("Test error")):
|
||||
client = OpenAI(api_key="test-key", posthog_client=mock_client)
|
||||
with pytest.raises(Exception):
|
||||
client.chat.completions.create(model="gpt-4", messages=[{"role": "user", "content": "Hello"}])
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_is_error"] is True
|
||||
assert props["$ai_error"] == "Test error"
|
||||
|
||||
+126
-35
@@ -5,8 +5,10 @@ from uuid import uuid4
|
||||
|
||||
import mock
|
||||
import six
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -53,6 +55,11 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
# these will change between platforms so just asssert on presence here
|
||||
assert msg["properties"]["$python_runtime"] == mock.ANY
|
||||
assert msg["properties"]["$python_version"] == mock.ANY
|
||||
assert msg["properties"]["$os"] == mock.ANY
|
||||
assert msg["properties"]["$os_version"] == mock.ANY
|
||||
|
||||
def test_basic_capture_with_uuid(self):
|
||||
client = self.client
|
||||
@@ -100,7 +107,6 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["source"], "repo-name")
|
||||
|
||||
def test_basic_capture_exception(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
exception = Exception("test exception")
|
||||
@@ -128,7 +134,6 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_distinct_id(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
exception = Exception("test exception")
|
||||
@@ -156,7 +161,6 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com")
|
||||
exception = Exception("test exception")
|
||||
@@ -184,7 +188,6 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com")
|
||||
exception = Exception("test exception")
|
||||
@@ -212,7 +215,6 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_no_exception_given(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
try:
|
||||
@@ -249,10 +251,8 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["in_app"], True)
|
||||
|
||||
def test_basic_capture_exception_with_no_exception_happening(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
with self.assertLogs("posthog", level="WARNING") as logs:
|
||||
|
||||
client = self.client
|
||||
client.capture_exception()
|
||||
|
||||
@@ -292,7 +292,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -321,7 +320,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -344,7 +342,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "false-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -387,6 +384,25 @@ class TestClient(unittest.TestCase):
|
||||
assert "$feature/false-flag" not in msg["properties"]
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_quota_limited(self, patch_get):
|
||||
mock_response = {
|
||||
"type": "quota_limited",
|
||||
"detail": "You have exceeded your feature flag request quota",
|
||||
"code": "payment_required",
|
||||
}
|
||||
patch_get.side_effect = APIError(402, mock_response["detail"])
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
with self.assertLogs("posthog", level="WARNING") as logs:
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertEqual(client.feature_flags, [])
|
||||
self.assertEqual(client.feature_flags_by_key, {})
|
||||
self.assertEqual(client.group_type_mapping, {})
|
||||
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"}}
|
||||
@@ -396,7 +412,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -425,7 +440,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -581,16 +595,14 @@ class TestClient(unittest.TestCase):
|
||||
"distinct_id",
|
||||
"python test event",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -623,13 +635,12 @@ class TestClient(unittest.TestCase):
|
||||
def test_advanced_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -651,14 +662,11 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test_advanced_set(self):
|
||||
client = self.client
|
||||
success, msg = client.set(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
success, msg = client.set("distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid")
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -681,13 +689,12 @@ class TestClient(unittest.TestCase):
|
||||
def test_advanced_set_once(self):
|
||||
client = self.client
|
||||
success, msg = client.set_once(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set_once"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -736,7 +743,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test_advanced_group_identify(self):
|
||||
success, msg = self.client.group_identify(
|
||||
"organization", "id:5", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"organization", "id:5", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -754,16 +761,14 @@ class TestClient(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
|
||||
def test_advanced_group_identify_with_distinct_id(self):
|
||||
success, msg = self.client.group_identify(
|
||||
"organization",
|
||||
"id:5",
|
||||
{"trait": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
distinct_id="distinct_id",
|
||||
)
|
||||
|
||||
@@ -783,7 +788,6 @@ class TestClient(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
|
||||
def test_basic_alias(self):
|
||||
client = self.client
|
||||
@@ -819,15 +823,13 @@ class TestClient(unittest.TestCase):
|
||||
"distinct_id",
|
||||
"https://posthog.com/contact",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
@@ -1051,7 +1053,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
|
||||
client.feature_flags = [{"key": "example"}]
|
||||
|
||||
self.assertFalse(client.feature_enabled("example", "distinct_id"))
|
||||
|
||||
@@ -1121,3 +1123,92 @@ class TestClient(unittest.TestCase):
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
# name, sys_platform, version_info, expected_runtime, expected_version, expected_os, expected_os_version, platform_method, platform_return, distro_info
|
||||
(
|
||||
"macOS",
|
||||
"darwin",
|
||||
(3, 8, 10),
|
||||
"MockPython",
|
||||
"3.8.10",
|
||||
"Mac OS X",
|
||||
"10.15.7",
|
||||
"mac_ver",
|
||||
("10.15.7", "", ""),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Windows",
|
||||
"win32",
|
||||
(3, 8, 10),
|
||||
"MockPython",
|
||||
"3.8.10",
|
||||
"Windows",
|
||||
"10",
|
||||
"win32_ver",
|
||||
("10", "", "", ""),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Linux",
|
||||
"linux",
|
||||
(3, 8, 10),
|
||||
"MockPython",
|
||||
"3.8.10",
|
||||
"Linux",
|
||||
"20.04",
|
||||
None,
|
||||
None,
|
||||
{"version": "20.04"},
|
||||
),
|
||||
]
|
||||
)
|
||||
def test_mock_system_context(
|
||||
self,
|
||||
_name,
|
||||
sys_platform,
|
||||
version_info,
|
||||
expected_runtime,
|
||||
expected_version,
|
||||
expected_os,
|
||||
expected_os_version,
|
||||
platform_method,
|
||||
platform_return,
|
||||
distro_info,
|
||||
):
|
||||
"""Test that we can mock platform and sys for testing system_context"""
|
||||
with mock.patch("posthog.client.platform") as mock_platform:
|
||||
with mock.patch("posthog.client.sys") as mock_sys:
|
||||
# Set up common mocks
|
||||
mock_platform.python_implementation.return_value = expected_runtime
|
||||
mock_sys.version_info = version_info
|
||||
mock_sys.platform = sys_platform
|
||||
|
||||
# Set up platform-specific mocks
|
||||
if platform_method:
|
||||
getattr(mock_platform, platform_method).return_value = platform_return
|
||||
|
||||
# Special handling for Linux which uses distro module
|
||||
if sys_platform == "linux":
|
||||
# Directly patch the get_os_info function to return our expected values
|
||||
with mock.patch("posthog.client.get_os_info", return_value=(expected_os, expected_os_version)):
|
||||
from posthog.client import system_context
|
||||
|
||||
context = system_context()
|
||||
else:
|
||||
# Get system context for non-Linux platforms
|
||||
from posthog.client import system_context
|
||||
|
||||
context = system_context()
|
||||
|
||||
# Verify results
|
||||
expected_context = {
|
||||
"$python_runtime": expected_runtime,
|
||||
"$python_version": expected_version,
|
||||
"$os": expected_os,
|
||||
"$os_version": expected_os_version,
|
||||
}
|
||||
|
||||
assert context == expected_context
|
||||
|
||||
@@ -38,7 +38,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -69,6 +68,59 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertTrue(feature_flag_match)
|
||||
self.assertFalse(not_feature_flag_match)
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
self.client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "location",
|
||||
"operator": "exact",
|
||||
"value": ["Straße"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "star",
|
||||
"operator": "exact",
|
||||
"value": ["ſun"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"location": "straße"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"location": "strasse"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "ſun"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "sun"})
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_flag_group_properties(self, patch_get, patch_decide):
|
||||
@@ -77,7 +129,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "group-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"aggregation_group_type_index": 0,
|
||||
@@ -170,7 +221,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -286,7 +336,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -301,7 +350,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -343,7 +391,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -358,7 +405,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -410,7 +456,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -457,7 +502,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -472,7 +516,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
# decide called always because experience_continuity is set
|
||||
self.assertTrue(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@@ -487,7 +531,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -503,7 +546,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -518,7 +560,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -551,7 +592,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -570,7 +610,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -588,7 +627,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -654,7 +692,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -670,7 +707,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -696,7 +732,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -715,7 +750,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -751,7 +785,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -767,7 +800,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -782,7 +814,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -814,7 +845,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -833,7 +863,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -851,7 +880,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -888,7 +916,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -904,7 +931,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -927,7 +953,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": False,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -943,7 +968,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -969,7 +993,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
id: 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1007,13 +1030,15 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"beta-feature",
|
||||
"some-distinct-id",
|
||||
person_properties={
|
||||
"latestBuildVersion": "24.32..1",
|
||||
"latestBuildVersion": "24.32.1",
|
||||
"latestBuildVersionMajor": "24",
|
||||
"latestBuildVersionMinor": "32",
|
||||
"latestBuildVersionPatch": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
|
||||
@@ -1023,7 +1048,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1094,7 +1118,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1207,7 +1230,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1232,7 +1254,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 0,
|
||||
"filters": {
|
||||
@@ -1257,7 +1278,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": None,
|
||||
"filters": {
|
||||
@@ -1281,7 +1301,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1305,7 +1324,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1330,7 +1348,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1352,7 +1369,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1418,7 +1434,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1459,7 +1474,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1511,7 +1525,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1552,7 +1565,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1595,7 +1607,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1659,7 +1670,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -2237,7 +2247,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2337,6 +2346,55 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
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"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [{"key": "region", "value": "USA"}],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Gamma Feature",
|
||||
"key": "simple-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
self.assertTrue(
|
||||
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"region": "USA"})
|
||||
)
|
||||
|
||||
# Grab the capture message that was just added to the queue
|
||||
msg = client.queue.get(block=False)
|
||||
assert msg["event"] == "$feature_flag_called"
|
||||
assert msg["properties"]["$feature_flag"] == "complex-flag"
|
||||
assert msg["properties"]["$feature_flag_response"] is True
|
||||
assert msg["properties"]["locally_evaluated"] is True
|
||||
assert msg["properties"]["$feature/complex-flag"] is True
|
||||
assert "$feature/simple-flag" not in msg["properties"]
|
||||
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):
|
||||
@@ -2351,7 +2409,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2429,7 +2486,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2472,7 +2528,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2531,7 +2586,6 @@ class TestConsistency(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "simple-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 45}],
|
||||
@@ -3559,7 +3613,6 @@ class TestConsistency(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "multivariate-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 55}],
|
||||
@@ -4587,3 +4640,84 @@ class TestConsistency(unittest.TestCase):
|
||||
self.assertEqual(feature_flag_match, results[i])
|
||||
else:
|
||||
self.assertFalse(feature_flag_match)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {"featureFlags": {}} # Ensure decide returns empty flags
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation is case-sensitive
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_payload_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that payload retrieval is case-sensitive
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
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")
|
||||
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation and payload retrieval are consistently case-sensitive
|
||||
# Only exact match should work
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
|
||||
# Different cases should not match
|
||||
test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
|
||||
for case in test_cases:
|
||||
self.assertFalse(client.feature_enabled(case, "user1"))
|
||||
self.assertIsNone(client.get_feature_flag_payload(case, "user1"))
|
||||
|
||||
@@ -2,10 +2,11 @@ import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
|
||||
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@@ -44,6 +45,36 @@ class TestRequests(unittest.TestCase):
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzutc
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
|
||||
from posthog import utils
|
||||
|
||||
@@ -81,6 +84,32 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
|
||||
|
||||
def test_clean_pydantic(self):
|
||||
class ModelV2(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
baz: Optional[str] = None
|
||||
|
||||
class ModelV1(BaseModelV1):
|
||||
foo: int
|
||||
bar: str
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
foo: ModelV2
|
||||
|
||||
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
|
||||
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
|
||||
self.assertEqual(
|
||||
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))), {"foo": {"foo": "1", "bar": 2, "baz": "3"}}
|
||||
)
|
||||
|
||||
class Dummy:
|
||||
def model_dump(self, required_param):
|
||||
pass
|
||||
|
||||
# Skips a class with a defined non-Pydantic `model_dump` method.
|
||||
self.assertEqual(utils.clean({"test": Dummy()}), {})
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
def test_size_limited_dict(self):
|
||||
|
||||
+55
-5
@@ -51,14 +51,24 @@ def clean(item):
|
||||
return float(item)
|
||||
if isinstance(item, UUID):
|
||||
return str(item)
|
||||
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
|
||||
if isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
|
||||
return item
|
||||
elif isinstance(item, (set, list, tuple)):
|
||||
if isinstance(item, (set, list, tuple)):
|
||||
return _clean_list(item)
|
||||
elif isinstance(item, dict):
|
||||
# Pydantic model
|
||||
try:
|
||||
# v2+
|
||||
if hasattr(item, "model_dump") and callable(item.model_dump):
|
||||
item = item.model_dump()
|
||||
# v1
|
||||
elif hasattr(item, "dict") and callable(item.dict):
|
||||
item = item.dict()
|
||||
except TypeError as e:
|
||||
log.debug(f"Could not serialize Pydantic-like model: {e}")
|
||||
pass
|
||||
if isinstance(item, dict):
|
||||
return _clean_dict(item)
|
||||
else:
|
||||
return _coerce_unicode(item)
|
||||
return _coerce_unicode(item)
|
||||
|
||||
|
||||
def _clean_list(list_):
|
||||
@@ -115,3 +125,43 @@ def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
return date_obj
|
||||
|
||||
|
||||
def str_icontains(source, search):
|
||||
"""
|
||||
Check if a string contains another string, ignoring case.
|
||||
|
||||
Args:
|
||||
source: The string to search within
|
||||
search: The substring to search for
|
||||
|
||||
Returns:
|
||||
bool: True if search is a substring of source (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_icontains("Hello World", "WORLD")
|
||||
True
|
||||
>>> str_icontains("Hello World", "python")
|
||||
False
|
||||
"""
|
||||
return str(search).casefold() in str(source).casefold()
|
||||
|
||||
|
||||
def str_iequals(value, comparand):
|
||||
"""
|
||||
Check if a string equals another string, ignoring case.
|
||||
|
||||
Args:
|
||||
value: The string to compare
|
||||
comparand: The string to compare with
|
||||
|
||||
Returns:
|
||||
bool: True if value and comparand are equal (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_iequals("Hello World", "hello world")
|
||||
True
|
||||
>>> str_iequals("Hello World", "hello")
|
||||
False
|
||||
"""
|
||||
return str(value).casefold() == str(comparand).casefold()
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.8.4"
|
||||
VERSION = "3.18.1"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -20,19 +20,30 @@ install_requires = [
|
||||
"monotonic>=1.5",
|
||||
"backoff>=1.10.0",
|
||||
"python-dateutil>2.1",
|
||||
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
|
||||
]
|
||||
|
||||
extras_require = {
|
||||
"dev": [
|
||||
"black",
|
||||
"django-stubs",
|
||||
"isort",
|
||||
"flake8",
|
||||
"flake8-print",
|
||||
"lxml",
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"types-mock",
|
||||
"types-python-dateutil",
|
||||
"types-requests",
|
||||
"types-setuptools",
|
||||
"types-six",
|
||||
"pre-commit",
|
||||
"pydantic",
|
||||
],
|
||||
"test": [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==0.3.15",
|
||||
"freezegun==1.5.1",
|
||||
"pylint",
|
||||
"flake8",
|
||||
"coverage",
|
||||
@@ -42,9 +53,12 @@ extras_require = {
|
||||
"django",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"langgraph",
|
||||
"langchain-community>=0.2.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
"langchain-anthropic>=0.2.0",
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
],
|
||||
"sentry": ["sentry-sdk", "django"],
|
||||
"langchain": ["langchain>=0.2.0"],
|
||||
@@ -80,19 +94,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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ parser.add_argument("--type", help="The posthog message type")
|
||||
|
||||
parser.add_argument("--distinct_id", help="the user id to send the event as")
|
||||
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
|
||||
parser.add_argument("--context", help="additional context for the event (JSON-encoded)")
|
||||
|
||||
parser.add_argument("--event", help="the event name to send with the event")
|
||||
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
|
||||
@@ -48,7 +47,6 @@ def capture():
|
||||
options.event,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +56,6 @@ def page():
|
||||
name=options.name,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +64,6 @@ def identify():
|
||||
options.distinct_id,
|
||||
anonymous_id=options.anonymousId,
|
||||
traits=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -75,7 +71,6 @@ def set_once():
|
||||
posthog.set_once(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,7 +78,6 @@ def set():
|
||||
posthog.set(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user