Compare commits

..
Author SHA1 Message Date
Frank Hamand d82cc0b6a9 3.13 is latest release 2025-02-19 09:44:44 +00:00
Frank Hamand 9a6131337b use latest python for CI 2025-02-19 09:43:13 +00:00
23 changed files with 261 additions and 994 deletions
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
with:
fetch-depth: 1
- name: Set up Python 3.11
- name: Set up Python 3.13
uses: actions/setup-python@v2
with:
python-version: 3.11.11
python-version: "3.13"
- uses: actions/cache@v3
with:
-37
View File
@@ -1,41 +1,4 @@
## 3.19.1  2025-03-11
1. Fix bug where None is sent as delta in azure
## 3.19.0  2025-03-04
1. Add support for tool calls in OpenAI and Anthropic.
2. Add support for cached tokens.
## 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
-2
View File
@@ -10,10 +10,8 @@ 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`
+220
View File
@@ -0,0 +1,220 @@
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.host = os.getenv("POSTHOG_HOST", "http://localhost:8000") # Or https://app.posthog.com
posthog.debug = True
# change this to False to see usage events
# posthog.privacy_mode = True
openai_client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY", "your-openai-api-key"),
posthog_client=posthog,
)
async_openai_client = AsyncOpenAI(
api_key=os.getenv("OPENAI_API_KEY", "your-openai-api-key"),
posthog_client=posthog,
)
def main_sync():
trace_id = str(uuid.uuid4())
print("Trace ID:", trace_id)
distinct_id = "test2_distinct_id"
properties = {"test_property": "test_value"}
groups = {"company": "test_company"}
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()
beta_openai_call(distinct_id, trace_id, properties, groups)
except Exception as e:
print("Error during OpenAI call:", str(e))
async def main_async():
trace_id = str(uuid.uuid4())
print("Trace ID:", trace_id)
distinct_id = "test_distinct_id"
properties = {"test_property": "test_value"}
groups = {"company": "test_company"}
try:
await basic_async_openai_call(distinct_id, trace_id, properties, groups)
await streaming_async_openai_call(distinct_id, trace_id, properties, groups)
await embedding_async_openai_call(distinct_id, trace_id, properties, groups)
await image_async_openai_call()
except Exception as e:
print("Error during OpenAI call:", str(e))
def basic_openai_call(distinct_id, trace_id, properties, groups):
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a complex problem solver."},
{"role": "user", "content": "Explain quantum computing in simple terms."},
],
max_tokens=100,
temperature=0.7,
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id,
posthog_properties=properties,
posthog_groups=groups,
)
print(response)
if response and response.choices:
print("OpenAI response:", response.choices[0].message.content)
else:
print("No response or unexpected format returned.")
return response
async def basic_async_openai_call(distinct_id, trace_id, properties, groups):
response = await async_openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a complex problem solver."},
{"role": "user", "content": "Explain quantum computing in simple terms."},
],
max_tokens=100,
temperature=0.7,
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id,
posthog_properties=properties,
posthog_groups=groups,
)
if response and hasattr(response, "choices"):
print("OpenAI response:", response.choices[0].message.content)
else:
print("No response or unexpected format returned.")
return response
def streaming_openai_call(distinct_id, trace_id, properties, groups):
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a complex problem solver."},
{"role": "user", "content": "Explain quantum computing in simple terms."},
],
max_tokens=100,
temperature=0.7,
stream=True,
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id,
posthog_properties=properties,
posthog_groups=groups,
)
for chunk in response:
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
print(chunk.choices[0].delta.content or "", end="")
return response
async def streaming_async_openai_call(distinct_id, trace_id, properties, groups):
response = await async_openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a complex problem solver."},
{"role": "user", "content": "Explain quantum computing in simple terms."},
],
max_tokens=100,
temperature=0.7,
stream=True,
posthog_distinct_id=distinct_id,
posthog_trace_id=trace_id,
posthog_properties=properties,
posthog_groups=groups,
)
async for chunk in response:
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
print(chunk.choices[0].delta.content or "", end="")
return response
# none instrumented
def image_openai_call():
response = openai_client.images.generate(model="dall-e-3", prompt="A cute baby hedgehog", n=1, size="1024x1024")
print(response)
return response
# none instrumented
async def image_async_openai_call():
response = await async_openai_client.images.generate(
model="dall-e-3", prompt="A cute baby hedgehog", n=1, size="1024x1024"
)
print(response)
return response
def embedding_openai_call(posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_groups):
response = openai_client.embeddings.create(
input="The hedgehog is cute",
model="text-embedding-3-small",
posthog_distinct_id=posthog_distinct_id,
posthog_trace_id=posthog_trace_id,
posthog_properties=posthog_properties,
posthog_groups=posthog_groups,
)
print(response)
return response
async def embedding_async_openai_call(posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_groups):
response = await async_openai_client.embeddings.create(
input="The hedgehog is cute",
model="text-embedding-3-small",
posthog_distinct_id=posthog_distinct_id,
posthog_trace_id=posthog_trace_id,
posthog_properties=posthog_properties,
posthog_groups=posthog_groups,
)
print(response)
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())
+6
View File
@@ -43,13 +43,19 @@ sentry_django_example/sentry_django_example/settings.py:0: error: Need type anno
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]
-4
View File
@@ -125,8 +125,6 @@ class WrappedMessages(Messages):
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
@@ -186,8 +184,6 @@ class WrappedMessages(Messages):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
-4
View File
@@ -125,8 +125,6 @@ class AsyncWrappedMessages(AsyncMessages):
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
@@ -186,8 +184,6 @@ class AsyncWrappedMessages(AsyncMessages):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
+3 -24
View File
@@ -60,8 +60,6 @@ class GenerationMetadata(SpanMetadata):
"""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]
@@ -379,8 +377,6 @@ class CallbackHandler(BaseCallbackHandler):
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
if isinstance(invocation_params, dict):
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"):
generation.model = model
@@ -428,11 +424,7 @@ class CallbackHandler(BaseCallbackHandler):
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),
trace_id, run_id, run, outputs, self._get_parent_run_id(trace_id, run_id, parent_run_id)
)
def _capture_trace_or_span(
@@ -473,10 +465,7 @@ class CallbackHandler(BaseCallbackHandler):
)
def _pop_run_and_capture_generation(
self,
run_id: UUID,
parent_run_id: Optional[UUID],
response: Union[LLMResult, BaseException],
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)
@@ -487,11 +476,7 @@ class CallbackHandler(BaseCallbackHandler):
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),
trace_id, run_id, run, response, self._get_parent_run_id(trace_id, run_id, parent_run_id)
)
def _capture_generation(
@@ -515,12 +500,6 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_latency": run.latency,
"$ai_base_url": run.base_url,
}
if run.tools:
event_properties["$ai_tools"] = with_privacy_mode(
self._client,
self._privacy_mode,
run.tools,
)
if isinstance(output, BaseException):
event_properties["$ai_http_status"] = _get_http_status(output)
+1 -2
View File
@@ -1,5 +1,4 @@
from .openai import OpenAI
from .openai_async import AsyncOpenAI
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
__all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
__all__ = ["OpenAI", "AsyncOpenAI"]
+6 -39
View File
@@ -1,6 +1,6 @@
import time
import uuid
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
try:
import openai
@@ -92,7 +92,6 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
start_time = time.time()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
@@ -101,8 +100,6 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
def generator():
nonlocal usage_stats
nonlocal accumulated_content
nonlocal accumulated_tools
try:
for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
@@ -115,29 +112,10 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
yield chunk
@@ -145,7 +123,6 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
@@ -156,7 +133,6 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
usage_stats,
latency,
output,
tools,
)
return generator()
@@ -172,7 +148,6 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
usage_stats: Dict[str, int],
latency: float,
output: str,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = uuid.uuid4()
@@ -190,20 +165,12 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
**posthog_properties,
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -267,7 +234,7 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
**posthog_properties,
}
if posthog_distinct_id is None:
+7 -41
View File
@@ -1,6 +1,6 @@
import time
import uuid
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
try:
import openai
@@ -73,8 +73,6 @@ 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,
@@ -93,14 +91,13 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
start_time = time.time()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
response = await super().create(**kwargs)
async def async_generator():
nonlocal usage_stats, accumulated_content, accumulated_tools
nonlocal usage_stats, accumulated_content
try:
async for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
@@ -112,30 +109,10 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
yield chunk
@@ -143,7 +120,6 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
@@ -154,7 +130,6 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
usage_stats,
latency,
output,
tools,
)
return async_generator()
@@ -170,7 +145,6 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
usage_stats: Dict[str, int],
latency: float,
output: str,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = uuid.uuid4()
@@ -188,20 +162,12 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
**posthog_properties,
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -267,7 +233,7 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
**posthog_properties,
}
if posthog_distinct_id is None:
-41
View File
@@ -1,41 +0,0 @@
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)
-45
View File
@@ -34,25 +34,15 @@ def get_usage(response, provider: str) -> Dict[str, Any]:
return {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
}
elif provider == "openai":
cached_tokens = 0
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
response.usage.prompt_tokens_details, "cached_tokens"
):
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
return {
"input_tokens": response.usage.prompt_tokens,
"output_tokens": response.usage.completion_tokens,
"cache_read_input_tokens": cached_tokens,
}
return {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
}
@@ -96,21 +86,6 @@ def format_response_openai(response):
return output
def format_tool_calls(response, provider: str):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
return response.tools
elif provider == "openai":
if (
hasattr(response, "choices")
and response.choices
and hasattr(response.choices[0].message, "tool_calls")
and response.choices[0].message.tool_calls
):
return response.choices[0].message.tool_calls
return None
def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
if provider != "anthropic":
return kwargs.get("messages")
@@ -182,16 +157,6 @@ def call_llm_and_track_usage(
**(error_params or {}),
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -268,16 +233,6 @@ async def call_llm_and_track_usage_async(
**(error_params or {}),
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
+6 -83
View File
@@ -2,14 +2,11 @@ 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
@@ -32,60 +29,6 @@ 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."""
@@ -288,8 +231,7 @@ class Client(object):
stacklevel=2,
)
properties = {**(properties or {}), **system_context()}
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("event", event, string_types)
@@ -658,21 +600,6 @@ 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:
@@ -895,7 +822,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), None)
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
@@ -948,14 +875,10 @@ class Client(object):
if self.feature_flags_by_key is None:
return payload
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)
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)
return payload
def get_all_flags(
+1 -19
View File
@@ -68,21 +68,7 @@ def _process_response(
log = logging.getLogger("posthog")
if res.status_code == 200:
log.debug(success_message)
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
return res.json() if return_json else res
try:
payload = res.json()
log.debug("received response: %s", payload)
@@ -126,10 +112,6 @@ 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)):
@@ -55,28 +55,6 @@ def mock_anthropic_stream():
return stream_generator()
@pytest.fixture
def mock_anthropic_response_with_cached_tokens():
# Create a mock Usage object with cached_tokens in input_tokens_details
usage = Usage(
input_tokens=20,
output_tokens=10,
cache_read_input_tokens=15,
cache_creation_input_tokens=2,
)
return Message(
id="msg_123",
type="message",
role="assistant",
content=[{"type": "text", "text": "Test response"}],
model="claude-3-opus-20240229",
usage=usage,
stop_reason="end_turn",
stop_sequence=None,
)
def test_basic_completion(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
@@ -361,34 +339,3 @@ def test_error(mock_client, mock_anthropic_response):
props = call_args["properties"]
assert props["$ai_is_error"] is True
assert props["$ai_error"] == "Test error"
def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response_with_cached_tokens):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_cached_tokens
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
assert props["$ai_cache_creation_input_tokens"] == 2
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
@@ -1168,61 +1168,6 @@ async def test_async_anthropic_streaming(mock_client):
assert isinstance(trace_props["$ai_output_state"], AIMessage)
def test_metadata_tools(mock_client):
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
tools = [
[
{
"type": "function",
"function": {
"name": "foo",
"description": "The foo.",
"parameters": {
"properties": {
"bar": {
"description": "The bar of foo.",
"type": "string",
},
},
"required": ["query_description", "query_kind"],
"type": "object",
"additionalProperties": False,
},
"strict": True,
},
}
]
]
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://us.posthog.com"}},
run_id,
messages=[{"role": "user", "content": "What's the weather like in SF?"}],
invocation_params={"temperature": 0.5, "tools": tools},
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
name="test",
)
expected = GenerationMetadata(
model="hog-mini",
input=[{"role": "user", "content": "What's the weather like in SF?"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="posthog",
base_url="https://us.posthog.com",
name="test",
tools=tools,
end_time=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
run = callbacks._pop_run_metadata(run_id)
expected.end_time = 1234567891
assert run == expected
assert callbacks._runs == {}
def test_tool_calls(mock_client):
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
model = FakeMessagesListChatModel(
-312
View File
@@ -1,14 +1,9 @@
import json
import time
from unittest.mock import patch
import pytest
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
from openai.types.completion_usage import CompletionUsage
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
from openai.types.embedding import Embedding
@@ -67,67 +62,6 @@ def mock_embedding_response():
)
@pytest.fixture
def mock_openai_response_with_cached_tokens():
return ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=10,
prompt_tokens=20,
total_tokens=30,
prompt_tokens_details={"cached_tokens": 15},
),
)
@pytest.fixture
def mock_openai_response_with_tool_calls():
return ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="tool_calls",
index=0,
message=ChatCompletionMessage(
content="I'll check the weather for you.",
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id="call_abc123",
type="function",
function=Function(
name="get_weather",
arguments='{"location": "San Francisco", "unit": "celsius"}',
),
)
],
),
)
],
usage=CompletionUsage(
completion_tokens=15,
prompt_tokens=20,
total_tokens=35,
),
)
def test_basic_completion(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
@@ -253,249 +187,3 @@ def test_error(mock_client, mock_openai_response):
props = call_args["properties"]
assert props["$ai_is_error"] is True
assert props["$ai_error"] == "Test error"
def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_cached_tokens
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_cached_tokens
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_tool_calls
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_with_tool_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "What's the weather in San Francisco?"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "I'll check the weather for you."}]
# Check that tool calls are properly captured
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
# Verify the tool call details
tool_call = tool_calls[0]
assert tool_call.id == "call_abc123"
assert tool_call.type == "function"
assert tool_call.function.name == "get_weather"
# Verify the arguments
arguments = tool_call.function.arguments
parsed_args = json.loads(arguments)
assert parsed_args == {"location": "San Francisco", "unit": "celsius"}
# Check token usage
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_streaming_with_tool_calls(mock_client):
# Create mock tool call chunks that will be returned in sequence
tool_call_chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
role="assistant",
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
name="get_weather",
arguments='{"location": "',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk2",
model="gpt-4",
object="chat.completion.chunk",
created=1234567891,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
arguments='San Francisco"',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk3",
model="gpt-4",
object="chat.completion.chunk",
created=1234567892,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
arguments=', "unit": "celsius"}',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk4",
model="gpt-4",
object="chat.completion.chunk",
created=1234567893,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
content="The weather in San Francisco is 15°C.",
),
finish_reason=None,
)
],
usage=CompletionUsage(
prompt_tokens=20,
completion_tokens=15,
total_tokens=35,
),
),
]
# Mock the create method to return our chunks
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
# Set up the mock to return our chunks when iterated
mock_create.return_value = tool_call_chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Call the streaming method
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
}
],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
chunks = list(response_generator)
# Verify the chunks were returned correctly
assert len(chunks) == 4
assert chunks == tool_call_chunks
# Verify the capture was called with the right arguments
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
# Check that the tool calls were properly accumulated
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
# Verify the complete tool call was properly assembled
tool_call = tool_calls[0]
assert tool_call.id == "call_abc123"
assert tool_call.type == "function"
assert tool_call.function.name == "get_weather"
# Verify the arguments were concatenated correctly
arguments = tool_call.function.arguments
parsed_args = json.loads(arguments)
assert parsed_args == {"location": "San Francisco", "unit": "celsius"}
# Check that the content was also accumulated
assert props["$ai_output_choices"][0]["content"] == "The weather in San Francisco is 15°C."
# Check token usage
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
+7 -115
View File
@@ -5,10 +5,8 @@ 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
@@ -55,11 +53,6 @@ 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
@@ -107,6 +100,7 @@ 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")
@@ -134,6 +128,7 @@ 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")
@@ -161,6 +156,7 @@ 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")
@@ -188,6 +184,7 @@ 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")
@@ -215,6 +212,7 @@ 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:
@@ -251,8 +249,10 @@ 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()
@@ -384,25 +384,6 @@ 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"}}
@@ -1123,92 +1104,3 @@ 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
-81
View File
@@ -4640,84 +4640,3 @@ 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"))
+1 -32
View File
@@ -2,11 +2,10 @@ import json
import unittest
from datetime import date, datetime
import mock
import pytest
import requests
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
from posthog.test.test_utils import TEST_API_KEY
@@ -45,36 +44,6 @@ class TestRequests(unittest.TestCase):
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
)
def test_quota_limited_response(self):
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{
"quotaLimited": ["feature_flags"],
"featureFlags": {},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
).encode("utf-8")
with mock.patch("posthog.request._session.post", return_value=mock_response):
with self.assertRaises(QuotaLimitError) as cm:
decide("fake_key", "fake_host")
self.assertEqual(cm.exception.status, 200)
self.assertEqual(cm.exception.message, "Feature flags quota limited")
def test_normal_decide_response(self):
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
).encode("utf-8")
with mock.patch("posthog.request._session.post", return_value=mock_response):
response = decide("fake_key", "fake_host")
self.assertEqual(response["featureFlags"], {"flag1": True})
@pytest.mark.parametrize(
"host, expected",
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.19.1"
VERSION = "3.14.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
-2
View File
@@ -20,7 +20,6 @@ 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 = {
@@ -58,7 +57,6 @@ extras_require = {
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"pydantic",
"parameterized>=0.8.1",
],
"sentry": ["sentry-sdk", "django"],
"langchain": ["langchain>=0.2.0"],