Compare commits

...
32 Commits
Author SHA1 Message Date
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
Lucas RicoyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
f0e1cdf870 feat: bump version to 5.4.0 with session_id on page method (#271)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-21 00:18:57 +01:00
Lucas RicoyandGitHub e23ca94296 chore: ensure session_id context works with page method (#269) 2025-06-20 23:49:53 +01:00
Oliver BrowneandGitHub 5a7f324a61 fix(err): always safe_str exception values (#267)
* always safe_str

* bump version
2025-06-19 14:38:17 +00:00
Oliver BrowneandGitHub e13c428ff6 feat(err): construct full trace if no traceback available (#266)
* construct full trace if no traceback available

* delete asserts

* bump version
2025-06-19 16:47:42 +03:00
Oliver BrowneandGitHub 77190c23e1 document prep_local (#265) 2025-06-18 19:50:32 +01:00
Oliver BrowneandGitHub b7753392f7 feat: session and identity integrate with context now (#264)
* session and identity in context

* bump version

* make django integration use context distinct id and session functions

* don't use self

* fix comments

* fix middleware tests

* clarify fresh and distinct id's

* Fix exported modules, add makefile command to test changes locally

* tiny fix
2025-06-18 19:27:27 +03:00
250bd424d0 feat(err): add django middleware (#263)
* fix exactly-once capture

* add middleware

* fix typing

* ignore unreacable

* Revert "ignore unreacable"

This reverts commit 0458f0efa6c8e52ecfb1eeb41c57a76d84578164.

* add unreachable ignore

* move unreachable ignore

* switch to use request.headers

* clarify comment

* Update posthog/integrations/django.py

Co-authored-by: David Newell <d.newell1@outlook.com>

* explain typle

* fix comment

* explain that tags become properties

* fix tests

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2025-06-17 17:54:32 +03:00
Oliver BrowneandGitHub 579cc56787 fix: delete sentry integration (#262)
* delete relevant files

* bump uv lock, bump major version as deprecation

* README.md

* try mypy sync

* Revert "try mypy sync"

This reverts commit e1b98b26e59132e52eff6389afd42cb1b07a6a0b.

* try looking at the github action
2025-06-16 18:38:53 +03:00
Oliver BrowneandGitHub 3778eaef7b fix(err): just check if the passed exceptions is a BaseException (#261)
* just check if it's an exception first

* version bump
2025-06-13 19:36:00 +00:00
Georgiy TarasovandGitHub 52df246a3e feat(ai): langchain cached and reasoning tokens (#258)
* fix: reasoning and cached tokens

* test: new flows

* fix: missing field

* chore: bump

* fix: make sure we send write/read/reasoning tokens
2025-06-13 15:02:06 +02:00
Phil HaackandGitHub f1f9ecf7a4 Add flags project board workflow (#259) 2025-06-12 16:33:42 +00:00
Oliver BrowneandGitHub 9db1b7e9f3 fix: change scoped export, add capturing param (#257)
* change export, add capturing param

* capturing -> capture_exceptions
2025-06-12 10:29:32 +01:00
Peter KirkhamandGitHub 01751d1205 feat: add support for parse via responses (#256) 2025-06-11 05:39:24 +01:00
62 changed files with 8236 additions and 3912 deletions
@@ -0,0 +1,17 @@
# This workflow is used to call the flags-project-board workflow when a pull request is opened, ready for review, review requested, synchronized, converted to draft, or reopened.
# It is used to update the feature flags project board with the pull request information.
name: Call Feature Flags Project Workflow
on:
pull_request:
types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened]
jobs:
call-flags-project:
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
with:
pr_number: ${{ github.event.pull_request.number }}
pr_node_id: ${{ github.event.pull_request.node_id }}
is_draft: ${{ github.event.pull_request.draft }}
secrets: inherit
+1
View File
@@ -18,3 +18,4 @@ posthog-analytics
pyrightconfig.json
.env
.DS_Store
posthog-python-references.json
+113
View File
@@ -1,3 +1,116 @@
# 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
# 5.3.0 - 2025-06-19
- fix: safely handle exception values
# 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
## 5.1.0 - 2025-06-18
- feat: session and distinct ID's can now be associated with contexts, and are used as such
- feat: django http request middleware
## 5.0.0 - 2025-06-16
- fix: removed deprecated sentry integration
## 4.10.0 - 2025-06-13
- fix: no longer fail in autocapture.
## 4.9.0 - 2025-06-13
- feat(ai): track reasoning and cache tokens in the LangChain callback
## 4.8.0 - 2025-06-10
- fix: export scoped, rather than tracked, decorator
- feat: allow use of contexts without error tracking
## 4.7.0 - 2025-06-10
- feat: add support for parse endpoint in responses API (no longer beta)
## 4.6.2 - 2025-06-09
- fix: replace `import posthog` with direct method imports
+19 -4
View File
@@ -35,8 +35,23 @@ release_analytics:
e2e_test:
.buildscripts/e2e.sh
django_example:
python -m pip install -e ".[sentry]"
cd sentry_django_example && python manage.py runserver 8080
prep_local:
rm -rf ../posthog-python-local
mkdir ../posthog-python-local
cp -r . ../posthog-python-local/
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
cd ../posthog-python-local && mkdir posthoganalytics
cd ../posthog-python-local && cp -r posthog/* posthoganalytics/
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
cd ../posthog-python-local && rm -rf posthog
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
cd ../posthog-python-local && rm setup_analytics.py.bak
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && rm setup.py.bak
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"
.PHONY: test lint release e2e_test
.PHONY: test lint release e2e_test prep_local
+20 -20
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
@@ -43,24 +43,24 @@ make test
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
### Running the Django Sentry Integration Locally
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
1. Settings.py
1. Input your Sentry DSN
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
2. urls.py
1. This includes the `sentry-debug/` endpoint, which generates an exception
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
Also start the PostHog app locally.
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
### Releasing Versions
Updated 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
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
```toml
dependencies = [
...
"posthoganalytics" #NOTE: no version number
...
]
...
[tools.uv.sources]
posthoganalytics = { path = "../posthog-python-local" }
```
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
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" "$@"
+81
View File
@@ -0,0 +1,81 @@
"""
Constants for PostHog Python SDK documentation generation.
"""
from typing import Dict, Union
# Types that are built-in to Python and don't need to be documented
NO_DOCS_TYPES = [
"Client",
"any",
"int",
"float",
"bool",
"dict",
"list",
"str",
"tuple",
"set",
"frozenset",
"bytes",
"bytearray",
"memoryview",
"range",
"slice",
"complex",
"Union",
"Optional",
"Any",
"Callable",
"Type",
"TypeVar",
"Generic",
"Literal",
"ClassVar",
"Final",
"Annotated",
"NotRequired",
"Required",
"None",
"NoneType",
"object",
"Unpack",
"BaseException",
"Exception",
]
# Documentation generation metadata
DOCUMENTATION_METADATA = {
"hogRef": "0.1",
"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,
}
+472
View File
@@ -0,0 +1,472 @@
#!/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 (
NO_DOCS_TYPES,
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 = f"Parameter: {param.name}"
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}")
# 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,
}
)
# Create the final structure
result = {
"id": "posthog-python",
"hogRef": DOCUMENTATION_METADATA["hogRef"],
"info": sdk_info,
"noDocsTypes": NO_DOCS_TYPES,
"types": types_list,
"classes": classes_list,
}
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")
no_docs = documentation["noDocsTypes"]
if no_docs:
print(
f"{len(no_docs)} types without documentation: {', '.join(no_docs[:5])}{'...' if len(no_docs) > 5 else ''}"
)
except Exception as e:
print(f"❌ Error generating documentation: {e}")
import traceback
traceback.print_exc()
+27 -12
View File
@@ -41,9 +41,9 @@ print(
# Capture an event
posthog.capture(
"distinct_id",
"event",
{"property1": "value", "property2": "value"},
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
@@ -59,37 +59,52 @@ 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
# get feature flag result with all details (enabled, variant, payload, key, reason)
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()}")
# 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"}
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
posthog.capture(
"new_distinct_id",
"event-with-groups",
{"property1": "value", "property2": "value"},
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# # Add properties to the person
posthog.identify("new_distinct_id", {"email": "something@something.com"})
posthog.set(
distinct_id="new_distinct_id", properties={"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(distinct_id="new_distinct_id", properties={"self_serve_signup": True})
posthog.set_once(
"new_distinct_id", {"self_serve_signup": False}
distinct_id="new_distinct_id", properties={"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"})
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Firefox"})
# #############################################################################
+16 -10
View File
@@ -22,19 +22,25 @@ 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
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]
example.py:0: error: Statement is unreachable [unreachable]
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/ai/utils.py:0: error: 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"?
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
posthog/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]
+473 -331
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -8,7 +8,7 @@ except ImportError:
import time
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, cast
from posthog.ai.utils import (
call_llm_and_track_usage,
@@ -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(
+109 -31
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
@@ -14,7 +15,6 @@ from typing import (
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
@@ -30,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
@@ -82,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]]
@@ -113,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,
@@ -128,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 {}
@@ -482,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,
@@ -498,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,
@@ -551,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)
@@ -569,9 +566,14 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_is_error"] = True
else:
# Add usage
input_tokens, output_tokens = _parse_usage(output)
event_properties["$ai_input_tokens"] = input_tokens
event_properties["$ai_output_tokens"] = output_tokens
usage = _parse_usage(output)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
usage.cache_write_tokens
)
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
# Generation results
generation_result = output.generations[-1]
@@ -582,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:
@@ -594,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,
@@ -613,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() != "":
@@ -626,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):
@@ -644,12 +670,24 @@ 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
@dataclass
class ModelUsage:
input_tokens: Optional[int]
output_tokens: Optional[int]
cache_write_tokens: Optional[int]
cache_read_tokens: Optional[int]
reasoning_tokens: Optional[int]
def _parse_usage_model(
usage: Union[BaseModel, Dict],
) -> Tuple[Union[int, None], Union[int, None]]:
usage: Union[BaseModel, dict],
) -> ModelUsage:
if isinstance(usage, BaseModel):
usage = usage.__dict__
@@ -657,15 +695,23 @@ def _parse_usage_model(
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
("input_tokens", "input"),
("output_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
("prompt_token_count", "input"),
("candidates_token_count", "output"),
("cached_content_token_count", "cache_read"),
("thoughts_token_count", "reasoning"),
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
("inputTokenCount", "input"),
("outputTokenCount", "output"),
("cacheCreationInputTokenCount", "cache_write"),
("cacheReadInputTokenCount", "cache_read"),
# Bedrock Anthropic
("prompt_tokens", "input"),
("completion_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# langchain-ibm https://pypi.org/project/langchain-ibm/
("input_token_count", "input"),
("generated_token_count", "output"),
@@ -683,13 +729,45 @@ def _parse_usage_model(
parsed_usage[type_key] = final_count
return parsed_usage.get("input"), parsed_usage.get("output")
# Caching (OpenAI & langchain 0.3.9+)
if "input_token_details" in usage and isinstance(
usage["input_token_details"], dict
):
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
# Reasoning (OpenAI & langchain 0.3.9+)
if "output_token_details" in usage and isinstance(
usage["output_token_details"], dict
):
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
field_mapping = {
"input": "input_tokens",
"output": "output_tokens",
"cache_write": "cache_write_tokens",
"cache_read": "cache_read_tokens",
"reasoning": "reasoning_tokens",
}
return ModelUsage(
**{
dataclass_key: parsed_usage.get(mapped_key) or 0
for mapped_key, dataclass_key in field_mapping.items()
},
)
def _parse_usage(response: LLMResult):
def _parse_usage(response: LLMResult) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
llm_usage: ModelUsage = ModelUsage(
input_tokens=None,
output_tokens=None,
cache_write_tokens=None,
cache_read_tokens=None,
reasoning_tokens=None,
)
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
+49 -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
@@ -230,6 +228,42 @@ class WrappedResponses:
groups=posthog_groups,
)
def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
@@ -305,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
@@ -314,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:
@@ -353,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,
@@ -388,7 +401,7 @@ class WrappedCompletions:
usage_stats,
latency,
output,
tools,
extract_available_tool_calls("openai", kwargs),
)
return generator()
@@ -404,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())
@@ -434,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
+49 -39
View File
@@ -1,6 +1,6 @@
import time
import uuid
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, cast
try:
import openai
@@ -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
@@ -230,6 +229,42 @@ class WrappedResponses:
groups=posthog_groups,
)
async def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
@@ -307,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"] = {}
@@ -317,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:
@@ -356,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,
@@ -391,7 +405,7 @@ class WrappedCompletions:
usage_stats,
latency,
output,
tools,
extract_available_tool_calls("openai", kwargs),
)
return async_generator()
@@ -407,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())
@@ -437,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]
+850 -340
View File
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
import contextvars
from contextlib import contextmanager
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:
def __init__(
self,
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
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.tags: Dict[str, Any] = {}
def set_session_id(self, session_id: str):
self.session_id = session_id
def set_distinct_id(self, distinct_id: str):
self.distinct_id = distinct_id
def add_tag(self, key: str, value: Any):
self.tags[key] = value
def get_parent(self):
return self.parent
def get_session_id(self) -> Optional[str]:
if self.session_id is not None:
return self.session_id
if self.parent is not None and not self.fresh:
return self.parent.get_session_id()
return None
def get_distinct_id(self) -> Optional[str]:
if self.distinct_id is not None:
return self.distinct_id
if self.parent is not None and not self.fresh:
return self.parent.get_distinct_id()
return None
def collect_tags(self) -> Dict[str, Any]:
tags = self.tags.copy()
if self.parent and not self.fresh:
# We want child tags to take precedence over parent tags,
# so we can't use a simple update here, instead collecting
# the parent tags and then updating with the child tags.
new_tags = self.parent.collect_tags()
tags.update(new_tags)
return tags
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
"posthog_context_stack", default=None
)
def _get_current_context() -> Optional[ContextScope]:
return _context_stack.get()
@contextmanager
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
or events captured within the context will be tagged with the context tags.
Args:
fresh: Whether to start with a fresh context (default: False).
If False, inherits tags, identity and session id's from parent context.
If True, starts with no state
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, client)
_context_stack.set(new_context)
try:
yield
except Exception as e:
if new_context.capture_exceptions:
if new_context.client:
new_context.client.capture_exception(e)
else:
capture_exception(e)
raise
finally:
_context_stack.set(new_context.get_parent())
def tag(key: str, value: Any) -> None:
"""
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)
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context. Note, modifying
the returned dictionary will not affect the current context.
Returns:
Dict of all tags in the current context
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
return current_context.collect_tags()
return {}
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. 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:
current_context.set_distinct_id(distinct_id)
def set_context_session(session_id: str) -> None:
"""
Set the session ID for the current context, associating all events captured in this or
child contexts with the given session ID (unless set_context_session is called again).
Entering a fresh context will clear the context-level session ID.
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:
current_context.set_session_id(session_id)
def get_context_session_id() -> Optional[str]:
"""
Get the session ID for the current context.
Returns:
The session ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
return current_context.get_session_id()
return None
def get_context_distinct_id() -> Optional[str]:
"""
Get the distinct ID for the current context.
Returns:
The distinct ID if set, None otherwise
Category:
Contexts
"""
current_context = _get_current_context()
if current_context:
return current_context.get_distinct_id()
return None
F = TypeVar("F", bound=Callable[..., Any])
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.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Example:
@posthog.scoped()
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
posthog.tag("payment_method", "credit_card")
# This event will be captured with tags
posthog.capture("payment_started")
# 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:
from functools import wraps
@wraps(func)
def wrapper(*args, **kwargs):
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
return func(*args, **kwargs)
return cast(F, wrapper)
return decorator
+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)
+139 -179
View File
@@ -9,8 +9,26 @@ import linecache
import os
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
@@ -22,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, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports
"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)
@@ -136,9 +130,6 @@ def event_hint_with_exc_info(exc_info=None):
class AnnotatedValue:
"""
Meta information for a data field in the event payload.
This is to tell Relay that we have tampered with the fields value.
See:
https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423
"""
__slots__ = ("value", "metadata")
@@ -364,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
@@ -394,50 +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(nk): Sort out this current invalid import
# from sentry_sdk.serializer import serialize
# rv["vars"] = serialize(
# dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
# )
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)
@@ -445,18 +396,19 @@ def get_errno(exc_value):
def get_error_message(exc_value):
# type: (Optional[BaseException]) -> str
return (
message = (
getattr(exc_value, "message", "")
or getattr(exc_value, "detail", "")
or safe_str(exc_value)
or exc_value
)
return safe_str(message)
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]
@@ -464,10 +416,7 @@ def single_exception_from_error_tuple(
):
# type: (...) -> Dict[str, Any]
"""
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
Creates a dict that goes into the events `exception.values` list
"""
exception_value = {} # type: Dict[str, Any]
exception_value["mechanism"] = (
@@ -507,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)
]
@@ -581,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
@@ -591,16 +527,12 @@ def exceptions_from_error(
"""
Creates the list of exceptions.
This can include chained exceptions and exceptions from an ExceptionGroup.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""
parent = single_exception_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
@@ -628,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__",
@@ -649,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__",
@@ -664,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,
@@ -677,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]]
@@ -692,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,
@@ -702,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()
@@ -793,11 +718,42 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
return frames
def exception_is_already_captured(error):
# type: (ExceptionArg) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__posthog_exception_captured")
# 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:
return error[1] is not None and hasattr(
error[1], "__posthog_exception_captured"
)
else:
return False # type: ignore[unreachable]
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):
try:
construct_artificial_traceback(error)
except Exception:
pass
tb = getattr(error, "__traceback__", None)
if tb is not None:
exc_type = type(error)
@@ -822,25 +778,29 @@ def exc_info_from_error(error):
return exc_info
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 construct_artificial_traceback(e):
# type: (BaseException) -> None
if getattr(e, "__traceback__", None) is not None:
return
depth = 0
frames = []
while True:
try:
frame = sys._getframe(depth)
depth += 1
except ValueError:
break
frames.append(frame)
frames.reverse()
tb = None
for frame in frames:
tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno)
setattr(e, "__traceback__", tb)
def _module_in_list(name, items):
+15
View File
@@ -109,6 +109,14 @@ def is_condition_match(
property_type = prop.get("type")
if property_type == "cohort":
matches = match_cohort(prop, properties, cohort_properties)
elif property_type == "flag":
log.warning(
"Flag dependency filters are not supported in local evaluation. "
"Skipping condition for flag '%s' with dependency on flag '%s'",
feature_flag.get("key", "unknown"),
prop.get("key", "unknown"),
)
continue
else:
matches = match_property(prop, properties)
if not matches:
@@ -317,6 +325,13 @@ def match_property_group(property_group, property_values, cohort_properties) ->
try:
if prop.get("type") == "cohort":
matches = match_cohort(prop, property_values, cohort_properties)
elif prop.get("type") == "flag":
log.warning(
"Flag dependency filters are not supported in local evaluation. "
"Skipping condition with dependency on flag '%s'",
prop.get("key", "unknown"),
)
continue
else:
matches = match_property(prop, property_values)
+181
View File
@@ -0,0 +1,181 @@
from typing import TYPE_CHECKING, cast
from posthog import contexts, capture_exception
from posthog.client import Client
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse # noqa: F401
from typing import Callable, Dict, Any, Optional # noqa: F401
class PosthogContextMiddleware:
"""Middleware to automatically track Django requests.
This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers:
- Session ID, (extracted from `X-POSTHOG-SESSION-ID`)
- Distinct ID, (extracted from `X-POSTHOG-DISTINCT-ID`)
- Request URL as $current_url
- 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. 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.
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
Context tags are automatically included as properties on all events captured within a context, including exceptions.
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
"""
def __init__(self, get_response):
# type: (Callable[[HttpRequest], HttpResponse]) -> None
self.get_response = get_response
from django.conf import settings
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
settings.POSTHOG_MW_EXTRA_TAGS
):
self.extra_tags = cast(
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
settings.POSTHOG_MW_EXTRA_TAGS,
)
else:
self.extra_tags = None
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
settings.POSTHOG_MW_REQUEST_FILTER
):
self.request_filter = cast(
"Optional[Callable[[HttpRequest], bool]]",
settings.POSTHOG_MW_REQUEST_FILTER,
)
else:
self.request_filter = None
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
settings.POSTHOG_MW_TAG_MAP
):
self.tag_map = cast(
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
settings.POSTHOG_MW_TAG_MAP,
)
else:
self.tag_map = None
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
):
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
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:
contexts.set_context_session(session_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:
contexts.identify_context(distinct_id)
# Extract user email
if user_email:
tags["email"] = user_email
# Extract current URL
absolute_url = request.build_absolute_uri()
if absolute_url:
tags["$current_url"] = absolute_url
# Extract request method
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)
if extra:
tags.update(extra)
# Apply tag mapping if configured
if self.tag_map:
tags = self.tag_map(tags)
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 contexts.new_context(self.capture_exceptions, client=self.client):
for k, v in self.extract_tags(request).items():
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)
-122
View File
@@ -1,122 +0,0 @@
import contextvars
from contextlib import contextmanager
from typing import Any, Callable, Dict, TypeVar, cast
_context_stack: contextvars.ContextVar[list] = contextvars.ContextVar(
"posthog_context_stack", default=[{}]
)
def _get_current_context() -> Dict[str, Any]:
return _context_stack.get()[-1]
@contextmanager
def new_context(fresh=False):
"""
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
or events captured within the context will be tagged with the context tags.
Args:
fresh: Whether to start with a fresh context (default: False).
If False, inherits tags from parent context.
If True, starts with no tags.
Examples:
# 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")
# 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")
"""
from posthog import capture_exception
current_tags = _get_current_context().copy()
current_stack = _context_stack.get()
new_stack = current_stack + [{}] if fresh else current_stack + [current_tags]
token = _context_stack.set(new_stack)
try:
yield
except Exception as e:
capture_exception(e)
raise
finally:
_context_stack.reset(token)
def tag(key: str, value: Any) -> None:
"""
Add a tag to the current context.
Args:
key: The tag key
value: The tag value
Example:
posthog.tag("user_id", "123")
"""
_get_current_context()[key] = value
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context. Note, modifying
the returned dictionary will not affect the current context.
Returns:
Dict of all tags in the current context
"""
return _get_current_context().copy()
def clear_tags() -> None:
"""Clear all tags in the current context."""
_get_current_context().clear()
F = TypeVar("F", bound=Callable[..., Any])
def scoped(fresh=False):
"""
Decorator that creates a new context for the function. Simply wraps
the function in a with posthog.new_context(): block.
Args:
fresh: Whether to start with a fresh context (default: False)
Example:
@posthog.scoped()
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
posthog.tag("payment_method", "credit_card")
# This event will be captured with tags
posthog.capture("payment_started")
# If this raises an exception, it will be captured with tags
# and then re-raised
some_risky_function()
"""
def decorator(func: F) -> F:
from functools import wraps
@wraps(func)
def wrapper(*args, **kwargs):
with new_context(fresh=fresh):
return func(*args, **kwargs)
return cast(F, wrapper)
return decorator
-1
View File
@@ -1 +0,0 @@
POSTHOG_ID_TAG = "posthog_distinct_id"
-28
View File
@@ -1,28 +0,0 @@
from django.conf import settings
from sentry_sdk import configure_scope
from posthog.sentry import POSTHOG_ID_TAG
GET_DISTINCT_ID = getattr(settings, "POSTHOG_DJANGO", {}).get("distinct_id")
def get_distinct_id(request):
if not GET_DISTINCT_ID:
return None
try:
return GET_DISTINCT_ID(request)
except: # noqa: E722
return None
class PosthogDistinctIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
with configure_scope() as scope:
distinct_id = get_distinct_id(request)
if distinct_id:
scope.set_tag(POSTHOG_ID_TAG, distinct_id)
response = self.get_response(request)
return response
-57
View File
@@ -1,57 +0,0 @@
from sentry_sdk._types import MYPY
from sentry_sdk.hub import Hub
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import Dsn
from posthog import capture, host
from posthog.request import DEFAULT_HOST
from posthog.sentry import POSTHOG_ID_TAG
if MYPY:
from typing import Optional # noqa: F401
from sentry_sdk._types import Event, Hint # noqa: F401
class PostHogIntegration(Integration):
identifier = "posthog-python"
organization = None # The Sentry organization, used to send a direct link from PostHog to Sentry
project_id = (
None # The Sentry project id, used to send a direct link from PostHog to Sentry
)
prefix = "https://sentry.io/organizations/" # URL of a hosted sentry instance (default: https://sentry.io/organizations/)
@staticmethod
def setup_once():
@add_global_event_processor
def processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if Hub.current.get_integration(PostHogIntegration) is not None:
if event.get("level") != "error":
return event
if event.get("tags", {}).get(POSTHOG_ID_TAG):
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
event["tags"]["PostHog URL"] = (
f"{host or DEFAULT_HOST}/person/{posthog_distinct_id}"
)
properties = {
"$sentry_event_id": event["event_id"],
"$sentry_exception": event["exception"],
}
if PostHogIntegration.organization:
project_id = PostHogIntegration.project_id or (
not not Hub.current.client.dsn
and Dsn(Hub.current.client.dsn).project_id
)
if project_id:
properties["$sentry_url"] = (
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
)
capture(posthog_distinct_id, "$exception", properties)
return event
+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
+406 -8
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
@@ -1378,11 +1378,11 @@ def test_langgraph_agent(mock_client):
)
graph.invoke(inputs, config={"callbacks": [cb]})
calls = [call[1] for call in mock_client.capture.call_args_list]
assert len(calls) == 21
assert len(calls) == 15
for call in calls:
assert call["properties"]["$ai_trace_id"] == "test-trace-id"
assert len([call for call in calls if call["event"] == "$ai_generation"]) == 2
assert len([call for call in calls if call["event"] == "$ai_span"]) == 18
assert len([call for call in calls if call["event"] == "$ai_span"]) == 12
assert len([call for call in calls if call["event"] == "$ai_trace"]) == 1
@@ -1435,11 +1435,13 @@ def test_span_set_parent_ids_for_third_level_run(mock_client, trace_id):
assert mock_client.capture.call_count == 3
span2, span1, trace = [
call[1]["properties"] for call in mock_client.capture.call_args_list
]
assert span2["$ai_parent_id"] == span1["$ai_span_id"]
assert span1["$ai_parent_id"] == trace["$ai_trace_id"]
calls = mock_client.capture.call_args_list
span_props_2 = calls[0][1]["properties"]
span_props_1 = calls[1][1]["properties"]
trace_props = calls[2][1]["properties"]
assert span_props_2["$ai_parent_id"] == span_props_1["$ai_span_id"]
assert span_props_1["$ai_parent_id"] == trace_props["$ai_trace_id"]
def test_captures_error_with_details_in_span(mock_client):
@@ -1478,3 +1480,399 @@ def test_captures_error_without_details_in_span(mock_client):
== "ValueError"
)
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"]
def test_openai_reasoning_tokens(mock_client):
"""Test that OpenAI reasoning tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think step by step about this problem")]
)
# Mock response with reasoning tokens in output_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me think through this step by step...",
usage_metadata={
"input_tokens": 10,
"output_tokens": 25,
"total_tokens": 35,
"output_token_details": {"reasoning": 15}, # 15 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me think through this step by step..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 10
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_reasoning_tokens"] == 15
def test_anthropic_cache_write_and_read_tokens(mock_client):
"""Test that Anthropic cache creation and read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages([("user", "Analyze this large document")])
# First call with cache creation
model_write = FakeMessagesListChatModel(
responses=[
AIMessage(
content="I've analyzed the document and cached the context.",
usage_metadata={
"total_tokens": 1050,
"input_tokens": 1000,
"output_tokens": 50,
"cache_creation_input_tokens": 800, # Anthropic cache write
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model_write
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "I've analyzed the document and cached the context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 1000
assert generation_props["$ai_output_tokens"] == 50
assert generation_props["$ai_cache_creation_input_tokens"] == 800
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
# Reset mock for second call
mock_client.reset_mock()
# Second call with cache read
model_read = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Using cached analysis to provide quick response.",
usage_metadata={
"input_tokens": 200,
"output_tokens": 30,
"total_tokens": 1030,
"cache_read_input_tokens": 800, # Anthropic cache read
},
)
]
)
chain = prompt | model_read
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Using cached analysis to provide quick response."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 200
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_read_tokens(mock_client):
"""Test that OpenAI cache read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Use the cached prompt for this request")]
)
# Mock response with cache read tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response using cached prompt context.",
usage_metadata={
"input_tokens": 150,
"output_tokens": 40,
"total_tokens": 190,
"input_token_details": {
"cache_read": 100, # 100 tokens read from cache
"cache_creation": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response using cached prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_creation_tokens(mock_client):
"""Test that OpenAI cache creation tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Create a cache for this large prompt context")]
)
# Mock response with cache creation tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Created cache for the prompt context.",
usage_metadata={
"input_tokens": 2000,
"output_tokens": 25,
"total_tokens": 2025,
"input_token_details": {
"cache_creation": 1500, # 1500 tokens written to cache
"cache_read": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Created cache for the prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 2000
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_cache_creation_input_tokens"] == 1500
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_combined_reasoning_and_cache_tokens(mock_client):
"""Test that both reasoning tokens and cache tokens can be captured together."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think through this cached problem")]
)
# Mock response with both reasoning and cache tokens
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me reason through this using cached context...",
usage_metadata={
"input_tokens": 500,
"output_tokens": 100,
"total_tokens": 600,
"input_token_details": {"cache_read": 300, "cache_creation": 0},
"output_token_details": {"reasoning": 60}, # 60 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me reason through this using cached context..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 500
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 60
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
def test_openai_reasoning_tokens(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
cb = CallbackHandler(
mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id"
)
model.invoke("what is the weather in sf", config={"callbacks": [cb]})
call = mock_client.capture.call_args_list[0][1]
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
+462 -33
View File
@@ -1,4 +1,3 @@
import json
import time
from unittest.mock import patch
@@ -26,6 +25,12 @@ try:
ResponseOutputMessage,
ResponseOutputText,
ResponseUsage,
ResponseFunctionToolCall,
ParsedResponse,
)
from openai.types.responses.parsed_response import (
ParsedResponseOutputMessage,
ParsedResponseOutputText,
)
from posthog.ai.openai import OpenAI
@@ -115,6 +120,59 @@ def mock_openai_response_with_responses_api():
)
@pytest.fixture
def mock_parsed_response():
return ParsedResponse(
id="test",
model="gpt-4o-2024-08-06",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ParsedResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ParsedResponseOutputText(
type="output_text",
text='{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
annotations=[],
parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
)
],
)
],
output_parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=15,
output_tokens=20,
input_tokens_details={"prompt_tokens": 15, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 5},
total_tokens=35,
),
user=None,
metadata={},
)
@pytest.fixture
def mock_embedding_response():
return CreateEmbeddingResponse(
@@ -169,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",
@@ -185,7 +259,7 @@ def mock_openai_response_with_tool_calls():
)
],
),
)
),
],
usage=CompletionUsage(
completion_tokens=15,
@@ -195,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",
@@ -220,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
@@ -369,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
@@ -417,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
@@ -442,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 = [
@@ -586,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 (
@@ -638,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
@@ -646,3 +937,141 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_responses_parse(mock_client, mock_parsed_response):
with patch(
"openai.resources.responses.Responses.parse",
return_value=mock_parsed_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
],
text={
"format": {
"type": "json_schema",
"json_schema": {
"name": "event",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"date": {"type": "string"},
"participants": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["name", "date", "participants"],
},
},
}
},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_parsed_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-2024-08-06"
assert props["$ai_input"] == [
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "text",
"text": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
}
],
}
]
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 20
assert props["$ai_reasoning_tokens"] == 5
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",
}
@@ -0,0 +1,173 @@
from posthog.contexts import (
new_context,
get_context_session_id,
get_context_distinct_id,
)
import unittest
from unittest.mock import Mock
from posthog.integrations.django import PosthogContextMiddleware
class MockRequest:
"""Mock Django HttpRequest object"""
def __init__(
self,
headers=None,
method="GET",
path="/test",
host="example.com",
is_secure=False,
):
self.headers = headers or {}
self.method = method
self.path = path
self._host = host
self._is_secure = is_secure
def build_absolute_uri(self):
scheme = "https" if self._is_secure else "http"
return f"{scheme}://{self._host}{self.path}"
class TestPosthogContextMiddleware(unittest.TestCase):
def create_middleware(
self,
extra_tags=None,
request_filter=None,
tag_map=None,
capture_exceptions=True,
):
"""Helper to create middleware instance without calling __init__"""
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = Mock()
middleware.extra_tags = extra_tags
middleware.request_filter = request_filter
middleware.tag_map = tag_map
middleware.capture_exceptions = capture_exceptions
return middleware
def test_extract_tags_basic(self):
with new_context():
"""Test basic tag extraction from request"""
middleware = self.create_middleware()
request = MockRequest(
headers={
"X-POSTHOG-SESSION-ID": "session-123",
"X-POSTHOG-DISTINCT-ID": "user-456",
},
method="POST",
path="/api/test",
host="example.com",
is_secure=True,
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-123")
self.assertEqual(get_context_distinct_id(), "user-456")
self.assertEqual(tags["$current_url"], "https://example.com/api/test")
self.assertEqual(tags["$request_method"], "POST")
def test_extract_tags_missing_headers(self):
"""Test tag extraction when PostHog headers are missing"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(headers={}, method="GET", path="/home")
tags = middleware.extract_tags(request)
self.assertIsNone(get_context_session_id())
self.assertIsNone(get_context_distinct_id())
self.assertEqual(tags["$current_url"], "http://example.com/home")
self.assertEqual(tags["$request_method"], "GET")
def test_extract_tags_partial_headers(self):
"""Test tag extraction with only some PostHog headers present"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-only"}, method="PUT"
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-only")
self.assertIsNone(get_context_distinct_id())
self.assertEqual(tags["$request_method"], "PUT")
def test_extract_tags_with_extra_tags(self):
"""Test tag extraction with extra_tags function"""
def extra_tags_func(request):
return {"custom_tag": "custom_value", "user_id": "789"}
with new_context():
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-123")
self.assertEqual(tags["custom_tag"], "custom_value")
self.assertEqual(tags["user_id"], "789")
def test_extract_tags_with_tag_map(self):
"""Test tag extraction with tag_map function"""
def extra_tags_func(request):
return {"custom_tag": "custom_value", "user_id": "789"}
def tag_map_func(tags):
if "custom_tag" in tags:
tags["mapped_custom_tag"] = f"mapped_{tags['custom_tag']}"
del tags["custom_tag"]
return tags
with new_context():
middleware = self.create_middleware(
tag_map=tag_map_func, extra_tags=extra_tags_func
)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
self.assertEqual(tags["mapped_custom_tag"], "mapped_custom_value")
def test_extract_tags_extra_tags_returns_none(self):
"""Test tag extraction when extra_tags returns None"""
def extra_tags_func(request):
return None
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(method="GET")
tags = middleware.extract_tags(request)
self.assertEqual(tags["$request_method"], "GET")
# Should not crash when extra_tags returns None
def test_extract_tags_extra_tags_returns_empty_dict(self):
"""Test tag extraction when extra_tags returns empty dict"""
def extra_tags_func(request):
return {}
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(method="PATCH")
tags = middleware.extract_tags(request)
self.assertEqual(tags["$request_method"], "PATCH")
if __name__ == "__main__":
unittest.main()
+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")
+1529 -635
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
import unittest
from unittest.mock import patch
from posthog.contexts import (
get_tags,
new_context,
scoped,
tag,
identify_context,
set_context_session,
get_context_session_id,
get_context_distinct_id,
)
class TestContexts(unittest.TestCase):
def test_tag_and_get_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
tag("key2", 2)
tags = get_tags()
assert tags["key1"] == "value1"
assert tags["key2"] == 2
def test_new_context_isolation(self):
with new_context(fresh=True):
# Set tag in outer context
tag("outer", "value")
with new_context(fresh=True):
# Inner context should start empty
assert get_tags() == {}
# Set tag in inner context
tag("inner", "value")
assert get_tags()["inner"] == "value"
# Outer tag should not be visible
self.assertNotIn("outer", get_tags())
with new_context(fresh=False):
# Inner context should inherit outer tag
assert get_tags() == {"outer": "value"}
# After exiting context, inner tag should be gone
self.assertNotIn("inner", get_tags())
# Outer tag should still be there
assert get_tags()["outer"] == "value"
def test_nested_contexts(self):
with new_context(fresh=True):
tag("level1", "value1")
with new_context(fresh=True):
tag("level2", "value2")
with new_context(fresh=True):
tag("level3", "value3")
assert get_tags() == {"level3": "value3"}
# Back to level 2
assert get_tags() == {"level2": "value2"}
# Back to level 1
assert get_tags() == {"level1": "value1"}
@patch("posthog.capture_exception")
def test_scoped_decorator_success(self, mock_capture):
@scoped()
def successful_function(x, y):
tag("x", x)
tag("y", y)
return x + y
result = successful_function(1, 2)
# Function should execute normally
assert result == 3
# No exception should be captured
mock_capture.assert_not_called()
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_scoped_decorator_exception(self, mock_capture):
test_exception = ValueError("Test exception")
def check_context_on_capture(exception, **kwargs):
# Assert tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("important_context") == "value"
mock_capture.side_effect = check_context_on_capture
@scoped()
def failing_function():
tag("important_context", "value")
raise test_exception
# Function should raise the exception
with self.assertRaises(ValueError):
failing_function()
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_new_context_exception_handling(self, mock_capture):
test_exception = RuntimeError("Context exception")
def check_context_on_capture(exception, **kwargs):
# Assert inner context tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("inner_context") == "inner_value"
mock_capture.side_effect = check_context_on_capture
# Set up outer context
with new_context():
tag("outer_context", "outer_value")
try:
with new_context():
tag("inner_context", "inner_value")
raise test_exception
except RuntimeError:
pass # Expected exception
# Outer context should still be intact
assert get_tags()["outer_context"] == "outer_value"
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
def test_identify_context(self):
with new_context(fresh=True):
# Initially no distinct ID
assert get_context_distinct_id() is None
# Set distinct ID
identify_context("user123")
assert get_context_distinct_id() == "user123"
def test_set_context_session(self):
with new_context(fresh=True):
# Initially no session ID
assert get_context_session_id() is None
# Set session ID
set_context_session("session456")
assert get_context_session_id() == "session456"
def test_context_inheritance_fresh_context(self):
with new_context(fresh=True):
identify_context("user123")
set_context_session("session456")
with new_context(fresh=True):
# Fresh context should not inherit
assert get_context_distinct_id() is None
assert get_context_session_id() is None
# Original context should still have values
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
def test_context_inheritance_non_fresh_context(self):
with new_context(fresh=True):
identify_context("user123")
set_context_session("session456")
with new_context(fresh=False):
# Non-fresh context should inherit
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
# Override in child context
identify_context("user789")
set_context_session("session999")
assert get_context_distinct_id() == "user789"
assert get_context_session_id() == "session999"
# Original context should still have original values
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
def test_scoped_decorator_with_context_ids(self):
@scoped()
def function_with_context():
identify_context("user456")
set_context_session("session789")
return get_context_distinct_id(), get_context_session_id()
distinct_id, session_id = function_with_context()
assert distinct_id == "user456"
assert session_id == "session789"
# Context should be cleared after function execution
assert get_context_distinct_id() is None
assert get_context_session_id() is None
-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,
+101 -22
View File
@@ -1355,6 +1355,77 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("posthog.feature_flags.log")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_with_flag_dependencies(
self, patch_get, patch_flags, mock_log
):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"name": "Flag with Dependencies",
"key": "flag-with-dependencies",
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "beta-feature",
"operator": "exact",
"value": True,
"type": "flag",
},
{
"key": "email",
"operator": "icontains",
"value": "@example.com",
"type": "person",
},
],
"rollout_percentage": 100,
}
],
},
}
]
# Test that flag evaluation doesn't fail when encountering a flag dependency
# The flag should evaluate based on other conditions (email contains @example.com)
# Since flag dependencies aren't implemented, it should skip the flag condition
# and evaluate based on the email condition only
feature_flag_match = client.get_feature_flag(
"flag-with-dependencies",
"test-user",
person_properties={"email": "test@example.com"},
)
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
# Verify warning was logged for flag dependency
mock_log.warning.assert_called_with(
"Flag dependency filters are not supported in local evaluation. "
"Skipping condition for flag '%s' with dependency on flag '%s'",
"flag-with-dependencies",
"beta-feature",
)
# Test with email that doesn't match
feature_flag_match = client.get_feature_flag(
"flag-with-dependencies",
"test-user-2",
person_properties={"email": "test@other.com"},
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
# Verify warning was logged again for the second evaluation
self.assertEqual(mock_log.warning.call_count, 2)
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags(self, patch_get, patch_poll):
@@ -2695,9 +2766,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -2729,9 +2800,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -2767,9 +2838,9 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-value",
"locally_evaluated": False,
@@ -2820,9 +2891,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-variant",
"locally_evaluated": False,
@@ -2871,9 +2942,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "decide-flag-with-payload",
"$feature_flag_response": True,
"locally_evaluated": False,
@@ -2948,7 +3019,9 @@ class TestCaptureCalls(unittest.TestCase):
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
@@ -2977,9 +3050,9 @@ class TestCaptureCalls(unittest.TestCase):
# Assert that capture was called once, with the correct parameters
self.assertEqual(patch_capture.call_count, 1)
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,
@@ -3012,9 +3085,9 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -3058,9 +3131,9 @@ class TestCaptureCalls(unittest.TestCase):
)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -3102,9 +3175,9 @@ class TestCaptureCalls(unittest.TestCase):
person_properties={"region": "USA", "name": "Aloha"},
)
patch_capture.assert_called_with(
distinct_id,
"$feature_flag_called",
{
distinct_id=distinct_id,
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -5229,7 +5302,9 @@ class TestConsistency(unittest.TestCase):
"featureFlags": {}
} # Ensure decide returns empty flags
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
@@ -5253,7 +5328,9 @@ class TestConsistency(unittest.TestCase):
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
@@ -5282,7 +5359,9 @@ class TestConsistency(unittest.TestCase):
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
+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()
-138
View File
@@ -1,138 +0,0 @@
import unittest
from unittest.mock import patch
from posthog.scopes import clear_tags, get_tags, new_context, scoped, tag
class TestScopes(unittest.TestCase):
def setUp(self):
# Reset any context between tests
clear_tags()
def test_tag_and_get_tags(self):
tag("key1", "value1")
tag("key2", 2)
tags = get_tags()
assert tags["key1"] == "value1"
assert tags["key2"] == 2
def test_clear_tags(self):
tag("key1", "value1")
assert get_tags()["key1"] == "value1"
clear_tags()
assert get_tags() == {}
def test_new_context_isolation(self):
# Set tag in outer context
tag("outer", "value")
with new_context(fresh=True):
# Inner context should start empty
assert get_tags() == {}
# Set tag in inner context
tag("inner", "value")
assert get_tags()["inner"] == "value"
# Outer tag should not be visible
self.assertNotIn("outer", get_tags())
with new_context(fresh=False):
# Inner context should start empty
assert get_tags() == {"outer": "value"}
# After exiting context, inner tag should be gone
self.assertNotIn("inner", get_tags())
# Outer tag should still be there
assert get_tags()["outer"] == "value"
def test_nested_contexts(self):
tag("level1", "value1")
with new_context(fresh=True):
tag("level2", "value2")
with new_context(fresh=True):
tag("level3", "value3")
assert get_tags() == {"level3": "value3"}
# Back to level 2
assert get_tags() == {"level2": "value2"}
# Back to level 1
assert get_tags() == {"level1": "value1"}
@patch("posthog.capture_exception")
def test_scoped_decorator_success(self, mock_capture):
@scoped()
def successful_function(x, y):
tag("x", x)
tag("y", y)
return x + y
result = successful_function(1, 2)
# Function should execute normally
assert result == 3
# No exception should be captured
mock_capture.assert_not_called()
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_scoped_decorator_exception(self, mock_capture):
test_exception = ValueError("Test exception")
def check_context_on_capture(exception, **kwargs):
# Assert tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("important_context") == "value"
mock_capture.side_effect = check_context_on_capture
@scoped()
def failing_function():
tag("important_context", "value")
raise test_exception
# Function should raise the exception
with self.assertRaises(ValueError):
failing_function()
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_new_context_exception_handling(self, mock_capture):
test_exception = RuntimeError("Context exception")
def check_context_on_capture(exception, **kwargs):
# Assert inner context tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("inner_context") == "inner_value"
mock_capture.side_effect = check_context_on_capture
# Set up outer context
tag("outer_context", "outer_value")
try:
with new_context():
tag("inner_context", "inner_value")
raise test_exception
except RuntimeError:
pass # Expected exception
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
# Outer context should still be intact
assert get_tags()["outer_context"] == "outer_value"
+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
+26 -3
View File
@@ -9,6 +9,24 @@ FlagValue = Union[bool, str]
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
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 } }
"""
only_evaluate_locally: Optional[bool]
person_properties: Optional[dict[str, Any]]
group_properties: Optional[dict[str, dict[str, Any]]]
@dataclass(frozen=True)
class FlagReason:
code: str
@@ -92,7 +110,7 @@ class FeatureFlag:
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
payload=payload,
),
)
@@ -160,7 +178,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 +221,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 +299,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 = "4.6.2"
VERSION = "6.4.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+9 -9
View File
@@ -8,7 +8,7 @@ dynamic = ["version"]
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = {text = "MIT"}
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
@@ -29,6 +29,7 @@ dependencies = [
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0",
"typing-extensions>=4.2.0",
]
[project.urls]
@@ -36,7 +37,6 @@ Homepage = "https://github.com/posthog/posthog-python"
Repository = "https://github.com/posthog/posthog-python"
[project.optional-dependencies]
sentry = ["sentry-sdk", "django"]
langchain = ["langchain>=0.2.0"]
dev = [
"django-stubs",
@@ -52,7 +52,7 @@ dev = [
"pydantic",
"ruff",
"setuptools",
"packaging",
"packaging",
"wheel",
"twine",
"tomli",
@@ -68,10 +68,11 @@ test = [
"django",
"openai",
"anthropic",
"langgraph",
"langchain-community>=0.2.0",
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
@@ -86,8 +87,7 @@ packages = [
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.sentry",
"posthog.exception_integrations",
"posthog.integrations",
]
[tool.setuptools.dynamic]
View File
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
@@ -1,16 +0,0 @@
"""
ASGI config for sentry_django_example project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_asgi_application()
@@ -1,171 +0,0 @@
"""
Django settings for sentry_django_example project.
Generated by 'django-admin startproject' using Django 3.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from pathlib import Path
from uuid import uuid4
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-4kzfiq7vb(t0+jbl#vq)u=%06ouf)n*=l%730c8=tk(wkm9i9o"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# PostHog Setup (can be a separate app)
import posthog # noqa: E402
# You can find this key on the /setup page in PostHog
posthog.api_key = (
"LXP6nQXvo-2TCqGVrWvPah8uJIyVykoMmhnEkEBi5PA" # TODO: replace with your api key
)
posthog.personal_api_key = ""
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = "http://127.0.0.1:8000"
from posthog.sentry.posthog_integration import PostHogIntegration # noqa: E402
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
# PostHogIntegration.prefix = # TODO: your self hosted Sentry url. (default: https://sentry.io/organizations/)
# Since Sentry doesn't allow Integrations configuration (see https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/__init__.py#L171-L183)
# we work around this by setting static class variables beforehand
# Sentry Setup
import sentry_sdk # noqa: E402
from sentry_sdk.integrations.django import DjangoIntegration # noqa: E402
sentry_sdk.init(
dsn="https://27ac54f7f4cf484abf1335436b0c52e5@o344752.ingest.sentry.io/5624115", # TODO: your Sentry DSN here
integrations=[DjangoIntegration(), PostHogIntegration()],
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for performance monitoring.
# We recommend adjusting this value in production.
traces_sample_rate=1.0,
# If you wish to associate users to errors (assuming you are using
# django.contrib.auth) you may enable sending PII data.
send_default_pii=True,
)
POSTHOG_DJANGO = {
"distinct_id": lambda request: str(
uuid4()
) # TODO: your logic for generating unique ID, given the request object
}
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"posthog.sentry.django.PosthogDistinctIdMiddleware",
]
ROOT_URLCONF = "sentry_django_example.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "sentry_django_example.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -1,28 +0,0 @@
"""sentry_django_example URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
def trigger_error(request):
division_by_zero = 1 / 0
urlpatterns = [
path("admin/", admin.site.urls),
path("sentry-debug/", trigger_error),
]
@@ -1,16 +0,0 @@
"""
WSGI config for sentry_django_example project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_wsgi_application()
-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
+883 -972
View File
File diff suppressed because it is too large Load Diff