Compare commits

...
28 Commits
Author SHA1 Message Date
Dylan MartinandGitHub f4c99714c3 chore(flags): improved some logs for quota limiting (#197)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez

* this will probably work

* now do local eval

* okay

* yo

* formatting

* fix import order

* type check

* ai yi yi

* code review

* format

* do it

* merge conflict UGH

* black formatting

* bump version

* correct changelog
2025-03-03 14:00:52 -05:00
Peter KirkhamGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
7dc4cbb16b feat: azure export w/ async (#200)
* feat: azure export w/ async

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-02-28 20:40:09 +00:00
Michael MatlokaandGitHub 4cda646f03 feat(llm-observability): $ai_tools capture in Langchain (#199) 2025-02-27 17:50:20 +00:00
Paul D'AmbraandGitHub ea4e7fa16d feat: add some platform info to events (#198) 2025-02-26 12:26:17 +00:00
Peter KirkhamandGitHub 57a3e7470f fix: async client (#196) 2025-02-23 13:10:43 +00:00
Dylan MartinandGitHub 5e0f9e35c1 feat(feature-flags): support quota limiting for feature flags (#195)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez

* this will probably work

* now do local eval

* okay

* yo

* formatting

* fix import order

* type check

* ai yi yi

* code review

* format
2025-02-21 15:45:51 -05:00
Dylan MartinandGitHub 337f7da7c5 fix(flags): remove lower() when evaluating feature flag payloads – these payloads are case-sensitive! (#191)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez
2025-02-19 19:51:40 -05:00
Peter KirkhamandGitHub 31652d5ec3 fix: support usage as part of generation (#192) 2025-02-18 00:17:52 +00:00
HavenGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Manoel Aranda Neto
6764c786a4 feat(flags): Add method for fetching decrypted remote config flag payload (#180)
* feat(flags): Add method for fetching decrypted remote config flag payload

* tweak

* tweak

* tweak

* Update posthog/__init__.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* tweak

* get example script working

* format

* sort import

* tweak

* bump minor version

* Update posthog/version.py

Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>

* Use flag key instead of id

* tweak

* tweak

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>
2025-02-13 14:21:05 -08:00
Frank HamandandGitHub 1b57a96509 automatically retry connection errors (#190)
* automatically retry connection errors

from the docs for max_retries: this applies only to failed DNS lookups,
 socket connections and connection timeouts

* run tests on multiple python versions

* update freezegun
2025-02-12 12:29:01 +00:00
Phil HaackandGitHub 38683e8550 Add mypy to CI (#189) 2025-02-11 09:50:45 -08:00
Phil HaackandGitHub a5c8f62a63 Use casefold to compare strings case insensitively (#184) 2025-02-11 08:06:20 -08:00
Rafael AudibertandGitHub e480b88dce fix: Move code under mypy type (#188)
* fix: Move code under mypy type

This is incorrect, we should've added these slightly lower in the method definition to avoid mypy from breaking

* feat: Bump to 3.12.1
2025-02-11 12:01:08 -03:00
Phil HaackandGitHub 3ff2a8599d Remove the usage of is_simple_flag (#186) 2025-02-10 18:52:54 -08:00
Phil HaackandGitHub a3cf4ad5fb Stop capturing all feature flags on $feature_flag_called event. (#181) 2025-02-10 17:39:03 -08:00
Peter KirkhamandGitHub cec532f241 feat: add beta parse method support (#185) 2025-02-11 00:43:36 +00:00
Phil HaackandGitHub 415508087f Deprecate the context argument (#182) 2025-02-10 15:26:29 -08:00
Phil HaackandGitHub 994003fc42 Allow specifying the flag in the example script (#157)
* Allow specifying the flag in the example script

* Reformat

* Run isort
2025-02-07 09:28:00 +09:00
Phil HaackandGitHub 319b3807f3 Move accessing variants outside of loop (#175)
* Move accessing variants outside of loop

`flag_variants` doesn't depend on condition so it doesn't make sense to declare it in the loop.

* Fix assertion

* Remove incorrect comment

Comment seems superfluous anyways.

* Break out of the loop when the key is found

The purpose of the loop is to loop through the flag keys and evaluate the one where `flag["key"] == key`. Once that key is found, there's no need to continue the loop.

* Complete the test

Looks like the test was missing an assert.

* Precompute valid variant keys outside loop
2025-02-07 09:24:17 +09:00
Peter KirkhamandGitHub 5e7314f89d fix: langchain tool parent add (#179) 2025-02-05 19:03:14 +00:00
8f43bbc613 feat(llm-observability): LangChain spans (#176)
* feat: refactor to dataclasses

* feat: spans

* test: fix part 1

* test: fix part 2

* test: fix part 3

* test: fix part n

* test: add langgraph agent test

* chore: bump and linters

* chore: bump

* fix: correctly capture a parent id when a custom trace_id is set

* test: multiple spans parent_ids

* fix: exception serialization

* refactor: ai_trace_name -> ai_span_name and ai_generation_id -> ai_span_id

* fix: logs typos

* Add minor breaking change note to changelog

* fix: naming

---------

Co-authored-by: Michael Matloka <michael@matloka.com>
2025-01-28 14:23:22 +01:00
Georgiy TarasovandGitHub eb07aafaa3 fix: serialize pydantic models (#177) 2025-01-27 17:38:49 +00:00
Peter KirkhamandGitHub 0f8b10bb09 feat(ai): add error handling to python ai sdk (#174) 2025-01-24 21:09:49 +00:00
Georgiy TarasovandGitHub 45dc933b9c fix(llm-observability): parallel traces (#172)
* fix: parallel traces

* fix: linters

* chore: bump

* fix: better naming for clarity
2025-01-23 17:27:46 +01:00
Michael Matloka 2835af49cb fix: Actually fix LangChain callback in posthoganalytics 2025-01-22 16:27:36 +01:00
Michael MatlokaandGitHub 54506e5a7c fix: Account for import posthog in posthoganalytics release (#171) 2025-01-22 13:50:39 +00:00
Peter KirkhamandGitHub bcf5b27083 chore: bump (#170) 2025-01-21 23:33:47 +00:00
Michael MatlokaandGitHub 0b6ff2e8d3 feat(llm-observability): LangChain tracing, with LangGraph tests (#169) 2025-01-21 23:18:55 +00:00
31 changed files with 2380 additions and 486 deletions
+13 -6
View File
@@ -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: |
+74
View File
@@ -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.
+2
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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()
+30 -5
View File
@@ -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())
+61
View File
@@ -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]
+38
View File
@@ -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
+77
View File
@@ -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
View File
@@ -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__
+2 -1
View File
@@ -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"]
+43
View File
@@ -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,
)
+45
View File
@@ -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,
)
+41
View File
@@ -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)
+12
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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
+9 -7
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+187 -53
View File
@@ -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"))
+32 -1
View File
@@ -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",
+29
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
VERSION = "3.8.4"
VERSION = "3.18.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+17 -12
View File
@@ -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",
],
)
-6
View File
@@ -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),
)