Compare commits

...
30 Commits
Author SHA1 Message Date
Phil HaackandGitHub 6e00d573f3 Bump version to 6.7.0 (#317) 2025-08-26 22:32:04 +00:00
Phil HaackandGitHub a91a20876e fix(flags): flag dependency evaluation for multivariate flags (#316) 2025-08-25 14:20:45 -07:00
Vincent (Wen Yu) GeGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
10472e721d Add categories to doc specs (#313)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-21 11:48:54 -04:00
Juraj MajerikandGitHub fb38447869 chore: bump version to 6.6.1 (#314) 2025-08-21 16:14:27 +02:00
Juraj MajerikandGitHub ae97131107 Fix NoneType error when group_properties is None (#312) 2025-08-19 12:01:12 -07:00
Phil HaackandGitHub 675dea16a6 feat(flags): implement local evaluation of flag dependency filters (#311) 2025-08-19 09:43:46 -07:00
Phil HaackGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
6a3e7ef3ad chore: Improvements to example.py (#310)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-18 21:46:58 -07:00
Dylan MartinandGitHub 20b8825bd2 feat(flags): support passing in lists of flag keys to the /flags endpoint instead of evaluating every flag every time we fall back (#307) 2025-08-18 16:10:34 -07:00
David NewellandGitHub 818edc2811 feat: we should capture which properties were added as tags (#304) 2025-08-08 11:44:34 +01:00
Vincent (Wen Yu) GeandGitHub 05074351a3 Remove placeholder for params, waste space (#298) 2025-08-07 15:20:27 -04:00
Phil HaackandGitHub d25fae383c fix(flags): Pass project API key in remote_config requests (#303) 2025-08-06 21:17:30 +00:00
Radu RaiceaandGitHub 68e78c877d feat(llmo): support Vertex AI (#302)
* feat(llmo): support Vertex AI

* chore(llmo): run formatter

* fix(llmo): fix types error

* chore(llmo): run formatter

* chore(llmo): bump version
2025-08-05 15:33:10 -04:00
Radu RaiceaandGitHub 07cf32bb04 fix(llmo): tool calls are broken for most providers (#299)
* fix(llmo): set the $ai_tools properly for all providers

* fix(llmo): remove privacy mode from $ai_tools

* chore(llmo): bump version

* chore(llmo): run formatter

* fix(llmo): properly set tool calls in $ai_output_choices

* chore(llmo): bump version

* chore(llmo): run formatter

* fix(llmo): fix types error

* feat(llmo): change $ai_output_choices to have an array of content

* chore(llmo): run formatter

* feat(llmo): create text type object

* chore(llmo): update CHANGELOG.md
2025-08-05 14:02:37 -04:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
0076b66b75 feat: Expose get_feature_flag_result method in public API (#284)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-05 10:14:29 -07:00
Dylan MartinandGitHub 09dad8117f fix (#300) 2025-08-01 17:32:58 -07:00
Radu RaiceaandGitHub 09b9b5dc88 bug(llmo): fix anthropic tool call response (#297)
* bug(llmo): fix anthropic's tool call response

* bug(llmo): fix tool calls response handling for anthropic

* bug(llmo): run formatter

* bug(llmo): bump version

* bug(llmo): add date to changelog
2025-07-31 17:13:44 -04:00
Georgiy TarasovandGitHub 5a52af66a9 fix(ai): capture tool calls in reasoning models (#292)
* fix: capture tool calls in reasoning models

* fix: check for empty tool calls
2025-07-23 11:51:58 +02:00
Dylan MartinandGitHub 722c88701b feat(flags): make the sendFeatureFlags parameter more declarative and ergonomic (#283) 2025-07-22 15:20:54 -07:00
Radu RaiceaandGitHub 6ab2856f8d feat(llmo): Use default PH client for LangChain (#293)
* feat(llmo): Use default PH client for langchain

* chore: Run formatter

* feat: Test the CallbackHandler without any PH client

* chore: Run formatter
2025-07-22 14:09:47 -07:00
7a8b09123c feat(llmo): Make it optional to pass posthog client (#291)
Co-authored-by: Peter Kirkham <peter@posthog.com>
2025-07-22 06:53:47 +00:00
David NewellandGitHub da09639428 fix: capture django processed exceptions (#287) 2025-07-16 21:54:01 +02:00
Vincent (Wen Yu) GeandGitHub 6a271026d1 Init reference doc generation (#280) 2025-07-15 13:53:13 -04:00
Phil HaackandGitHub 6d9247960f fix: Ignore new flag filter type in local evaluation (#285) 2025-07-11 16:33:23 +00:00
Dylan MartinandGitHub c4e09cdd40 feat(flags): decouple local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller (#282) 2025-07-10 08:03:37 -07:00
Oliver BrowneandGitHub c61236b26a fix: add middleware setting for custom client (#281)
* Add middleware setting for custom client

* mypy

* comment
2025-07-09 17:06:20 +03:00
Dylan MartinandGitHub b965332698 feat(flags): add a flag_fallback_cache that tracks feature flag evaluation results and uses them as fallback values whenever the /flags API isn't available (#275) 2025-07-07 07:13:35 +00:00
Oliver BrowneandGitHub 4739945a82 fix: default send_feature_flags false for capture_exception (#278)
* default send_feature_flags false

* bump version
2025-07-02 22:21:25 +03:00
Oliver BrowneandGitHub 50ab10c858 fix(err): permit disabling person processing (#277)
* whoops

* bump version
2025-07-01 15:26:57 +03:00
Oliver BrowneGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>David Newell
37bd30194e feat: prep for 6.0.0, bunch of breaking changes (#273)
* alright

* fix exports

* Update posthog/test/test_before_send.py

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

* Update posthog/__init__.py

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

* Update posthog/__init__.py

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

* fix tests after rebase

* update examples

* update mypy baseline

* Revert "update mypy baseline"

This reverts commit da395dd7cc075d1f5b1c03d748544e4abcd75bb9.

* try again

* whatever

* request user

* fix middleware

* getattr is_authenticated

* allow using custom client for exception capture

* update comments

* fix circular import

* type arguments, use TypedDict

* fix setup

* mypy

* whoops

* ok

* further mypy

* mypy sync

* docs and fixes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Newell <david@posthog.com>
2025-06-27 14:57:02 +03:00
Lucas RicoyandGitHub b41dc8568e docs: update release details on readme (#272) 2025-06-21 00:38:18 +01:00
52 changed files with 8535 additions and 3535 deletions
+11
View File
@@ -0,0 +1,11 @@
# PostHog API Configuration
# Copy this file to .env and update with your actual values
# Your project API key (found on the /setup page in PostHog)
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here
# Your personal API key (for local evaluation and other advanced features)
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
# PostHog host URL (remove this line if using posthog.com)
POSTHOG_HOST=http://localhost:8000
+1
View File
@@ -18,3 +18,4 @@ posthog-analytics
pyrightconfig.json
.env
.DS_Store
posthog-python-references.json
+96
View File
@@ -1,3 +1,99 @@
# 6.7.0 - 2025-08-26
- feat: Add support for feature flag dependencies
# 6.6.1 - 2025-08-21
- fix: Prevent `NoneType` error when `group_properties` is `None`
# 6.6.0 - 2025-08-15
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
# 6.5.0 - 2025-08-08
- feat: Add `$context_tags` to an event to know which properties were included as tags
# 6.4.1 - 2025-08-06
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
# 6.4.0 - 2025-08-05
- feat: support Vertex AI for Gemini
# 6.3.4 - 2025-08-04
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
# 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
# 6.3.0 - 2025-07-22
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
# 6.2.1 - 2025-07-21
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
# 6.1.1 - 2025-07-16
- fix: correctly capture exceptions processed by Django from views or middleware
# 6.1.0 - 2025-07-10
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
# 6.0.4 - 2025-07-09
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
# 6.0.3 - 2025-07-07
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
# 6.0.2 - 2025-07-02
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
# 6.0.1
- fix: response `$process_person_profile` property when passed to capture
# 6.0.0
This release contains a number of major breaking changes:
- feat: make distinct_id an optional parameter in posthog.capture and related functions
- feat: make capture and related functions return `Optional[str]`, which is the UUID of the sent event, if it was sent
- fix: remove `identify` (prefer `posthog.set()`), and `page` and `screen` (prefer `posthog.capture()`)
- fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django `Integration`.
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
```python
# Old calling convention
posthog.capture("user123", "button_clicked", {"button_id": "123"})
# New calling convention
posthog.capture(distinct_id="user123", event="button_clicked", properties={"button_id": "123"})
# Better pattern
with posthog.new_context():
posthog.identify_context("user123")
# The event name is the first argument, and can be passed positionally, or as a keyword argument in a later position
posthog.capture("button_pressed")
```
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
# 5.4.0 - 2025-06-20
- feat: add support to session_id context on page method
+4 -2
View File
@@ -32,7 +32,7 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
```bash
uv python install 3.9.19
uv python pin 3.9.19
uv venv env
uv venv
source env/bin/activate
uv sync --extra dev --extra test
pre-commit install
@@ -45,7 +45,9 @@ Assuming you have a [local version of PostHog](https://posthog.com/docs/developi
### Releasing Versions
Updates are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version.
If you need to check the latest runs or manually trigger a release, you can go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
### Testing changes locally with the PostHog app
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
#/ Usage: bin/docs
#/ Description: Generate documentation for the PostHog Python SDK
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
exec python3 "$(dirname "$0")/docs_scripts/generate_json_schemas.py" "$@"
+41
View File
@@ -0,0 +1,41 @@
"""
Constants for PostHog Python SDK documentation generation.
"""
from typing import Dict, Union
# Documentation generation metadata
DOCUMENTATION_METADATA = {
"hogRef": "0.3",
"slugPrefix": "posthog-python",
"specUrl": "https://github.com/PostHog/posthog-python",
}
# Docstring parsing patterns for new format
DOCSTRING_PATTERNS = {
"examples_section": r"Examples:\s*\n(.*?)(?=\n\s*\n\s*Category:|\Z)",
"args_section": r"Args:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Details:|\n\s*\n\s*Category:|\Z)",
"details_section": r"Details:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Category:|\Z)",
"category_section": r"Category:\s*\n\s*(.+?)\s*(?:\n|$)",
"code_block": r"```(?:python)?\n(.*?)```",
"param_description": r"^\s*{param_name}:\s*(.+?)(?=\n\s*\w+:|\Z)",
"args_marker": r"\n\s*Args:\s*\n",
"examples_marker": r"\n\s*Examples:\s*\n",
"details_marker": r"\n\s*Details:\s*\n",
"category_marker": r"\n\s*Category:\s*\n",
}
# Output file configuration
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
"output_dir": ".",
"filename": "posthog-python-references.json",
"indent": 2,
}
# Documentation structure defaults
DOC_DEFAULTS = {
"showDocs": True,
"releaseTag": "public",
"return_type_void": "None",
"max_optional_params": 3,
}
+487
View File
@@ -0,0 +1,487 @@
#!/usr/bin/env python3
"""
Generate comprehensive SDK documentation JSON from PostHog Python SDK.
This script inspects the code and docstrings to create documentation in the specified format.
"""
import json
import inspect
import re
from dataclasses import is_dataclass, fields
from typing import get_origin, get_args, Union
from textwrap import dedent
from doc_constant import (
DOCUMENTATION_METADATA,
DOCSTRING_PATTERNS,
OUTPUT_CONFIG,
DOC_DEFAULTS,
)
import os
def extract_examples_from_docstring(docstring: str) -> list:
"""Extract code examples from docstring."""
if not docstring:
return []
examples = []
# Look for Examples section in the new format
examples_section_match = re.search(
DOCSTRING_PATTERNS["examples_section"], docstring, re.DOTALL
)
if examples_section_match:
examples_content = examples_section_match.group(1).strip()
# Extract code blocks from the Examples section
code_blocks = re.findall(
DOCSTRING_PATTERNS["code_block"], examples_content, re.DOTALL
)
for i, code_block in enumerate(code_blocks):
# Remove common leading whitespace while preserving relative indentation
code = dedent(code_block).strip()
# Extract name from first comment line if present
lines = code.split("\n")
name = f"Example {i + 1}" # Default fallback
if lines and lines[0].strip().startswith("#"):
# Extract name from first comment, keep the comment in the code
comment_text = lines[0].strip()[1:].strip()
if comment_text:
name = comment_text
examples.append({"id": f"example_{i + 1}", "name": name, "code": code})
return examples
def extract_details_from_docstring(docstring: str) -> str:
"""Extract details section from docstring."""
if not docstring:
return ""
# Look for Details section
details_match = re.search(
DOCSTRING_PATTERNS["details_section"], docstring, re.DOTALL
)
if details_match:
details_content = details_match.group(1).strip()
# Clean up formatting
return details_content.replace("\n", " ")
return ""
def parse_docstring_tags(docstring: str) -> dict:
"""Parse tags from docstring Category section."""
if not docstring:
return {}
tags = {}
# Extract Category section
category_match = re.search(DOCSTRING_PATTERNS["category_section"], docstring)
if category_match:
category_value = category_match.group(1).strip()
tags["category"] = category_value
return tags
def extract_description_from_docstring(docstring: str) -> str:
"""Extract main description from docstring."""
if not docstring:
return ""
# Clean up the docstring
cleaned = dedent(docstring).strip()
# Find the end of the description by looking for first section marker
# Check for Args:, Examples:, Details:, or Category: sections
section_patterns = [
DOCSTRING_PATTERNS["args_marker"],
DOCSTRING_PATTERNS["examples_marker"],
DOCSTRING_PATTERNS["details_marker"],
DOCSTRING_PATTERNS["category_marker"],
]
end_pos = len(cleaned)
for pattern in section_patterns:
match = re.search(pattern, cleaned)
if match:
end_pos = min(end_pos, match.start())
# Extract description up to the first section marker
description = cleaned[:end_pos].strip()
# Remove one level of \n since it will be rendered as markdown
# and \n will be padded in later steps
description = description.replace("\n", " ")
return description
def get_type_name(type_annotation) -> str:
"""Convert type annotation to string name."""
if type_annotation is None or type_annotation is type(None):
return "any"
# Handle typing constructs
origin = get_origin(type_annotation)
if origin is not None:
# Handle Union types (including Optional)
if origin is Union:
args = get_args(type_annotation)
if len(args) == 2 and type(None) in args:
# This is Optional[Type] - get the non-None type
non_none_type = next(arg for arg in args if arg is not type(None))
return f"Optional[{get_type_name(non_none_type)}]"
else:
# Regular Union - list all types
type_names = [get_type_name(arg) for arg in args]
return f"Union[{', '.join(type_names)}]"
# Handle other generic types (List, Dict, etc.)
origin_name = getattr(origin, "__name__", str(origin))
args = get_args(type_annotation)
if args:
arg_names = [get_type_name(arg) for arg in args]
return f"{origin_name}[{', '.join(arg_names)}]"
else:
return origin_name
# Handle regular types
elif hasattr(type_annotation, "__name__"):
return type_annotation.__name__
else:
return str(type_annotation)
def analyze_parameter(param: inspect.Parameter, docstring: str = "") -> dict:
"""Analyze a function parameter and return its documentation."""
# Determine if parameter is optional (has default value)
is_optional = param.default == inspect.Parameter.empty
# Get the type annotation
type_annotation = param.annotation
param_type = "any"
if type_annotation != inspect.Parameter.empty:
# Handle Union/Optional types first
origin = get_origin(type_annotation)
if origin is Union:
args = get_args(type_annotation)
if len(args) == 2 and type(None) in args:
# This is Optional[Type]
non_none_type = next(arg for arg in args if arg is not type(None))
param_type = get_type_name(non_none_type)
is_optional = True
else:
# Other Union types, use first type
param_type = get_type_name(args[0]) if args else "any"
else:
param_type = get_type_name(type_annotation)
elif param.default != inspect.Parameter.empty:
# No type annotation, but has default value - infer type from default
param_type = get_type_name(type(param.default))
# Extract parameter description from Args section
param_description = ""
if docstring:
# Look for Args section and extract description for this parameter
args_section_match = re.search(
DOCSTRING_PATTERNS["args_section"], docstring, re.DOTALL
)
if args_section_match:
args_content = args_section_match.group(1)
# Look for the parameter description
param_pattern = DOCSTRING_PATTERNS["param_description"].format(
param_name=re.escape(param.name)
)
param_match = re.search(
param_pattern, args_content, re.MULTILINE | re.DOTALL
)
if param_match:
param_description = param_match.group(1).strip().replace("\n", " ")
param_info = {
"name": param.name,
"description": param_description,
"isOptional": is_optional,
"type": param_type,
}
return param_info
def analyze_function(func, name: str) -> dict:
"""Analyze a function and return its documentation."""
try:
sig = inspect.signature(func)
docstring = inspect.getdoc(func) or ""
# Skip functions with empty docstrings
if not docstring.strip():
return {}
# Extract parameters (excluding 'self')
params = []
for param_name, param in sig.parameters.items():
if param_name != "self":
params.append(analyze_parameter(param, docstring))
# Special handling for constructor
display_name = name
if name == "__init__":
display_name = func.__qualname__.split(".")[0]
# Parse tags from docstring
tags = parse_docstring_tags(docstring)
category = tags.get("category", None)
# Extract description
description = extract_description_from_docstring(docstring)
# Skip if no meaningful description
if not description.strip():
return {}
# Extract details section (only if it exists)
details = extract_details_from_docstring(docstring)
# Get examples from docstring, do not generate fallback examples
examples = extract_examples_from_docstring(docstring)
# If no examples, do not include the examples key or set to empty list
result = {
"id": name,
"title": display_name,
"description": description,
"details": details,
"category": category,
"params": params,
"showDocs": DOC_DEFAULTS["showDocs"],
"releaseTag": DOC_DEFAULTS["releaseTag"],
"returnType": {
"id": "return_type",
"name": get_type_name(sig.return_annotation)
if sig.return_annotation != inspect.Signature.empty
else DOC_DEFAULTS["return_type_void"],
},
}
if examples:
result["examples"] = examples
return result
except Exception as e:
print(f"Error analyzing function {name}: {e}")
return {}
def analyze_class(cls) -> dict:
"""Analyze a class and return its documentation."""
class_doc = inspect.getdoc(cls) or f"Class: {cls.__name__}"
# Get all public methods and constructor
functions = []
for method_name in dir(cls):
if method_name.startswith("_") and method_name != "__init__":
continue
method = getattr(cls, method_name)
if callable(method):
func_info = analyze_function(method, method_name)
if func_info: # Only add if not None (empty docstring check)
functions.append(func_info)
return {
"id": cls.__name__,
"title": cls.__name__,
"description": extract_description_from_docstring(class_doc),
"functions": functions,
}
def analyze_type(cls) -> dict:
"""Analyze a type/dataclass and return its documentation."""
type_info = {
"id": cls.__name__,
"name": cls.__name__,
"path": f"{cls.__module__}.{cls.__name__}",
"properties": [],
"example": "",
}
if is_dataclass(cls):
# Handle dataclass
for field in fields(cls):
prop = {
"name": field.name,
"type": get_type_name(field.type),
"description": f"Field: {field.name}",
}
type_info["properties"].append(prop)
elif hasattr(cls, "__annotations__"):
# Handle TypedDict or annotated class
for field_name, field_type in cls.__annotations__.items():
prop = {
"name": field_name,
"type": get_type_name(field_type),
"description": f"Field: {field_name}",
}
type_info["properties"].append(prop)
return type_info
def generate_sdk_documentation():
"""Generate complete SDK documentation in the requested format."""
# Import PostHog components
import posthog
from posthog.client import Client
import posthog.types as types_module
import posthog.args as args_module
from posthog.version import VERSION
# Main SDK info
sdk_info = {
"version": VERSION,
"id": "posthog-python",
"title": "PostHog Python SDK",
"description": "Integrate PostHog into any python application.",
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
"specUrl": DOCUMENTATION_METADATA["specUrl"],
}
# Collect types
types_list = []
# Types from posthog.types
for name in dir(types_module):
obj = getattr(types_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
try:
type_info = analyze_type(obj)
types_list.append(type_info)
except Exception as e:
print(f"Error analyzing type {name}: {e}")
# Types from posthog.args
for name in dir(args_module):
obj = getattr(args_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
try:
type_info = analyze_type(obj)
types_list.append(type_info)
except Exception as e:
print(f"Error analyzing type {name}: {e}")
# Clean types of empty types
# Remove types that have no properties and no examples
# Remove types that have no properties and no examples
types_list = [
t for t in types_list if len(t["properties"]) > 0 or t["example"] != ""
]
# Collect classes
classes_list = []
# Main PostHog class (renamed from Client)
client_class = analyze_class(Client)
client_class["id"] = "PostHog"
client_class["title"] = "PostHog"
classes_list.append(client_class)
# Global module functions (functions callable as posthog.function_name)
global_functions = []
for func_name in dir(posthog):
# Skip private functions and non-callables
if func_name.startswith("_") or not callable(getattr(posthog, func_name)):
continue
func = getattr(posthog, func_name)
# Only include functions actually defined in the posthog module (not imported)
# and exclude class references
if (
func_name not in ["Client", "Posthog"]
and hasattr(func, "__module__")
and func.__module__ == "posthog"
):
try:
func_info = analyze_function(func, func_name)
if func_info: # Only add if not None (has proper docstring)
global_functions.append(func_info)
except Exception:
continue
# Add global functions as a "class"
if global_functions:
classes_list.append(
{
"id": "PostHogModule",
"title": "PostHog Module Functions",
"description": "Global functions available in the PostHog module",
"functions": global_functions,
}
)
# Collect categories from functions
categories = ["Initialization", "Identification", "Capture"]
seen_categories = set(categories)
for class_info in classes_list:
if "functions" in class_info:
for func in class_info["functions"]:
if (
"category" in func
and func["category"] not in seen_categories
and func["category"]
):
categories.append(func["category"])
seen_categories.add(func["category"])
# Create the final structure
result = {
"id": "posthog-python",
"hogRef": DOCUMENTATION_METADATA["hogRef"],
"info": sdk_info,
"types": types_list,
"classes": classes_list,
"categories": categories,
}
return result
if __name__ == "__main__":
print("Generating PostHog Python SDK documentation...")
try:
documentation = generate_sdk_documentation()
# Write to file
output_file = os.path.join(
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
)
with open(output_file, "w") as f:
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
print(f"✓ Generated {output_file}")
# Print summary
types_count = len(documentation["types"])
classes_count = len(documentation["classes"])
total_functions = sum(len(cls["functions"]) for cls in documentation["classes"])
print("📊 Documentation Summary:")
print(f"{types_count} types documented")
print(f"{classes_count} classes documented")
print(f"{total_functions} functions documented")
except Exception as e:
print(f"❌ Error generating documentation: {e}")
import traceback
traceback.print_exc()
+2 -4
View File
@@ -6,9 +6,7 @@ set_source_and_root_dir
ensure_virtual_env
if [[ "$1" == "--check" ]]; then
black --check .
isort --check-only .
ruff format --check .
else
black .
isort .
ruff format .
fi
+473 -142
View File
@@ -1,171 +1,502 @@
# PostHog Python library example
import argparse
#
# This script demonstrates various PostHog Python SDK capabilities including:
# - Basic event capture and user identification
# - Feature flag local evaluation
# - Feature flag payloads
# - Context management and tagging
#
# Setup:
# 1. Copy .env.example to .env and fill in your PostHog credentials
# 2. Run this script and choose from the interactive menu
import os
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
def load_env_file():
"""Load environment variables from .env file if it exists."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(env_path):
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
# You can find this key on the /setup page in PostHog
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = "http://localhost:8000"
posthog.poll_interval = 10
# Load .env file if it exists
load_env_file()
print(
posthog.feature_enabled(
args.flag, # Use the flag from command line arguments
"12345",
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
group_properties={
"organization": {
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
"created_at": "2022-06-30 11:44:52.984121+00:00",
}
},
# Get configuration
project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
# Check if credentials are provided
if not project_key or not personal_api_key:
print("❌ Missing PostHog credentials!")
print(
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
)
print(" or copy .env.example to .env and fill in your values")
exit(1)
# Test authentication before proceeding
print("🔑 Testing PostHog authentication...")
try:
# Configure PostHog with credentials
posthog.debug = False # Keep quiet during auth test
posthog.api_key = project_key
posthog.project_api_key = project_key
posthog.personal_api_key = personal_api_key
posthog.host = host
posthog.poll_interval = 10
# Test by attempting to get feature flags (this validates both keys)
# This will fail if credentials are invalid
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)
# If we get here without exception, credentials work
print("✅ Authentication successful!")
print(f" Project API Key: {project_key[:9]}...")
print(" Personal API Key: [REDACTED]")
print(f" Host: {host}\n\n")
except Exception as e:
print("❌ Authentication failed!")
print(f" Error: {e}")
print("\n Please check your credentials:")
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
print(
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
)
print(" - POSTHOG_HOST: Your PostHog instance URL")
exit(1)
# Display menu and get user choice
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
print("1. Identify and capture examples")
print("2. Feature flag local evaluation examples")
print("3. Feature flag payload examples")
print("4. Flag dependencies examples")
print("5. Context management and tagging examples")
print("6. Run all examples")
print("7. Exit")
choice = input("\nEnter your choice (1-7): ").strip()
if choice == "1":
print("\n" + "=" * 60)
print("IDENTIFY AND CAPTURE EXAMPLES")
print("=" * 60)
posthog.debug = True
# Capture an event
print("📊 Capturing events...")
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
# Alias a previous distinct id with a new one
print("🔗 Creating alias...")
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture(
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
posthog.capture(
"event-with-groups",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# Add properties to the person
print("👤 Identifying user...")
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Add properties to a group
print("🏢 Identifying group...")
posthog.group_identify("company", "id:5", {"employees": 11})
# Properties set only once to the person
print("🔒 Setting properties once...")
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
)
# This will not change the property (because it was already set)
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
)
print("🔄 Updating properties...")
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
)
elif choice == "2":
print("\n" + "=" * 60)
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
print("=" * 60)
posthog.debug = True
print("🏁 Testing basic feature flags...")
print(
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
)
print(
f"beta-feature for 'new_distinct_id': {posthog.feature_enabled('beta-feature', 'new_distinct_id')}"
)
print(
f"beta-feature with groups: {posthog.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
)
print("\n🌍 Testing location-based flags...")
# Assume test-flag has `City Name = Sydney` as a person property set
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
print(
f"Sydney user (local only): {posthog.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
print("\n📋 Getting all flags...")
print(f"All flags: {posthog.get_all_flags('distinct_id_random_22')}")
print(
f"All flags (local): {posthog.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
)
print(
f"All flags with properties: {posthog.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
elif choice == "3":
print("\n" + "=" * 60)
print("FEATURE FLAG PAYLOAD EXAMPLES")
print("=" * 60)
posthog.debug = True
print("📦 Testing feature flag payloads...")
print(
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
)
print(
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
)
print(
f"Remote config payload: {posthog.get_remote_config_payload('encrypted_payload_flag_key')}"
)
# Get feature flag result with all details (enabled, variant, payload, key, reason)
print("\n🔍 Getting detailed flag result...")
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
if result:
print(f"Flag key: {result.key}")
print(f"Flag enabled: {result.enabled}")
print(f"Variant: {result.variant}")
print(f"Payload: {result.payload}")
print(f"Reason: {result.reason}")
# get_value() returns the variant if it exists, otherwise the enabled value
print(f"Value (variant or enabled): {result.get_value()}")
elif choice == "4":
print("\n" + "=" * 60)
print("FLAG DEPENDENCIES EXAMPLES")
print("=" * 60)
print("🔗 Testing flag dependencies with local evaluation...")
print(
" Flag structure: 'test-flag-dependency' depends on 'beta-feature' being enabled"
)
print("")
print("📋 Required setup (if 'test-flag-dependency' doesn't exist):")
print(" 1. Create feature flag 'beta-feature':")
print(" - Condition: email contains '@example.com'")
print(" - Rollout: 100%")
print(" 2. Create feature flag 'test-flag-dependency':")
print(" - Condition: flag 'beta-feature' is enabled")
print(" - Rollout: 100%")
print("")
posthog.debug = True
# Test @example.com user (should satisfy dependency if flags exist)
result1 = posthog.feature_enabled(
"test-flag-dependency",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
)
print(f"✅ @example.com user (test-flag-dependency): {result1}")
# Capture an event
posthog.capture(
"distinct_id",
"event",
{"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
print(
posthog.feature_enabled(
"beta-feature-groups", "distinct_id", groups={"company": "id:5"}
)
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
# get payload
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
print(posthog.get_all_flags_and_payloads("distinct_id"))
exit()
# # Alias a previous distinct id with a new one
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture(
"new_distinct_id", "event2", {"property1": "value", "property2": "value"}
)
posthog.capture(
"new_distinct_id",
"event-with-groups",
{"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# # Add properties to the person
posthog.identify("new_distinct_id", {"email": "something@something.com"})
# Add properties to a group
posthog.group_identify("company", "id:5", {"employees": 11})
# properties set only once to the person
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
posthog.set_once(
"new_distinct_id", {"self_serve_signup": False}
) # this will not change the property (because it was already set)
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
# #############################################################################
# Make sure you have a personal API key for the examples below
# Local Evaluation
# If flag has City=Sydney, this call doesn't go to `/decide`
print(
posthog.feature_enabled(
"test-flag",
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
)
)
print(
posthog.feature_enabled(
"test-flag",
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
# Test non-example.com user (dependency should not be satisfied)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"regular_user",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
)
print(f"❌ Regular user (test-flag-dependency): {result2}")
print(posthog.get_all_flags("distinct_id_random_22"))
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
print(
posthog.get_all_flags(
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
# Test beta-feature directly for comparison
beta1 = posthog.feature_enabled(
"beta-feature",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
)
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
beta2 = posthog.feature_enabled(
"beta-feature",
"regular_user",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"📊 Beta feature comparison - @example.com: {beta1}, regular: {beta2}")
print("\n🎯 Results Summary:")
print(
f" - Flag dependencies evaluated locally: {'✅ YES' if result1 != result2 else '❌ NO'}"
)
print(" - Zero API calls needed: ✅ YES (all evaluated locally)")
print(" - Python SDK supports flag dependencies: ✅ YES")
# You can add tags to a context, and these are automatically added to any events (including exceptions) captured
# within that context.
print("\n" + "-" * 60)
print("PRODUCTION-STYLE MULTIVARIATE DEPENDENCY CHAIN")
print("-" * 60)
print("🔗 Testing complex multivariate flag dependencies...")
print(
" Structure: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"
)
print("")
print("📋 Required setup (if flags don't exist):")
print(
" 1. Create 'multivariate-leaf-flag' with fruit variants (pineapple, mango, papaya, kiwi)"
)
print(" - pineapple: email = 'pineapple@example.com'")
print(" - mango: email = 'mango@example.com'")
print(
" 2. Create 'multivariate-intermediate-flag' with color variants (blue, red)"
)
print(" - blue: depends on multivariate-leaf-flag = 'pineapple'")
print(" - red: depends on multivariate-leaf-flag = 'mango'")
print(
" 3. Create 'multivariate-root-flag' with show variants (breaking-bad, the-wire)"
)
print(" - breaking-bad: depends on multivariate-intermediate-flag = 'blue'")
print(" - the-wire: depends on multivariate-intermediate-flag = 'red'")
print("")
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
# the new context inherits tags from the parent context.
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# Test pineapple -> blue -> breaking-bad chain
dependent_result3 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "pineapple@example.com"},
only_evaluate_locally=True,
)
if str(dependent_result3) != "breaking-bad":
print(
f" ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '{dependent_result3}'"
)
else:
print("'multivariate-root-flag' with email pineapple@example.com succeeded")
# This event will be captured with the tags set above
posthog.capture("order_processed")
# This exception will be captured with the tags set above
raise Exception("Order processing failed")
# Test mango -> red -> the-wire chain
dependent_result4 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "mango@example.com"},
only_evaluate_locally=True,
)
if str(dependent_result4) != "the-wire":
print(
f" ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '{dependent_result4}'"
)
else:
print("'multivariate-root-flag' with email mango@example.com succeeded")
# Show the complete chain evaluation
print("\n🔍 Complete dependency chain evaluation:")
for email, expected_chain in [
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
("mango@example.com", ["mango", "red", "the-wire"]),
]:
leaf = posthog.get_feature_flag(
"multivariate-leaf-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
intermediate = posthog.get_feature_flag(
"multivariate-intermediate-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
root = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
# Use fresh=True to start with a clean context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
raise Exception("Session handling failed")
actual_chain = [str(leaf), str(intermediate), str(root)]
chain_success = actual_chain == expected_chain
print(f" 📧 {email}:")
print(f" Expected: {' -> '.join(map(str, expected_chain))}")
print(f" Actual: {' -> '.join(map(str, actual_chain))}")
print(f" Status: {'✅ SUCCESS' if chain_success else '❌ FAILED'}")
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@posthog.scoped()
def process_order(order_id):
posthog.tag("order_id", order_id)
# Exception will be captured and tagged automatically
raise Exception("Order processing failed")
print("\n🎯 Multivariate Chain Summary:")
print(" - Complex dependency chains: ✅ SUPPORTED")
print(" - Multivariate flag dependencies: ✅ SUPPORTED")
print(" - Local evaluation of chains: ✅ WORKING")
elif choice == "5":
print("\n" + "=" * 60)
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
print("=" * 60)
# Use fresh=True to start with a clean context (no inherited tags)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
# Only payment_id tag will be present, no inherited tags
raise Exception("Payment processing failed")
posthog.debug = True
print("🏷️ Testing context management...")
print(
"You can add tags to a context, and these are automatically added to any events captured within that context."
)
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
# the new context inherits tags from the parent context.
try:
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# This event will be captured with the tags set above
posthog.capture("order_processed")
print("✅ Event captured with inherited context tags")
# This exception will be captured with the tags set above
# raise Exception("Order processing failed")
except Exception as e:
print(f"Exception captured: {e}")
# Use fresh=True to start with a clean context (no inherited tags)
try:
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
posthog.capture("session_event")
print("✅ Event captured with fresh context tags")
# raise Exception("Session handling failed")
except Exception as e:
print(f"Exception captured: {e}")
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@posthog.scoped()
def process_order(order_id):
posthog.tag("order_id", order_id)
posthog.capture("order_step_completed")
print(f"✅ Order {order_id} processed with scoped context")
# Exception will be captured and tagged automatically
# raise Exception("Order processing failed")
# Use fresh=True to start with a clean context (no inherited tags)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
posthog.capture("payment_processed")
print(f"✅ Payment {payment_id} processed with fresh scoped context")
# Only payment_id tag will be present, no inherited tags
# raise Exception("Payment processing failed")
process_order("12345")
process_payment("67890")
elif choice == "6":
print("\n🔄 Running all examples...")
# Run example 1
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
posthog.debug = True
print("📊 Capturing events...")
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print("🔗 Creating alias...")
posthog.alias("distinct_id", "new_distinct_id")
print("👤 Identifying user...")
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Run example 2
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 3
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
print("📦 Testing payloads...")
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
# Run example 4
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"✅ @example.com user: {result1}, regular user: {result2}")
# Run example 5
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
print("🏷️ Testing context management...")
with posthog.new_context():
posthog.tag("demo_run", "all_examples")
posthog.capture("demo_completed")
print("✅ Demo completed with context tags")
elif choice == "7":
print("👋 Goodbye!")
posthog.shutdown()
exit()
else:
print("❌ Invalid choice. Please run again and select 1-7.")
posthog.shutdown()
exit()
print("\n" + "=" * 60)
print("✅ Example completed!")
print("=" * 60)
posthog.shutdown()
+16 -8
View File
@@ -22,16 +22,24 @@ posthog/client.py:0: error: Library stubs not installed for "six" [import-untyp
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/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.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: 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/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef]
posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef]
+471 -340
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -17,6 +17,7 @@ from posthog.ai.utils import (
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
from posthog import setup
class Anthropic(anthropic.Anthropic):
@@ -26,14 +27,14 @@ class Anthropic(anthropic.Anthropic):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
+3 -2
View File
@@ -10,6 +10,7 @@ import time
import uuid
from typing import Any, Dict, Optional
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
get_model_params,
@@ -26,14 +27,14 @@ class AsyncAnthropic(anthropic.AsyncAnthropic):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
+11 -8
View File
@@ -5,9 +5,12 @@ except ImportError:
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
)
from typing import Optional
from posthog.ai.anthropic.anthropic import WrappedMessages
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
from posthog.client import Client as PostHogClient
from posthog import setup
class AnthropicBedrock(anthropic.AnthropicBedrock):
@@ -17,9 +20,9 @@ class AnthropicBedrock(anthropic.AnthropicBedrock):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
@@ -30,9 +33,9 @@ class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
@@ -43,9 +46,9 @@ class AnthropicVertex(anthropic.AnthropicVertex):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
@@ -56,7 +59,7 @@ class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
+74 -15
View File
@@ -10,6 +10,7 @@ except ImportError:
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
)
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
@@ -36,9 +37,17 @@ class Client:
)
"""
_ph_client: PostHogClient
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
@@ -48,7 +57,13 @@ class Client:
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
@@ -56,12 +71,20 @@ class Client:
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
if posthog_client is None:
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
self.models = Models(
api_key=api_key,
posthog_client=posthog_client,
vertexai=vertexai,
credentials=credentials,
project=project,
location=location,
debug_config=debug_config,
http_options=http_options,
posthog_client=self._ph_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
@@ -80,6 +103,12 @@ class Models:
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
@@ -89,7 +118,13 @@ class Models:
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
@@ -97,10 +132,10 @@ class Models:
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
if posthog_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
self._ph_client = posthog_client or setup()
self._ph_client = posthog_client
if self._ph_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
# Store default PostHog settings
self._default_distinct_id = posthog_distinct_id
@@ -108,16 +143,40 @@ class Models:
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Handle API key - try parameter first, then environment variables
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
# Build genai.Client arguments
client_args: Dict[str, Any] = {}
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
# Add Vertex AI parameters if provided
if vertexai is not None:
client_args["vertexai"] = vertexai
if credentials is not None:
client_args["credentials"] = credentials
if project is not None:
client_args["project"] = project
if location is not None:
client_args["location"] = location
if debug_config is not None:
client_args["debug_config"] = debug_config
if http_options is not None:
client_args["http_options"] = http_options
self._client = genai.Client(api_key=api_key)
# Handle API key authentication
if vertexai:
# For Vertex AI, api_key is optional
if api_key is not None:
client_args["api_key"] = api_key
else:
# For non-Vertex AI mode, api_key is required (backwards compatibility)
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
client_args["api_key"] = api_key
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_posthog_params(
+47 -22
View File
@@ -5,6 +5,7 @@ except ImportError:
"Please install LangChain to use this feature: 'pip install langchain'"
)
import json
import logging
import time
from dataclasses import dataclass
@@ -29,11 +30,12 @@ from langchain_core.messages import (
HumanMessage,
SystemMessage,
ToolMessage,
ToolCall,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from posthog import default_client
from posthog import setup
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.client import Client
@@ -81,10 +83,10 @@ class CallbackHandler(BaseCallbackHandler):
The PostHog LLM observability callback handler for LangChain.
"""
_client: Client
_ph_client: Client
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, float, UUID]]
_distinct_id: Optional[Union[str, int, UUID]]
"""Distinct ID of the user to associate the trace with."""
_trace_id: Optional[Union[str, int, float, UUID]]
@@ -112,7 +114,7 @@ class CallbackHandler(BaseCallbackHandler):
self,
client: Optional[Client] = None,
*,
distinct_id: Optional[Union[str, int, float, UUID]] = None,
distinct_id: Optional[Union[str, int, UUID]] = None,
trace_id: Optional[Union[str, int, float, UUID]] = None,
properties: Optional[Dict[str, Any]] = None,
privacy_mode: bool = False,
@@ -127,10 +129,7 @@ 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.
"""
posthog_client = client or default_client
if posthog_client is None:
raise ValueError("PostHog client is required")
self._client = posthog_client
self._ph_client = client or setup()
self._distinct_id = distinct_id
self._trace_id = trace_id
self._properties = properties or {}
@@ -481,7 +480,7 @@ class CallbackHandler(BaseCallbackHandler):
event_properties = {
"$ai_trace_id": trace_id,
"$ai_input_state": with_privacy_mode(
self._client, self._privacy_mode, run.input
self._ph_client, self._privacy_mode, run.input
),
"$ai_latency": run.latency,
"$ai_span_name": run.name,
@@ -497,13 +496,13 @@ class CallbackHandler(BaseCallbackHandler):
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
self._ph_client, self._privacy_mode, outputs
)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
self._ph_client.capture(
distinct_id=self._distinct_id or run_id,
event=event_name,
properties=event_properties,
@@ -550,17 +549,16 @@ class CallbackHandler(BaseCallbackHandler):
"$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_input": with_privacy_mode(
self._ph_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"] = with_privacy_mode(
self._client,
self._privacy_mode,
run.tools,
)
event_properties["$ai_tools"] = run.tools
if isinstance(output, BaseException):
event_properties["$ai_http_status"] = _get_http_status(output)
@@ -586,10 +584,11 @@ class CallbackHandler(BaseCallbackHandler):
]
else:
completions = [
_extract_raw_esponse(generation) for generation in generation_result
_extract_raw_response(generation)
for generation in generation_result
]
event_properties["$ai_output_choices"] = with_privacy_mode(
self._client, self._privacy_mode, completions
self._ph_client, self._privacy_mode, completions
)
if self._properties:
@@ -598,7 +597,7 @@ class CallbackHandler(BaseCallbackHandler):
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
self._ph_client.capture(
distinct_id=self._distinct_id or trace_id,
event="$ai_generation",
properties=event_properties,
@@ -617,7 +616,7 @@ class CallbackHandler(BaseCallbackHandler):
)
def _extract_raw_esponse(last_response):
def _extract_raw_response(last_response):
"""Extract the response from the last response of the LLM call."""
# We return the text of the response if not empty
if last_response.text is not None and last_response.text.strip() != "":
@@ -630,12 +629,35 @@ def _extract_raw_esponse(last_response):
return ""
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
def _convert_lc_tool_calls_to_oai(
tool_calls: list[ToolCall],
) -> list[dict[str, Any]]:
try:
return [
{
"type": "function",
"id": tool_call["id"],
"function": {
"name": tool_call["name"],
"arguments": json.dumps(tool_call["args"]),
},
}
for tool_call in tool_calls
]
except KeyError:
return tool_calls
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
# assistant message
if isinstance(message, HumanMessage):
message_dict = {"role": "user", "content": message.content}
elif isinstance(message, AIMessage):
message_dict = {"role": "assistant", "content": message.content}
if message.tool_calls:
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
message.tool_calls
)
elif isinstance(message, SystemMessage):
message_dict = {"role": "system", "content": message.content}
elif isinstance(message, ToolMessage):
@@ -648,6 +670,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
if message.additional_kwargs:
message_dict.update(message.additional_kwargs)
if "content" in message_dict and not message_dict["content"]:
message_dict["content"] = ""
return message_dict
+13 -40
View File
@@ -11,10 +11,12 @@ except ImportError:
from posthog.ai.utils import (
call_llm_and_track_usage,
extract_available_tool_calls,
get_model_params,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
from posthog import setup
class OpenAI(openai.OpenAI):
@@ -24,16 +26,15 @@ class OpenAI(openai.OpenAI):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: OpenAI API key.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -167,6 +168,7 @@ class WrappedResponses:
usage_stats,
latency,
output,
extract_available_tool_calls("openai", kwargs),
)
return generator()
@@ -182,7 +184,7 @@ class WrappedResponses:
usage_stats: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -212,12 +214,8 @@ class WrappedResponses:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -341,7 +339,6 @@ class WrappedCompletions:
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
@@ -350,7 +347,6 @@ class WrappedCompletions:
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
try:
for chunk in response:
@@ -389,31 +385,12 @@ class WrappedCompletions:
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
)
yield chunk
finally:
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,
@@ -424,7 +401,7 @@ class WrappedCompletions:
usage_stats,
latency,
output,
tools,
extract_available_tool_calls("openai", kwargs),
)
return generator()
@@ -440,7 +417,7 @@ class WrappedCompletions:
usage_stats: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -470,12 +447,8 @@ class WrappedCompletions:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
+12 -38
View File
@@ -9,8 +9,10 @@ except ImportError:
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
)
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
extract_available_tool_calls,
get_model_params,
with_privacy_mode,
)
@@ -24,7 +26,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: OpenAI API key.
@@ -33,7 +35,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -167,6 +169,7 @@ class WrappedResponses:
usage_stats,
latency,
output,
extract_available_tool_calls("openai", kwargs),
)
return async_generator()
@@ -182,7 +185,7 @@ class WrappedResponses:
usage_stats: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -212,12 +215,8 @@ class WrappedResponses:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -343,7 +342,6 @@ class WrappedCompletions:
start_time = time.time()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
@@ -353,7 +351,6 @@ class WrappedCompletions:
async def async_generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
try:
async for chunk in response:
@@ -392,31 +389,12 @@ class WrappedCompletions:
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
)
yield chunk
finally:
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,
@@ -427,7 +405,7 @@ class WrappedCompletions:
usage_stats,
latency,
output,
tools,
extract_available_tool_calls("openai", kwargs),
)
return async_generator()
@@ -443,7 +421,7 @@ class WrappedCompletions:
usage_stats: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -473,12 +451,8 @@ class WrappedCompletions:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
+7 -4
View File
@@ -15,7 +15,10 @@ from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
from typing import Optional
from posthog.client import Client as PostHogClient
from posthog import setup
class AzureOpenAI(openai.AzureOpenAI):
@@ -25,7 +28,7 @@ class AzureOpenAI(openai.AzureOpenAI):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
@@ -34,7 +37,7 @@ class AzureOpenAI(openai.AzureOpenAI):
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -63,7 +66,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
@@ -72,7 +75,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
+143 -81
View File
@@ -117,34 +117,87 @@ def format_response(response, provider: str):
def format_response_anthropic(response):
output = []
content = []
for choice in response.content:
if choice.text:
output.append(
{
"role": "assistant",
"content": choice.text,
}
)
if (
hasattr(choice, "type")
and choice.type == "text"
and hasattr(choice, "text")
and choice.text
):
content.append({"type": "text", "text": choice.text})
elif (
hasattr(choice, "type")
and choice.type == "tool_use"
and hasattr(choice, "name")
and hasattr(choice, "id")
):
tool_call = {
"type": "function",
"id": choice.id,
"function": {
"name": choice.name,
"arguments": getattr(choice, "input", {}),
},
}
content.append(tool_call)
if content:
message = {
"role": "assistant",
"content": content,
}
output.append(message)
return output
def format_response_openai(response):
output = []
if hasattr(response, "choices"):
content = []
role = "assistant"
for choice in response.choices:
# Handle Chat Completions response format
if hasattr(choice, "message") and choice.message and choice.message.content:
output.append(
{
"content": choice.message.content,
"role": choice.message.role,
}
)
if hasattr(choice, "message") and choice.message:
if choice.message.role:
role = choice.message.role
if choice.message.content:
content.append({"type": "text", "text": choice.message.content})
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tool_call in choice.message.tool_calls:
content.append(
{
"type": "function",
"id": tool_call.id,
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
)
if content:
message = {
"role": role,
"content": content,
}
output.append(message)
# Handle Responses API format
if hasattr(response, "output"):
content = []
role = "assistant"
for item in response.output:
if item.type == "message":
# Extract text content from the content list
role = item.role
if hasattr(item, "content") and isinstance(item.content, list):
for content_item in item.content:
if (
@@ -152,99 +205,110 @@ def format_response_openai(response):
and content_item.type == "output_text"
and hasattr(content_item, "text")
):
output.append(
{
"content": content_item.text,
"role": item.role,
}
)
content.append({"type": "text", "text": content_item.text})
elif hasattr(content_item, "text"):
output.append(
{
"content": content_item.text,
"role": item.role,
}
)
content.append({"type": "text", "text": content_item.text})
elif (
hasattr(content_item, "type")
and content_item.type == "input_image"
and hasattr(content_item, "image_url")
):
output.append(
content.append(
{
"content": {
"type": "image",
"image": content_item.image_url,
},
"role": item.role,
"type": "image",
"image": content_item.image_url,
}
)
else:
output.append(
{
"content": item.content,
"role": item.role,
}
)
elif hasattr(item, "content"):
content.append({"type": "text", "text": str(item.content)})
elif hasattr(item, "type") and item.type == "function_call":
content.append(
{
"type": "function",
"id": getattr(item, "call_id", getattr(item, "id", "")),
"function": {
"name": item.name,
"arguments": getattr(item, "arguments", {}),
},
}
)
if content:
message = {
"role": role,
"content": content,
}
output.append(message)
return output
def format_response_gemini(response):
output = []
if hasattr(response, "candidates") and response.candidates:
for candidate in response.candidates:
if hasattr(candidate, "content") and candidate.content:
content_text = ""
content = []
if hasattr(candidate.content, "parts") and candidate.content.parts:
for part in candidate.content.parts:
if hasattr(part, "text") and part.text:
content_text += part.text
if content_text:
output.append(
{
"role": "assistant",
"content": content_text,
}
)
content.append({"type": "text", "text": part.text})
elif hasattr(part, "function_call") and part.function_call:
function_call = part.function_call
content.append(
{
"type": "function",
"function": {
"name": function_call.name,
"arguments": function_call.args,
},
}
)
if content:
message = {
"role": "assistant",
"content": content,
}
output.append(message)
elif hasattr(candidate, "text") and candidate.text:
output.append(
{
"role": "assistant",
"content": candidate.text,
"content": [{"type": "text", "text": candidate.text}],
}
)
elif hasattr(response, "text") and response.text:
output.append(
{
"role": "assistant",
"content": response.text,
"content": [{"type": "text", "text": response.text}],
}
)
return output
def format_tool_calls(response, provider: str):
def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
return response.tools
elif provider == "openai":
# Handle both Chat Completions and Responses API
if hasattr(response, "choices") and response.choices:
# Check for tool_calls in message (Chat Completions format)
if (
hasattr(response.choices[0], "message")
and hasattr(response.choices[0].message, "tool_calls")
and response.choices[0].message.tool_calls
):
return response.choices[0].message.tool_calls
if "tools" in kwargs:
return kwargs["tools"]
# Check for tool_calls directly in response (Responses API format)
if (
hasattr(response.choices[0], "tool_calls")
and response.choices[0].tool_calls
):
return response.choices[0].tool_calls
return None
return None
elif provider == "gemini":
if "config" in kwargs and hasattr(kwargs["config"], "tools"):
return kwargs["config"].tools
return None
elif provider == "openai":
if "tools" in kwargs:
return kwargs["tools"]
return None
def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
@@ -377,11 +441,10 @@ 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
)
available_tool_calls = extract_available_tool_calls(provider, kwargs)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if (
usage.get("cache_read_input_tokens") is not None
@@ -492,11 +555,10 @@ 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
)
available_tool_calls = extract_available_tool_calls(provider, kwargs)
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if (
usage.get("cache_read_input_tokens") is not None
+71
View File
@@ -0,0 +1,71 @@
from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type
from types import TracebackType
from typing_extensions import NotRequired # For Python < 3.11 compatibility
from datetime import datetime
import numbers
from uuid import UUID
from posthog.types import SendFeatureFlagsOptions
ID_TYPES = Union[numbers.Number, str, UUID, int]
class OptionalCaptureArgs(TypedDict):
"""Optional arguments for the capture method.
Args:
distinct_id: Unique identifier for the person associated with this event. If not set, the context
distinct_id is used, if available, otherwise a UUID is generated, and the event is marked
as personless. Setting context-level distinct_id's is recommended.
properties: Dictionary of properties to track with the event
timestamp: When the event occurred (defaults to current time)
uuid: Unique identifier for this specific event. If not provided, one is generated. The event
UUID is returned, so you can correlate it with actions in your app (like showing users an
error ID if you capture an exception).
groups: Group identifiers to associate with this event (format: {group_type: group_key})
send_feature_flags: Whether to include currently active feature flags in the event properties.
Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration.
Defaults to False.
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
"""
distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[str]]
groups: NotRequired[Optional[Dict[str, str]]]
send_feature_flags: NotRequired[
Optional[Union[bool, SendFeatureFlagsOptions]]
] # Updated to support both boolean and options object
disable_geoip: NotRequired[
Optional[bool]
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
class OptionalSetArgs(TypedDict):
"""Optional arguments for the set method.
Args:
distinct_id: Unique identifier for the user to set properties on. If not set, the context
distinct_id is used, if available, otherwise this function does nothing. Setting
context-level distinct_id's is recommended.
properties: Dictionary of properties to set on the person
timestamp: When the properties were set (defaults to current time)
uuid: Unique identifier for this operation. If not provided, one is generated. This
UUID is returned, so you can correlate it with actions in your app.
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
"""
distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[str]]
disable_geoip: NotRequired[Optional[bool]]
ExcInfo = Union[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
Tuple[None, None, None],
]
ExceptionArg = Union[BaseException, ExcInfo]
+921 -379
View File
File diff suppressed because it is too large Load Diff
+59 -20
View File
@@ -1,6 +1,10 @@
import contextvars
from contextlib import contextmanager
from typing import Optional, Any, Callable, Dict, TypeVar, cast
from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
if TYPE_CHECKING:
# To avoid circular imports
from posthog.client import Client
class ContextScope:
@@ -9,7 +13,9 @@ class ContextScope:
parent=None,
fresh: bool = False,
capture_exceptions: bool = True,
client: Optional["Client"] = None,
):
self.client: Optional[Client] = client
self.parent = parent
self.fresh = fresh
self.capture_exceptions = capture_exceptions
@@ -64,7 +70,11 @@ def _get_current_context() -> Optional[ContextScope]:
@contextmanager
def new_context(fresh=False, capture_exceptions=True):
def new_context(
fresh: bool = False,
capture_exceptions: bool = True,
client: Optional["Client"] = None,
):
"""
Create a new context scope that will be active for the duration of the with block.
Any tags set within this scope will be isolated to this context. Any exceptions raised
@@ -77,34 +87,49 @@ def new_context(fresh=False, capture_exceptions=True):
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
If True, captures exceptions and tags them with the context tags before propagating them.
If False, exceptions will propagate without being tagged or captured.
client: Optional client instance to use for capturing exceptions (default: None).
If provided, the client will be used to capture exceptions within the context.
If not provided, the default (global) client will be used. Note that the passed
client is only used to capture exceptions within the context - other events captured
within the context via `Client.capture` or `posthog.capture` will still carry the context
state (tags, identity, session id), but will be captured by the client directly used (or
the global one, in the case of `posthog.capture`)
Examples:
```python
# Inherit parent context tags
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
```
```python
# Start with fresh context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
```
Category:
Contexts
"""
from posthog import capture_exception
current_context = _get_current_context()
new_context = ContextScope(current_context, fresh, capture_exceptions)
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
_context_stack.set(new_context)
try:
yield
except Exception as e:
if new_context.capture_exceptions:
capture_exception(e)
if new_context.client:
new_context.client.capture_exception(e)
else:
capture_exception(e)
raise
finally:
_context_stack.set(new_context.get_parent())
@@ -112,22 +137,26 @@ def new_context(fresh=False, capture_exceptions=True):
def tag(key: str, value: Any) -> None:
"""
Add a tag to the current context.
Add a tag to the current context. All tags are added as properties to any event, including exceptions, captured
within the context.
Args:
key: The tag key
value: The tag value
Example:
```python
posthog.tag("user_id", "123")
```
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
current_context.add_tag(key, value)
# NOTE: we should probably also remove this - there's no reason for the user to ever
# need to manually interact with the current tag set
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context. Note, modifying
@@ -135,6 +164,9 @@ def get_tags() -> Dict[str, Any]:
Returns:
Dict of all tags in the current context
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
@@ -142,25 +174,20 @@ def get_tags() -> Dict[str, Any]:
return {}
# NOTE: We should probably remove this function - the way to clear scope context
# is by entering a new, fresh context, rather than by clearing the tags or other
# scope data directly.
def clear_tags() -> None:
"""Clear all tags in the current context. Does not clear parent tags"""
current_context = _get_current_context()
if current_context:
current_context.tags.clear()
def identify_context(distinct_id: str) -> None:
"""
Identify the current context with a distinct ID, associating all events captured in this or
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
fresh context will clear the context-level distinct ID.
fresh context will clear the context-level distinct ID. The distinct-id passed should be uniquely associated
with one of your users. Events captured outside of a context, or in a context with no associated distinct
ID, will be assigned a random UUID, and captured as "personless".
Args:
distinct_id: The distinct ID to associate with the current context and its children.
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
@@ -175,6 +202,9 @@ def set_context_session(session_id: str) -> None:
Args:
session_id: The session ID to associate with the current context and its children. See https://posthog.com/docs/data/sessions
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
@@ -187,6 +217,9 @@ def get_context_session_id() -> Optional[str]:
Returns:
The session ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
@@ -200,6 +233,9 @@ def get_context_distinct_id() -> Optional[str]:
Returns:
The distinct ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
@@ -210,7 +246,7 @@ def get_context_distinct_id() -> Optional[str]:
F = TypeVar("F", bound=Callable[..., Any])
def scoped(fresh=False, capture_exceptions=True):
def scoped(fresh: bool = False, capture_exceptions: bool = True):
"""
Decorator that creates a new context for the function. Simply wraps
the function in a with posthog.new_context(): block.
@@ -230,6 +266,9 @@ def scoped(fresh=False, capture_exceptions=True):
# If this raises an exception, it will be captured with tags
# and then re-raised
some_risky_function()
Category:
Contexts
"""
def decorator(func: F) -> F:
+3 -25
View File
@@ -6,47 +6,25 @@
import logging
import sys
import threading
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from posthog.client import Client
class Integrations(str, Enum):
Django = "django"
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
log = logging.getLogger("posthog")
def __init__(
self, client: "Client", integrations: Optional[List[Integrations]] = None
):
def __init__(self, client: "Client"):
self.client = client
self.original_excepthook = sys.excepthook
sys.excepthook = self.exception_handler
threading.excepthook = self.thread_exception_handler
self.enabled_integrations = []
for integration in integrations or []:
# TODO: Maybe find a better way of enabling integrations
# This is very annoying currently if we had to add any configuration per integration
if integration == Integrations.Django:
try:
from posthog.exception_integrations.django import DjangoIntegration
enabled_integration = DjangoIntegration(self.exception_receiver)
self.enabled_integrations.append(enabled_integration)
except Exception as e:
self.log.exception(f"Failed to enable Django integration: {e}")
def close(self):
sys.excepthook = self.original_excepthook
for integration in self.enabled_integrations:
integration.uninstall()
def exception_handler(self, exc_type, exc_value, exc_traceback):
# don't affect default behaviour.
@@ -66,6 +44,6 @@ class ExceptionCapture:
def capture_exception(self, exception, metadata=None):
try:
distinct_id = metadata.get("distinct_id") if metadata else None
self.client.capture_exception(exception, distinct_id)
self.client.capture_exception(exception, distinct_id=distinct_id)
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
@@ -1,5 +0,0 @@
class IntegrationEnablingError(Exception):
"""
The integration could not be enabled due to a user error like
`django` not being installed for the `DjangoIntegration`.
"""
-117
View File
@@ -1,117 +0,0 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import re
import sys
from typing import TYPE_CHECKING
from posthog.exception_integrations import IntegrationEnablingError
try:
from django import VERSION as DJANGO_VERSION
from django.core import signals
except ImportError:
raise IntegrationEnablingError("Django not installed")
if TYPE_CHECKING:
from typing import Any, Dict # noqa: F401
from django.core.handlers.wsgi import WSGIRequest # noqa: F401
class DjangoIntegration:
# TODO: Abstract integrations one we have more and can see patterns
"""
Autocapture errors from a Django application.
"""
identifier = "django"
def __init__(self, capture_exception_fn=None):
if DJANGO_VERSION < (4, 2):
raise IntegrationEnablingError("Django 4.2 or newer is required.")
# TODO: Right now this seems too complicated / overkill for us, but seems like we can automatically plug in middlewares
# which is great for users (they don't need to do this) and everything should just work.
# We should consider this in the future, but for now we can just use the middleware and signals handlers.
# See: https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/integrations/django/__init__.py
self.capture_exception_fn = capture_exception_fn
def _got_request_exception(request=None, **kwargs):
# type: (WSGIRequest, **Any) -> None
extra_props = {}
if request is not None:
# get headers metadata
extra_props = DjangoRequestExtractor(request).extract_person_data()
self.capture_exception_fn(sys.exc_info(), extra_props)
signals.got_request_exception.connect(_got_request_exception)
def uninstall(self):
pass
class DjangoRequestExtractor:
def __init__(self, request):
# type: (Any) -> None
self.request = request
def extract_person_data(self):
headers = self.headers()
# Extract traceparent and tracestate headers
traceparent = headers.get("Traceparent")
tracestate = headers.get("Tracestate")
# Extract the distinct_id from tracestate
distinct_id = None
if tracestate:
# TODO: Align on the format of the distinct_id in tracestate
# We can't have comma or equals in header values here, so maybe we should base64 encode it?
match = re.search(r"posthog-distinct-id=([^,]+)", tracestate)
if match:
distinct_id = match.group(1)
return {
**self.user(),
"distinct_id": distinct_id,
"ip": headers.get("X-Forwarded-For"),
"user_agent": headers.get("User-Agent"),
"traceparent": traceparent,
"$request_path": self.request.path,
}
def user(self):
user_data: dict[str, str] = {}
user = getattr(self.request, "user", None)
if user is None or not user.is_authenticated:
return user_data
try:
user_id = str(user.pk)
if user_id:
user_data.setdefault("$user_id", user_id)
except Exception:
pass
try:
email = str(user.email)
if email:
user_data.setdefault("email", email)
except Exception:
pass
return user_data
def headers(self):
# type: () -> Dict[str, str]
return dict(self.request.headers)
+84 -167
View File
@@ -11,7 +11,24 @@ import re
import sys
import types
from datetime import datetime
from typing import TYPE_CHECKING
from types import FrameType, TracebackType # noqa: F401
from typing import ( # noqa: F401
Any,
Dict,
Iterator,
List,
Literal,
Optional,
Set,
Tuple,
TypedDict,
TypeVar,
Union,
cast,
TYPE_CHECKING,
)
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
try:
# Python 3.11
@@ -23,85 +40,61 @@ except ImportError:
DEFAULT_MAX_VALUE_LENGTH = 1024
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
if TYPE_CHECKING:
from types import FrameType, TracebackType
from typing import ( # noqa: F401
Any,
Callable,
Dict,
Iterator,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypedDict,
TypeVar,
Union,
cast,
)
ExcInfo = Union[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
Tuple[None, None, None],
]
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"check_in_id": str,
"contexts": Dict[str, Dict[str, object]],
"dist": str,
"duration": Optional[float],
"environment": str,
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
"event_id": str,
"exception": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
# "logentry": Mapping[str, object],
"logger": str,
# "measurements": Dict[str, MeasurementValue],
"message": str,
"modules": Dict[str, str],
# "monitor_config": Mapping[str, object],
"monitor_slug": Optional[str],
"platform": Literal["python"],
"profile": object,
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[
str, object
], # We access this key in the code, but I am unsure whether we ever set it
"start_timestamp": datetime,
"status": Optional[str],
# "tags": MutableMapping[
# str, str
# ], # Tags must be less than 200 characters each
"threads": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"timestamp": Optional[datetime], # Must be set before sending the event
"transaction": str,
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
"type": Literal["check_in", "transaction"],
"user": Dict[str, object],
"_metrics_summary": Dict[str, object],
},
total=False,
)
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"check_in_id": str,
"contexts": Dict[str, Dict[str, object]],
"dist": str,
"duration": Optional[float],
"environment": str,
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
"event_id": str,
"exception": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
# "logentry": Mapping[str, object],
"logger": str,
# "measurements": Dict[str, MeasurementValue],
"message": str,
"modules": Dict[str, str],
# "monitor_config": Mapping[str, object],
"monitor_slug": Optional[str],
"platform": Literal["python"],
"profile": object,
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[
str, object
], # We access this key in the code, but I am unsure whether we ever set it
"start_timestamp": datetime,
"status": Optional[str],
# "tags": MutableMapping[
# str, str
# ], # Tags must be less than 200 characters each
"threads": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"timestamp": Optional[datetime], # Must be set before sending the event
"transaction": str,
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
"type": Literal["check_in", "transaction"],
"user": Dict[str, object],
"_metrics_summary": Dict[str, object],
},
total=False,
)
epoch = datetime(1970, 1, 1)
@@ -362,12 +355,9 @@ def filename_for_module(module, abs_path):
def serialize_frame(
frame,
tb_lineno=None,
include_local_variables=True,
include_source_context=True,
max_value_length=None,
custom_repr=None,
):
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
# type: (FrameType, Optional[int], Optional[int]) -> Dict[str, Any]
f_code = getattr(frame, "f_code", None)
if not f_code:
abs_path = None
@@ -392,45 +382,13 @@ def serialize_frame(
"lineno": tb_lineno,
} # type: Dict[str, Any]
if include_source_context:
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
frame, tb_lineno, max_value_length
)
if include_local_variables:
# TODO - we don't support local variables, yet
pass
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
frame, tb_lineno, max_value_length
)
return rv
def current_stacktrace(
include_local_variables=True, # type: bool
include_source_context=True, # type: bool
max_value_length=None, # type: Optional[int]
):
# type: (...) -> Dict[str, Any]
__tracebackhide__ = True
frames = []
f = sys._getframe() # type: Optional[FrameType]
while f is not None:
if not should_hide_frame(f):
frames.append(
serialize_frame(
f,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
)
)
f = f.f_back
frames.reverse()
return {"frames": frames, "type": "raw"}
def get_errno(exc_value):
# type: (BaseException) -> Optional[Any]
return getattr(exc_value, "errno", None)
@@ -451,7 +409,6 @@ def single_exception_from_error_tuple(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=None, # type: Optional[int]
parent_id=None, # type: Optional[int]
@@ -499,25 +456,13 @@ def single_exception_from_error_tuple(
exception_value["type"] = get_type_name(exc_type)
exception_value["value"] = get_error_message(exc_value)
if client_options is None:
include_local_variables = True
include_source_context = True
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
custom_repr = None
else:
include_local_variables = client_options["include_local_variables"]
include_source_context = client_options["include_source_context"]
max_value_length = client_options["max_value_length"]
custom_repr = client_options.get("custom_repr")
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
frames = [
serialize_frame(
tb.tb_frame,
tb_lineno=tb.tb_lineno,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
custom_repr=custom_repr,
)
for tb in iter_stacks(tb)
]
@@ -573,7 +518,6 @@ def exceptions_from_error(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=0, # type: int
parent_id=0, # type: int
@@ -589,7 +533,6 @@ def exceptions_from_error(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
@@ -617,7 +560,6 @@ def exceptions_from_error(
exc_type=type(cause),
exc_value=cause,
tb=getattr(cause, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__cause__",
@@ -638,7 +580,6 @@ def exceptions_from_error(
exc_type=type(context),
exc_value=context,
tb=getattr(context, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__context__",
@@ -653,7 +594,6 @@ def exceptions_from_error(
exc_type=type(e),
exc_value=e,
tb=getattr(e, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
@@ -666,7 +606,6 @@ def exceptions_from_error(
def exceptions_from_error_tuple(
exc_info, # type: ExcInfo
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> List[Dict[str, Any]]
@@ -681,7 +620,6 @@ def exceptions_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=0,
parent_id=0,
@@ -691,9 +629,7 @@ def exceptions_from_error_tuple(
exceptions = []
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
exceptions.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client_options, mechanism
)
single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism)
)
exceptions.reverse()
@@ -783,7 +719,7 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
def exception_is_already_captured(error):
# type: (Union[BaseException, ExcInfo]) -> bool
# type: (ExceptionArg) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__posthog_exception_captured")
# Autocaptured exceptions are passed as a tuple from our system hooks,
@@ -796,19 +732,21 @@ def exception_is_already_captured(error):
return False # type: ignore[unreachable]
def mark_exception_as_captured(error):
# type: (Union[BaseException, ExcInfo]) -> None
def mark_exception_as_captured(error, uuid):
# type: (ExceptionArg, str) -> None
if isinstance(error, BaseException):
setattr(error, "__posthog_exception_captured", True)
setattr(error, "__posthog_exception_uuid", uuid)
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
if error[1] is not None:
setattr(error[1], "__posthog_exception_captured", True)
setattr(error[1], "__posthog_exception_uuid", uuid)
def exc_info_from_error(error):
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
# type: (ExceptionArg) -> ExcInfo
if isinstance(error, tuple) and len(error) == 3:
exc_type, exc_value, tb = error
elif isinstance(error, BaseException):
@@ -865,27 +803,6 @@ def construct_artificial_traceback(e):
setattr(e, "__traceback__", tb)
def event_from_exception(
exc_info, # type: Union[BaseException, ExcInfo]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> Tuple[Event, Dict[str, Any]]
exc_info = exc_info_from_error(exc_info)
hint = event_hint_with_exc_info(exc_info)
return (
{
"level": "error",
"exception": {
"values": exceptions_from_error_tuple(
exc_info, client_options, mechanism
)
},
},
hint,
)
def _module_in_list(name, items):
# type: (str | None, Optional[List[str]]) -> bool
if name is None:
+234 -9
View File
@@ -55,8 +55,161 @@ def variant_lookup_table(feature_flag):
return lookup_table
def evaluate_flag_dependency(
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
):
"""
Evaluate a flag dependency property according to the dependency chain algorithm.
Args:
property: Flag property with type="flag" and dependency_chain
flags_by_key: Dictionary of all flags by their key
evaluation_cache: Cache for storing evaluation results
distinct_id: The distinct ID being evaluated
properties: Person properties for evaluation
cohort_properties: Cohort properties for evaluation
Returns:
bool: True if all dependencies in the chain evaluate to True, False otherwise
"""
if flags_by_key is None or evaluation_cache is None:
# Cannot evaluate flag dependencies without required context
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
)
# Check if dependency_chain is present - it should always be provided for flag dependencies
if "dependency_chain" not in property:
# Missing dependency_chain indicates malformed server data
raise InconclusiveMatchError(
f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
)
dependency_chain = property["dependency_chain"]
# Handle circular dependency (empty chain means circular)
if len(dependency_chain) == 0:
log.debug(f"Circular dependency detected for flag: {property.get('key')}")
raise InconclusiveMatchError(
f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
)
# Evaluate all dependencies in the chain order
for dep_flag_key in dependency_chain:
if dep_flag_key not in evaluation_cache:
# Need to evaluate this dependency first
dep_flag = flags_by_key.get(dep_flag_key)
if not dep_flag:
# Missing flag dependency - cannot evaluate locally
evaluation_cache[dep_flag_key] = None
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
)
else:
# Check if the flag is active (same check as in client._compute_flag_locally)
if not dep_flag.get("active"):
evaluation_cache[dep_flag_key] = False
else:
# Recursively evaluate the dependency
try:
dep_result = match_feature_flag_properties(
dep_flag,
distinct_id,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
)
evaluation_cache[dep_flag_key] = dep_result
except InconclusiveMatchError as e:
# If we can't evaluate a dependency, store None and propagate the error
evaluation_cache[dep_flag_key] = None
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
) from e
# Check the cached result
cached_result = evaluation_cache[dep_flag_key]
if cached_result is None:
# Previously inconclusive - raise error again
raise InconclusiveMatchError(
f"Flag dependency '{dep_flag_key}' was previously inconclusive"
)
elif not cached_result:
# Definitive False result - dependency failed
return False
# All dependencies in the chain have been evaluated successfully
# Now check if the final flag value matches the expected value in the property
flag_key = property.get("key")
expected_value = property.get("value")
operator = property.get("operator", "exact")
if flag_key and expected_value is not None:
# Get the actual value of the flag we're checking
actual_value = evaluation_cache.get(flag_key)
if actual_value is None:
# Flag wasn't evaluated - this shouldn't happen if dependency chain is correct
raise InconclusiveMatchError(
f"Flag '{flag_key}' was not evaluated despite being in dependency chain"
)
# For flag dependencies, we need to compare the actual flag result with expected value
# using the flag_evaluates_to operator logic
if operator == "flag_evaluates_to":
return matches_dependency_value(expected_value, actual_value)
else:
# This should never happen, but just to be defensive.
raise InconclusiveMatchError(
f"Flag dependency property for '{property.get('key', 'unknown')}' has invalid operator '{operator}'"
)
# If no value check needed, return True (all dependencies passed)
return True
def matches_dependency_value(expected_value, actual_value):
"""
Check if the actual flag value matches the expected dependency value.
This follows the same logic as the C# MatchesDependencyValue function:
- String variant case: check for exact match or boolean true
- Boolean case: must match expected boolean value
Args:
expected_value: The expected value from the property
actual_value: The actual value returned by the flag evaluation
Returns:
bool: True if the values match according to flag dependency rules
"""
# String variant case - check for exact match or boolean true
if isinstance(actual_value, str) and len(actual_value) > 0:
if isinstance(expected_value, bool):
# Any variant matches boolean true
return expected_value
elif isinstance(expected_value, str):
# variants are case-sensitive, hence our comparison is too
return actual_value == expected_value
else:
return False
# Boolean case - must match expected boolean value
elif isinstance(actual_value, bool) and isinstance(expected_value, bool):
return actual_value == expected_value
# Default case
return False
def match_feature_flag_properties(
flag, distinct_id, properties, cohort_properties=None
flag,
distinct_id,
properties,
cohort_properties=None,
flags_by_key=None,
evaluation_cache=None,
) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
@@ -79,7 +232,13 @@ def match_feature_flag_properties(
# if any one condition resolves to True, we can shortcircuit and return
# the matching variant
if is_condition_match(
flag, distinct_id, condition, properties, cohort_properties
flag,
distinct_id,
condition,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
@@ -101,14 +260,36 @@ def match_feature_flag_properties(
def is_condition_match(
feature_flag, distinct_id, condition, properties, cohort_properties
feature_flag,
distinct_id,
condition,
properties,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
for prop in condition.get("properties"):
property_type = prop.get("type")
if property_type == "cohort":
matches = match_cohort(prop, properties, cohort_properties)
matches = match_cohort(
prop,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
elif property_type == "flag":
matches = evaluate_flag_dependency(
prop,
flags_by_key,
evaluation_cache,
distinct_id,
properties,
cohort_properties,
)
else:
matches = match_property(prop, properties)
if not matches:
@@ -256,7 +437,14 @@ def match_property(property, property_values) -> bool:
raise InconclusiveMatchError(f"Unknown operator {operator}")
def match_cohort(property, property_values, cohort_properties) -> bool:
def match_cohort(
property,
property_values,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
) -> bool:
# Cohort properties are in the form of property groups like this:
# {
# "cohort_id": {
@@ -273,10 +461,24 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
)
property_group = cohort_properties[cohort_id]
return match_property_group(property_group, property_values, cohort_properties)
return match_property_group(
property_group,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
def match_property_group(property_group, property_values, cohort_properties) -> bool:
def match_property_group(
property_group,
property_values,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
) -> bool:
if not property_group:
return True
@@ -293,7 +495,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
# a nested property group
for prop in properties:
try:
matches = match_property_group(prop, property_values, cohort_properties)
matches = match_property_group(
prop,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
if property_group_type == "AND":
if not matches:
return False
@@ -316,7 +525,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
for prop in properties:
try:
if prop.get("type") == "cohort":
matches = match_cohort(prop, property_values, cohort_properties)
matches = match_cohort(
prop,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
elif prop.get("type") == "flag":
matches = evaluate_flag_dependency(
prop,
flags_by_key,
evaluation_cache,
distinct_id,
property_values,
cohort_properties,
)
else:
matches = match_property(prop, property_values)
+68 -8
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING, cast
from posthog import scopes
from posthog import contexts, capture_exception
from posthog.client import Client
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse # noqa: F401
@@ -16,7 +17,8 @@ class PosthogContextMiddleware:
- Request Method as $request_method
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
The middleware behaviour is customisable through 3 additional functions:
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
@@ -74,19 +76,32 @@ class PosthogContextMiddleware:
else:
self.capture_exceptions = True
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
settings.POSTHOG_MW_CLIENT, Client
):
self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
else:
self.client = None
def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
(user_id, user_email) = self.extract_request_user(request)
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
scopes.set_context_session(session_id)
contexts.set_context_session(session_id)
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID")
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
scopes.identify_context(distinct_id)
contexts.identify_context(distinct_id)
# Extract user email
if user_email:
tags["email"] = user_email
# Extract current URL
absolute_url = request.build_absolute_uri()
@@ -97,6 +112,20 @@ class PosthogContextMiddleware:
if request.method:
tags["$request_method"] = request.method
# Extract request path
if request.path:
tags["$request_path"] = request.path
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
if user_agent:
tags["$user_agent"] = user_agent
# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
@@ -109,13 +138,44 @@ class PosthogContextMiddleware:
return tags
def extract_request_user(self, request):
user_id = None
email = None
user = getattr(request, "user", None)
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception:
pass
try:
email = str(user.email)
except Exception:
pass
return user_id, email
def __call__(self, request):
# type: (HttpRequest) -> HttpResponse
if self.request_filter and not self.request_filter(request):
return self.get_response(request)
with scopes.new_context(self.capture_exceptions):
with contexts.new_context(self.capture_exceptions, client=self.client):
for k, v in self.extract_tags(request).items():
scopes.tag(k, v)
contexts.tag(k, v)
return self.get_response(request)
def process_exception(self, request, exception):
if self.request_filter and not self.request_filter(request):
return
if not self.capture_exceptions:
return
if self.client:
self.client.capture_exception(exception)
else:
capture_exception(exception)
+6 -2
View File
@@ -132,12 +132,16 @@ def flags(
def remote_config(
personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15
personal_api_key: str,
project_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/",
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
host,
timeout,
)
+315 -3
View File
@@ -88,6 +88,56 @@ def mock_anthropic_response_with_cached_tokens():
)
@pytest.fixture
def mock_anthropic_response_with_tool_calls():
return Message(
id="msg_456",
type="message",
role="assistant",
content=[
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": {"location": "San Francisco"},
},
],
model="claude-3-5-sonnet-20241022",
usage=Usage(
input_tokens=25,
output_tokens=15,
),
stop_reason="tool_use",
stop_sequence=None,
)
@pytest.fixture
def mock_anthropic_response_tool_calls_only():
return Message(
id="msg_789",
type="message",
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_def456",
"name": "get_weather",
"input": {"location": "New York", "unit": "fahrenheit"},
}
],
model="claude-3-5-sonnet-20241022",
usage=Usage(
input_tokens=30,
output_tokens=12,
),
stop_reason="tool_use",
stop_sequence=None,
)
def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
@@ -112,7 +162,10 @@ def test_basic_completion(mock_client, mock_anthropic_response):
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"}
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
@@ -286,7 +339,9 @@ def test_basic_integration(mock_client):
{"role": "user", "content": "Foo"},
]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_output_choices"][0]["content"] == "Bar"
assert props["$ai_output_choices"][0]["content"] == [
{"type": "text", "text": "Bar"}
]
assert props["$ai_input_tokens"] == 18
assert props["$ai_output_tokens"] == 1
assert props["$ai_http_status"] == 200
@@ -425,7 +480,10 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
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"}
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
@@ -434,3 +492,257 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_definition(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)
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a specific location",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location name to get weather for",
}
},
"required": ["location"],
},
}
]
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
temperature=0.7,
tools=tools,
messages=[{"role": "user", "content": "hey"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
def test_tool_calls_in_output_choices(
mock_client, mock_anthropic_response_with_tool_calls
):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_calls,
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_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"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"id": "toolu_abc123",
"function": {
"name": "get_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_tool_calls_only_no_content(
mock_client, mock_anthropic_response_tool_calls_only
):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_tool_calls_only,
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{"role": "user", "content": "Get weather for New York"}],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_response_tool_calls_only
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-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "function",
"id": "toolu_def456",
"function": {
"name": "get_weather",
"arguments": {"location": "New York", "unit": "fahrenheit"},
},
}
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 12
assert props["$ai_http_status"] == 200
def test_async_tool_calls_in_output_choices(
mock_client, mock_anthropic_response_with_tool_calls
):
import asyncio
async def mock_async_create(**kwargs):
return mock_anthropic_response_with_tool_calls
with patch(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
return await async_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
],
posthog_distinct_id="test-id",
)
response = asyncio.run(run_test())
assert response == mock_anthropic_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"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"id": "toolu_abc123",
"function": {
"name": "get_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
+311
View File
@@ -56,6 +56,87 @@ def mock_google_genai_client():
yield mock_client_instance
@pytest.fixture
def mock_gemini_response_with_function_calls():
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 25
mock_usage.candidates_token_count = 15
mock_response.usage_metadata = mock_usage
# Mock function call
mock_function_call = MagicMock()
mock_function_call.name = "get_current_weather"
mock_function_call.args = {"location": "San Francisco"}
# Mock text part 1
mock_text_part1 = MagicMock()
mock_text_part1.text = "I'll check the weather for you."
# Make hasattr(part, "text") return True
type(mock_text_part1).text = mock_text_part1.text
# Mock text part 2
mock_text_part2 = MagicMock()
mock_text_part2.text = " Let me look that up."
type(mock_text_part2).text = mock_text_part2.text
# Mock function call part - need to ensure hasattr() works correctly
mock_function_part = MagicMock()
mock_function_part.function_call = mock_function_call
# Make hasattr(part, "function_call") return True
type(mock_function_part).function_call = mock_function_part.function_call
# Ensure hasattr(part, "text") returns False for the function part
del mock_function_part.text
# Mock content with 2 text parts and 1 function call part
mock_content = MagicMock()
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
# Mock candidate
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
@pytest.fixture
def mock_gemini_response_function_calls_only():
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 30
mock_usage.candidates_token_count = 12
mock_response.usage_metadata = mock_usage
# Mock function call
mock_function_call = MagicMock()
mock_function_call.name = "get_current_weather"
mock_function_call.args = {"location": "New York", "unit": "fahrenheit"}
# Mock function call part (no text part) - need to ensure hasattr() works correctly
mock_function_part = MagicMock()
mock_function_part.function_call = mock_function_call
# Make hasattr(part, "function_call") return True
type(mock_function_part).function_call = mock_function_part.function_call
# Ensure hasattr(part, "text") returns False for the function part
del mock_function_part.text
# Mock content with only function call part
mock_content = MagicMock()
mock_content.parts = [mock_function_part]
# Mock candidate
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
def test_new_client_basic_generation(
mock_client, mock_google_genai_client, mock_gemini_response
):
@@ -318,3 +399,233 @@ def test_new_client_override_defaults(
assert props["team"] == "ai" # from defaults
assert props["feature"] == "chat" # from call
assert props["urgent"] is True # from call
def test_vertex_ai_parameters_passed_through(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test that Vertex AI parameters are properly passed to genai.Client"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
# Mock credentials object
mock_credentials = MagicMock()
mock_debug_config = MagicMock()
mock_http_options = MagicMock()
# Create client with Vertex AI parameters
Client(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
google_genai.Client.assert_called_once_with(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
)
def test_api_key_mode(mock_client, mock_google_genai_client):
"""Test API key authentication mode"""
# Create client with just API key (traditional mode)
Client(
api_key="test-api-key",
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
google_genai.Client.assert_called_once_with(api_key="test-api-key")
def test_vertex_ai_mode_with_optional_api_key(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test Vertex AI mode with optional API key"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
mock_credentials = MagicMock()
# Create client with Vertex AI + API key
Client(
vertexai=True,
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
posthog_client=mock_client,
)
# Verify genai.Client was called with both Vertex AI params and API key
google_genai.Client.assert_called_once_with(
vertexai=True,
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
)
def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test that tools defined in config are captured in $ai_tools property"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
mock_tool.function_declarations = [
MagicMock(
name="get_current_weather",
description="Gets the current weather for a given location.",
parameters=MagicMock(
type="OBJECT",
properties={
"location": MagicMock(
type="STRING",
description="The city and state, e.g. San Francisco, CA",
)
},
required=["location"],
),
)
]
mock_config = MagicMock()
mock_config.tools = [mock_tool]
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["hey"],
config=mock_config,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response from Gemini"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == [mock_tool]
def test_function_calls_in_output_choices(
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
):
"""Test that function calls are properly included in $ai_output_choices"""
mock_google_genai_client.models.generate_content.return_value = (
mock_gemini_response_with_function_calls
)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["What's the weather in San Francisco?"],
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_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"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll check the weather for you."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_function_calls_only_no_content(
mock_client, mock_google_genai_client, mock_gemini_response_function_calls_only
):
"""Test function calls without text content in $ai_output_choices"""
mock_google_genai_client.models.generate_content.return_value = (
mock_gemini_response_function_calls_only
)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Get weather for New York"],
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_function_calls_only
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": {"location": "New York", "unit": "fahrenheit"},
},
}
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 12
assert props["$ai_http_status"] == 200
+150 -1
View File
@@ -5,7 +5,7 @@ import os
import time
import uuid
from typing import List, Literal, Optional, TypedDict, Union
from unittest.mock import patch
from unittest.mock import patch, MagicMock
import pytest
@@ -1727,3 +1727,152 @@ def test_openai_reasoning_tokens(mock_client):
assert call["properties"]["$ai_reasoning_tokens"] is not None
assert call["properties"]["$ai_input_tokens"] is not None
assert call["properties"]["$ai_output_tokens"] is not None
def test_callback_handler_without_client():
"""Test that CallbackHandler works properly when no PostHog client is passed."""
with patch("posthog.ai.langchain.callbacks.setup") as mock_setup:
mock_client = mock_setup.return_value
callbacks = CallbackHandler()
# Verify that setup() was called
mock_setup.assert_called_once()
# Verify that the client was set to the result of setup()
assert callbacks._ph_client == mock_client
# Test that the callback handler works with a simple chain
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
chain = prompt | model
# This should work and call the mock client
result = chain.invoke({}, config={"callbacks": [callbacks]})
assert result.content == "Bar"
# Verify that the mock client was used for capturing events
assert mock_client.capture.call_count == 3
def test_convert_message_to_dict_tool_calls():
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
from posthog.ai.langchain.callbacks import _convert_message_to_dict
from langchain_core.messages import AIMessage
from langchain_core.messages.tool import ToolCall
# Create an AIMessage with tool calls
tool_calls = [
ToolCall(
id="call_123",
name="get_weather",
args={"city": "San Francisco", "units": "celsius"},
)
]
ai_message = AIMessage(
content="I'll check the weather for you.", tool_calls=tool_calls
)
# Convert to dict
result = _convert_message_to_dict(ai_message)
# Verify the conversion
assert result["role"] == "assistant"
assert result["content"] == "I'll check the weather for you."
assert result["tool_calls"] == [
{
"type": "function",
"id": "call_123",
"function": {
"name": "get_weather",
"arguments": '{"city": "San Francisco", "units": "celsius"}',
},
}
]
def test_tool_definition(mock_client):
"""Test that tools defined in invocation parameters are captured in $ai_tools property"""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
# Define tools to be passed to the invocation parameters
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a specific location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location name to get weather for",
}
},
"required": ["location"],
},
},
}
]
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com/v1"}},
run_id,
messages=[{"role": "user", "content": "hey"}],
invocation_params={"temperature": 0.7, "tools": tools},
metadata={"ls_model_name": "gpt-4o-mini", "ls_provider": "openai"},
name="test",
)
expected = GenerationMetadata(
model="gpt-4o-mini",
input=[{"role": "user", "content": "hey"}],
start_time=1234567890,
model_params={"temperature": 0.7},
provider="openai",
base_url="https://api.openai.com/v1",
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 == {}
# Now test that the tools are properly captured in the PostHog event
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == run_id
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_model_parameters"] == {"temperature": 0.7}
assert props["$ai_base_url"] == "https://api.openai.com/v1"
assert props["$ai_span_name"] == "test"
assert props["$ai_span_id"] == run_id
assert props["$ai_trace_id"] == run_id
assert props["$ai_latency"] == 1.0
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
+335 -34
View File
@@ -1,4 +1,3 @@
import json
import time
from unittest.mock import patch
@@ -26,6 +25,7 @@ try:
ResponseOutputMessage,
ResponseOutputText,
ResponseUsage,
ResponseFunctionToolCall,
ParsedResponse,
)
from openai.types.responses.parsed_response import (
@@ -227,11 +227,27 @@ def mock_openai_response_with_tool_calls():
created=int(time.time()),
choices=[
Choice(
finish_reason="tool_calls",
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="I'll check the weather for you.",
role="assistant",
),
),
Choice(
finish_reason="stop",
index=1,
message=ChatCompletionMessage(
content=" Let me look that up.",
role="assistant",
),
),
Choice(
finish_reason="tool_calls",
index=2,
message=ChatCompletionMessage(
content=None,
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id="call_abc123",
@@ -243,7 +259,7 @@ def mock_openai_response_with_tool_calls():
)
],
),
)
),
],
usage=CompletionUsage(
completion_tokens=15,
@@ -253,6 +269,97 @@ def mock_openai_response_with_tool_calls():
)
@pytest.fixture
def mock_openai_response_tool_calls_only():
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=None,
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id="call_def456",
type="function",
function=Function(
name="get_weather",
arguments='{"location": "New York"}',
),
)
],
),
)
],
usage=CompletionUsage(
completion_tokens=10,
prompt_tokens=25,
total_tokens=35,
),
)
@pytest.fixture
def mock_responses_api_with_tool_calls():
return Response(
id="resp_123",
object="response",
created_at=int(time.time()),
model="gpt-4o-mini",
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
parallel_tool_calls=True,
output=[
ResponseOutputMessage(
id="msg_456",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="I'll help you with the weather.",
annotations=[],
),
ResponseOutputText(
type="output_text",
text=" Let me check that for you.",
annotations=[],
),
],
),
ResponseFunctionToolCall(
id="fc_789",
type="function_call",
name="get_weather",
call_id="call_xyz789",
arguments='{"location": "Chicago"}',
status="completed",
),
],
usage=ResponseUsage(
input_tokens=30,
output_tokens=20,
input_tokens_details={"prompt_tokens": 30, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
total_tokens=50,
),
previous_response_id=None,
user=None,
metadata={},
)
def test_basic_completion(mock_client, mock_openai_response):
with patch(
"openai.resources.chat.completions.Completions.create",
@@ -278,7 +385,10 @@ def test_basic_completion(mock_client, mock_openai_response):
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "Test response"}
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
@@ -427,7 +537,10 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "Test response"}
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
@@ -475,24 +588,34 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
{"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."}
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll check the weather for you."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"id": "call_abc123",
"function": {
"name": "get_weather",
"arguments": '{"location": "San Francisco", "unit": "celsius"}',
},
},
],
}
]
# Check that tool calls are properly captured
# Check that defined tools are properly captured in $ai_tools
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
defined_tools = props["$ai_tools"]
assert len(defined_tools) == 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"}
# Verify the defined tool details
defined_tool = defined_tools[0]
assert defined_tool["type"] == "function"
assert defined_tool["function"]["name"] == "get_weather"
assert defined_tool["function"]["description"] == "Get weather"
assert defined_tool["function"]["parameters"] == {}
# Check token usage
assert props["$ai_input_tokens"] == 20
@@ -500,6 +623,117 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
assert props["$ai_http_status"] == 200
def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls_only):
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_tool_calls_only,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Get weather for New York"}],
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {},
},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_tool_calls_only
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_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "function",
"id": "call_def456",
"function": {
"name": "get_weather",
"arguments": '{"location": "New York"}',
},
}
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_calls):
with patch(
"openai.resources.responses.Responses.create",
return_value=mock_responses_api_with_tool_calls,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "What's the weather in Chicago?"}],
tools=[
{
"name": "get_weather",
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {},
},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_responses_api_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-4o-mini"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll help you with the weather."},
{"type": "text", "text": " Let me check that for you."},
{
"type": "function",
"id": "call_xyz789",
"function": {
"name": "get_weather",
"arguments": '{"location": "Chicago"}',
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 20
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 = [
@@ -644,21 +878,17 @@ def test_streaming_with_tool_calls(mock_client):
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
# Check that the tool calls were properly accumulated
# Check that defined tools are properly captured in $ai_tools
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
defined_tools = props["$ai_tools"]
assert len(defined_tools) == 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"}
# Verify the defined tool details
defined_tool = defined_tools[0]
assert defined_tool["type"] == "function"
assert defined_tool["function"]["name"] == "get_weather"
assert defined_tool["function"]["description"] == "Get weather"
assert defined_tool["function"]["parameters"] == {}
# Check that the content was also accumulated
assert (
@@ -696,7 +926,10 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "Test response"}
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
@@ -765,7 +998,12 @@ def test_responses_parse(mock_client, mock_parsed_response):
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
"content": [
{
"type": "text",
"text": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
}
],
}
]
assert props["$ai_input_tokens"] == 15
@@ -774,3 +1012,66 @@ def test_responses_parse(mock_client, mock_parsed_response):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_definition(mock_client, mock_openai_response):
"""Test that tools defined in the create function are captured in $ai_tools property"""
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Define tools to be passed to the create function
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a specific location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location name to get weather for",
}
},
"required": ["location"],
},
},
}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hey"}],
tools=tools,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
@@ -1,3 +0,0 @@
import pytest
pytest.importorskip("django")
@@ -1,121 +0,0 @@
from posthog.exception_integrations.django import DjangoRequestExtractor
from django.test import RequestFactory
from django.conf import settings
from django.core.management import call_command
import django
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
# setup a test app
if not settings.configured:
settings.configure(
SECRET_KEY="test",
DEFAULT_CHARSET="utf-8",
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
)
django.setup()
call_command("migrate", verbosity=0, interactive=False)
def mock_request_factory(override_headers):
factory = RequestFactory(
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Referrer": "http://example.com",
"X-Forwarded-For": "193.4.5.12",
**(override_headers or {}),
}
)
request = factory.get("/api/endpoint")
return request
def test_request_extractor_with_no_trace():
request = mock_request_factory(None)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_trace():
request = mock_request_factory(
{"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": None,
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_tracestate():
request = mock_request_factory(
{
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"tracestate": "posthog-distinct-id=1234",
}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": "1234",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_complicated_tracestate():
request = mock_request_factory(
{"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": "alohaMountainsXUYZ",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_request_user():
from django.contrib.auth.models import User
user = User.objects.create_user(
username="test", email="test@posthog.com", password="top_secret"
)
request = mock_request_factory(None)
request.user = user
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
"email": "test@posthog.com",
"$user_id": "1",
}
+5 -1
View File
@@ -1,4 +1,8 @@
from posthog.scopes import new_context, get_context_session_id, get_context_distinct_id
from posthog.contexts import (
new_context,
get_context_session_id,
get_context_distinct_id,
)
import unittest
from unittest.mock import Mock
+118 -71
View File
@@ -40,16 +40,30 @@ class TestClient(unittest.TestCase):
event["properties"]["processed_by_before_send"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
)
success, msg = client.capture("user1", "test_event", {"original": "value"})
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=my_before_send,
sync_mode=True,
)
msg_uuid = client.capture(
"test_event", distinct_id="user1", properties={"original": "value"}
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
self.assertEqual(msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
self.assertIsNotNone(msg_uuid)
# Get the enqueued message from the mock
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(
enqueued_msg["properties"]["processed_by_before_send"], True
)
self.assertEqual(enqueued_msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
def test_before_send_callback_drops_event(self):
"""Test that before_send callback can drop events by returning None."""
@@ -59,20 +73,27 @@ class TestClient(unittest.TestCase):
return None
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
)
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=drop_test_events,
sync_mode=True,
)
# Event should be dropped
success, msg = client.capture("user1", "test_drop_me")
self.assertTrue(success)
self.assertIsNone(msg)
# Event should be dropped
msg_uuid = client.capture("test_drop_me", distinct_id="user1")
self.assertIsNone(msg_uuid)
# Event should go through
success, msg = client.capture("user1", "keep_me")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "keep_me")
# Event should go through
msg_uuid = client.capture("keep_me", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Check the enqueued message
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "keep_me")
def test_before_send_callback_handles_exceptions(self):
"""Test that exceptions in before_send don't crash the client."""
@@ -80,18 +101,26 @@ class TestClient(unittest.TestCase):
def buggy_before_send(event):
raise ValueError("Oops!")
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
)
success, msg = client.capture("user1", "robust_event")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=buggy_before_send,
sync_mode=True,
)
msg_uuid = client.capture("robust_event", distinct_id="user1")
# Event should still be sent despite the exception
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "robust_event")
# Event should still be sent despite the exception
self.assertIsNotNone(msg_uuid)
# Check the enqueued message
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "robust_event")
def test_before_send_callback_works_with_all_event_types(self):
"""Test that before_send works with capture, identify, set, etc."""
"""Test that before_send works with capture, set, etc."""
def add_marker(event):
if "properties" not in event:
@@ -99,38 +128,46 @@ class TestClient(unittest.TestCase):
event["properties"]["marked"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
)
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=add_marker,
sync_mode=True,
)
# Test capture
success, msg = client.capture("user1", "event")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test capture
msg_uuid = client.capture("event", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Test identify
success, msg = client.identify("user1", {"trait": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test set
msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"})
self.assertIsNotNone(msg_uuid)
# Test set
success, msg = client.set("user1", {"prop": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test page
success, msg = client.page("user1", "https://example.com")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Check all events were marked
self.assertEqual(mock_post.call_count, 2)
for call in mock_post.call_args_list:
batch_data = call[1]["batch"]
enqueued_msg = batch_data[0]
self.assertTrue(enqueued_msg["properties"]["marked"])
def test_before_send_callback_disabled_when_none(self):
"""Test that client works normally when before_send is None."""
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
success, msg = client.capture("user1", "normal_event")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=None,
sync_mode=True,
)
msg_uuid = client.capture("normal_event", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "normal_event")
# Check the event was sent normally
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "normal_event")
def test_before_send_callback_pii_scrubbing_example(self):
"""Test a realistic PII scrubbing use case."""
@@ -152,20 +189,30 @@ class TestClient(unittest.TestCase):
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
)
success, msg = client.capture(
"user1",
"form_submit",
{
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=scrub_pii,
sync_mode=True,
)
msg_uuid = client.capture(
"form_submit",
distinct_id="user1",
properties={
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", msg["properties"])
self.assertEqual(msg["properties"]["form_name"], "contact")
self.assertIsNotNone(msg_uuid)
# Check the enqueued message was scrubbed
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", enqueued_msg["properties"])
self.assertEqual(enqueued_msg["properties"]["form_name"], "contact")
+1416 -712
View File
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import unittest
from unittest.mock import patch
from posthog.scopes import (
clear_tags,
from posthog.contexts import (
get_tags,
new_context,
scoped,
@@ -14,11 +13,7 @@ from posthog.scopes import (
)
class TestScopes(unittest.TestCase):
def setUp(self):
# Reset any context between tests
clear_tags()
class TestContexts(unittest.TestCase):
def test_tag_and_get_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
@@ -28,14 +23,6 @@ class TestScopes(unittest.TestCase):
assert tags["key1"] == "value1"
assert tags["key2"] == 2
def test_clear_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
assert get_tags()["key1"] == "value1"
clear_tags()
assert get_tags() == {}
def test_new_context_isolation(self):
with new_context(fresh=True):
# Set tag in outer context
-29
View File
@@ -32,32 +32,3 @@ def test_excepthook(tmpdir):
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
def test_trying_to_use_django_integration(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
from posthog import Posthog, Integrations
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
1/0
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"LOL" in output
assert b"DEBUG:posthog:data uploaded successfully" in output
assert (
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
+12 -12
View File
@@ -229,9 +229,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -283,9 +283,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.payload, {"some": "value"})
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
distinct_id="distinct_id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": True,
@@ -305,9 +305,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertIsNone(another_flag_result.payload)
patch_capture.assert_called_with(
"another-distinct-id",
"$feature_flag_called",
{
distinct_id="another-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-2",
"locally_evaluated": True,
@@ -345,9 +345,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
@@ -388,9 +388,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, [1, 2, 3])
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
distinct_id="distinct_id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": False,
@@ -431,9 +431,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "no-person-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -7,8 +7,7 @@ class TestModule(unittest.TestCase):
posthog = None
def _assert_enqueue_result(self, result):
self.assertEqual(type(result[0]), bool)
self.assertEqual(type(result[1]), dict)
self.assertEqual(type(result[0]), str)
def failed(self):
self.failed = True
@@ -28,12 +27,7 @@ class TestModule(unittest.TestCase):
self.assertRaises(Exception, self.posthog.capture)
def test_track(self):
res = self.posthog.capture("distinct_id", "python module event")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_identify(self):
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
res = self.posthog.capture("python module event", distinct_id="distinct_id")
self._assert_enqueue_result(res)
self.posthog.flush()
@@ -42,9 +36,5 @@ class TestModule(unittest.TestCase):
self._assert_enqueue_result(res)
self.posthog.flush()
def test_page(self):
self.posthog.page("distinct_id", "https://posthog.com/contact")
self.posthog.flush()
def test_flush(self):
self.posthog.flush()
+123
View File
@@ -1,3 +1,4 @@
import time
import unittest
from dataclasses import dataclass
from datetime import date, datetime, timedelta
@@ -12,6 +13,7 @@ from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from posthog import utils
from posthog.types import FeatureFlagResult
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
FAKE_TEST_API_KEY = "random_key"
@@ -173,3 +175,124 @@ class TestUtils(unittest.TestCase):
"inner_optional": None,
},
}
class TestFlagCache(unittest.TestCase):
def setUp(self):
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
self.flag_result = FeatureFlagResult.from_value_and_payload(
"test-flag", True, None
)
def test_cache_basic_operations(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Test cache miss
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
# Test cache set and hit
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is not None
assert result.get_value()
def test_cache_ttl_expiration(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Set flag in cache
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
# Should be available immediately
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is not None
# Wait for TTL to expire (1 second + buffer)
time.sleep(1.1)
# Should be expired
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
def test_cache_version_invalidation(self):
distinct_id = "user123"
flag_key = "test-flag"
old_version = 1
new_version = 2
# Set flag with old version
self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
# Should hit with old version
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
assert result is not None
# Should miss with new version
result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
assert result is None
# Invalidate old version
self.cache.invalidate_version(old_version)
# Should miss even with old version after invalidation
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
assert result is None
def test_stale_cache_functionality(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Set flag in cache
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
# Wait for TTL to expire
time.sleep(1.1)
# Should not get fresh cache
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
# Should get stale cache (within 1 hour default)
stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
assert stale_result is not None
assert stale_result.get_value()
def test_lru_eviction(self):
# Cache has max_size=3, so adding 4 users should evict the LRU one
flag_version = 1
# Add 3 users
for i in range(3):
user_id = f"user{i}"
self.cache.set_cached_flag(
user_id, "test-flag", self.flag_result, flag_version
)
# Access user0 to make it recently used
self.cache.get_cached_flag("user0", "test-flag", flag_version)
# Add 4th user, should evict user1 (least recently used)
self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
# user0 should still be there (was recently accessed)
result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
assert result is not None
# user2 should still be there (was recently added)
result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
assert result is not None
# user3 should be there (just added)
result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
assert result is not None
+29 -3
View File
@@ -9,6 +9,27 @@ FlagValue = Union[bool, str]
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
# Type alias for the send_feature_flags parameter
class SendFeatureFlagsOptions(TypedDict, total=False):
"""Options for sending feature flags with capture events.
Args:
only_evaluate_locally: Whether to only use local evaluation for feature flags.
If True, only flags that can be evaluated locally will be included.
If False, remote evaluation via /flags API will be used when needed.
person_properties: Properties to use for feature flag evaluation specific to this event.
These properties will be merged with any existing person properties.
group_properties: Group properties to use for feature flag evaluation specific to this event.
Format: { group_type_name: { group_properties } }
"""
should_send: bool
only_evaluate_locally: Optional[bool]
person_properties: Optional[dict[str, Any]]
group_properties: Optional[dict[str, dict[str, Any]]]
flag_keys_filter: Optional[list[str]]
@dataclass(frozen=True)
class FlagReason:
code: str
@@ -92,7 +113,7 @@ class FeatureFlag:
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
payload=payload,
),
)
@@ -160,7 +181,9 @@ class FeatureFlagResult:
key=key,
enabled=enabled,
variant=variant,
payload=json.loads(payload) if isinstance(payload, str) else payload,
payload=json.loads(payload)
if isinstance(payload, str) and payload
else payload,
reason=None,
)
@@ -201,6 +224,7 @@ class FeatureFlagResult:
payload=(
json.loads(details.metadata.payload)
if isinstance(details.metadata.payload, str)
and details.metadata.payload
else details.metadata.payload
),
reason=details.reason.description if details.reason else None,
@@ -278,5 +302,7 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
return {
key: value.metadata.payload
for key, value in response.get("flags", {}).items()
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
if isinstance(value, FeatureFlag)
and value.enabled
and value.metadata.payload is not None
}
+319
View File
@@ -1,12 +1,17 @@
import json
import logging
import numbers
import re
import time
from collections import defaultdict
from dataclasses import asdict, is_dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
import sys
import platform
import distro # For Linux OS detection
import six
from dateutil.tz import tzlocal, tzutc
@@ -154,6 +159,266 @@ class SizeLimitedDict(defaultdict):
super().__setitem__(key, value)
class FlagCacheEntry:
def __init__(self, flag_result, flag_definition_version, timestamp=None):
self.flag_result = flag_result
self.flag_definition_version = flag_definition_version
self.timestamp = timestamp or time.time()
def is_valid(self, current_time, ttl, current_flag_version):
time_valid = (current_time - self.timestamp) < ttl
version_valid = self.flag_definition_version == current_flag_version
return time_valid and version_valid
def is_stale_but_usable(self, current_time, max_stale_age=3600):
return (current_time - self.timestamp) < max_stale_age
class FlagCache:
def __init__(self, max_size=10000, default_ttl=300):
self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry}
self.access_times = {} # distinct_id -> last_access_time
self.max_size = max_size
self.default_ttl = default_ttl
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
current_time = time.time()
if distinct_id not in self.cache:
return None
user_flags = self.cache[distinct_id]
if flag_key not in user_flags:
return None
entry = user_flags[flag_key]
if entry.is_valid(current_time, self.default_ttl, current_flag_version):
self.access_times[distinct_id] = current_time
return entry.flag_result
return None
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600):
current_time = time.time()
if distinct_id not in self.cache:
return None
user_flags = self.cache[distinct_id]
if flag_key not in user_flags:
return None
entry = user_flags[flag_key]
if entry.is_stale_but_usable(current_time, max_stale_age):
return entry.flag_result
return None
def set_cached_flag(
self, distinct_id, flag_key, flag_result, flag_definition_version
):
current_time = time.time()
# Evict LRU users if we're at capacity
if distinct_id not in self.cache and len(self.cache) >= self.max_size:
self._evict_lru()
# Initialize user cache if needed
if distinct_id not in self.cache:
self.cache[distinct_id] = {}
# Store the flag result
self.cache[distinct_id][flag_key] = FlagCacheEntry(
flag_result, flag_definition_version, current_time
)
self.access_times[distinct_id] = current_time
def invalidate_version(self, old_version):
users_to_remove = []
for distinct_id, user_flags in self.cache.items():
flags_to_remove = []
for flag_key, entry in user_flags.items():
if entry.flag_definition_version == old_version:
flags_to_remove.append(flag_key)
# Remove invalidated flags
for flag_key in flags_to_remove:
del user_flags[flag_key]
# Remove user entirely if no flags remain
if not user_flags:
users_to_remove.append(distinct_id)
# Clean up empty users
for distinct_id in users_to_remove:
del self.cache[distinct_id]
if distinct_id in self.access_times:
del self.access_times[distinct_id]
def _evict_lru(self):
if not self.access_times:
return
# Remove 20% of least recently used entries
sorted_users = sorted(self.access_times.items(), key=lambda x: x[1])
to_remove = max(1, len(sorted_users) // 5)
for distinct_id, _ in sorted_users[:to_remove]:
if distinct_id in self.cache:
del self.cache[distinct_id]
if distinct_id in self.access_times:
del self.access_times[distinct_id]
def clear(self):
self.cache.clear()
self.access_times.clear()
class RedisFlagCache:
def __init__(
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
):
self.redis = redis_client
self.default_ttl = default_ttl
self.stale_ttl = stale_ttl
self.key_prefix = key_prefix
self.version_key = f"{key_prefix}version"
def _get_cache_key(self, distinct_id, flag_key):
return f"{self.key_prefix}{distinct_id}:{flag_key}"
def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None):
if timestamp is None:
timestamp = time.time()
# Use clean to make flag_result JSON-serializable for cross-platform compatibility
serialized_result = clean(flag_result)
entry = {
"flag_result": serialized_result,
"flag_version": flag_definition_version,
"timestamp": timestamp,
}
return json.dumps(entry)
def _deserialize_entry(self, data):
try:
entry = json.loads(data)
flag_result = entry["flag_result"]
return FlagCacheEntry(
flag_result=flag_result,
flag_definition_version=entry["flag_version"],
timestamp=entry["timestamp"],
)
except (json.JSONDecodeError, KeyError, ValueError):
# If deserialization fails, treat as cache miss
return None
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
try:
cache_key = self._get_cache_key(distinct_id, flag_key)
data = self.redis.get(cache_key)
if data:
entry = self._deserialize_entry(data)
if entry and entry.is_valid(
time.time(), self.default_ttl, current_flag_version
):
return entry.flag_result
return None
except Exception:
# Redis error - return None to fall back to normal evaluation
return None
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None):
try:
if max_stale_age is None:
max_stale_age = self.stale_ttl
cache_key = self._get_cache_key(distinct_id, flag_key)
data = self.redis.get(cache_key)
if data:
entry = self._deserialize_entry(data)
if entry and entry.is_stale_but_usable(time.time(), max_stale_age):
return entry.flag_result
return None
except Exception:
# Redis error - return None
return None
def set_cached_flag(
self, distinct_id, flag_key, flag_result, flag_definition_version
):
try:
cache_key = self._get_cache_key(distinct_id, flag_key)
serialized_entry = self._serialize_entry(
flag_result, flag_definition_version
)
# Set with TTL for automatic cleanup (use stale_ttl for total lifetime)
self.redis.setex(cache_key, self.stale_ttl, serialized_entry)
# Update the current version
self.redis.set(self.version_key, flag_definition_version)
except Exception:
# Redis error - silently fail, don't break flag evaluation
pass
def invalidate_version(self, old_version):
try:
# For Redis, we use a simple approach: scan for keys with old version
# and delete them. This could be expensive with many keys, but it's
# necessary for correctness.
cursor = 0
pattern = f"{self.key_prefix}*"
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
if key.decode() == self.version_key:
continue
try:
data = self.redis.get(key)
if data:
entry_dict = json.loads(data)
if entry_dict.get("flag_version") == old_version:
self.redis.delete(key)
except (json.JSONDecodeError, KeyError):
# If we can't parse the entry, delete it to be safe
self.redis.delete(key)
if cursor == 0:
break
except Exception:
# Redis error - silently fail
pass
def clear(self):
try:
# Delete all keys matching our pattern
cursor = 0
pattern = f"{self.key_prefix}*"
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
if keys:
self.redis.delete(*keys)
if cursor == 0:
break
except Exception:
# Redis error - silently fail
pass
def convert_to_datetime_aware(date_obj):
if date_obj.tzinfo is None:
date_obj = date_obj.replace(tzinfo=timezone.utc)
@@ -198,3 +463,57 @@ def str_iequals(value, comparand):
False
"""
return str(value).casefold() == str(comparand).casefold()
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,
}
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "5.4.0"
VERSION = "6.7.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+1 -1
View File
@@ -29,6 +29,7 @@ dependencies = [
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0",
"typing-extensions>=4.2.0",
]
[project.urls]
@@ -86,7 +87,6 @@ packages = [
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.exception_integrations",
"posthog.integrations",
]
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
Simple test script for PostHog remote config endpoint.
"""
import posthog
# Initialize PostHog client
posthog.api_key = "phc_..."
posthog.personal_api_key = "phs_..." # or "phx_..."
posthog.host = "http://localhost:8000" # or "https://us.posthog.com"
posthog.debug = True
def test_remote_config():
"""Test remote config payload retrieval."""
print("Testing remote config endpoint...")
# Test feature flag key - replace with an actual flag key from your project
flag_key = "unencrypted-remote-config-setting"
try:
# Get remote config payload
payload = posthog.get_remote_config_payload(flag_key)
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
except Exception as e:
print(f"❌ Error getting remote config: {e}")
if __name__ == "__main__":
test_remote_config()
-112
View File
@@ -1,112 +0,0 @@
import argparse
import json
import logging
import posthog
__name__ = "simulator.py"
__version__ = "0.0.1"
__description__ = "scripting simulator"
def json_hash(str):
if str:
return json.loads(str)
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
parser = argparse.ArgumentParser(description="send a posthog message")
parser.add_argument("--writeKey", help="the posthog writeKey")
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("--event", help="the event name to send with the event")
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
parser.add_argument(
"--name", help="name of the screen or page to send with the message"
)
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
parser.add_argument("--groupId", help="the group id")
options = parser.parse_args()
def failed(status, msg):
raise Exception(msg)
def capture():
posthog.capture(
options.distinct_id,
options.event,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def page():
posthog.page(
options.distinct_id,
name=options.name,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def identify():
posthog.identify(
options.distinct_id,
anonymous_id=options.anonymousId,
traits=json_hash(options.traits),
)
def set_once():
posthog.set_once(
options.distinct_id,
properties=json_hash(options.traits),
)
def set():
posthog.set(
options.distinct_id,
properties=json_hash(options.traits),
)
def unknown():
print()
posthog.api_key = options.writeKey
posthog.on_error = failed
posthog.debug = True
log = logging.getLogger("posthog")
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)
switcher = {
"capture": capture,
"page": page,
"identify": identify,
"set_once": set_once,
"set": set,
}
func = switcher.get(options.type)
if func:
func()
posthog.shutdown()
else:
print("Invalid Message Type " + options.type)
Generated
+857 -935
View File
File diff suppressed because it is too large Load Diff