Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7f324a61 | ||
|
|
e13c428ff6 | ||
|
|
77190c23e1 | ||
|
|
b7753392f7 | ||
|
|
250bd424d0 | ||
|
|
579cc56787 | ||
|
|
3778eaef7b | ||
|
|
52df246a3e | ||
|
|
f1f9ecf7a4 | ||
|
|
9db1b7e9f3 | ||
|
|
01751d1205 | ||
|
|
4426dd9d27 |
@@ -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,3 +1,41 @@
|
||||
# 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
|
||||
|
||||
## 4.6.1 - 2025-06-09
|
||||
|
||||
- fix: replace `import posthog` in `posthoganalytics` package
|
||||
|
||||
@@ -18,7 +18,6 @@ release_analytics:
|
||||
cp -r posthog/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/import posthog/import posthoganalytics/g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
rm -rf posthog
|
||||
python setup_analytics.py sdist bdist_wheel
|
||||
@@ -26,7 +25,6 @@ release_analytics:
|
||||
mkdir posthog
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/import posthoganalytics/import posthog/g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
cp -r posthoganalytics/* posthog/
|
||||
rm -rf posthoganalytics
|
||||
@@ -37,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
|
||||
|
||||
@@ -43,24 +43,22 @@ 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 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`.
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -30,11 +30,8 @@ posthog/__init__.py:0: note: "identify" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
example.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: 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]
|
||||
|
||||
+13
-2
@@ -4,7 +4,15 @@ from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.exception_capture import Integrations # noqa: F401
|
||||
from posthog.scopes import clear_tags, get_tags, new_context, scoped, tag
|
||||
from posthog.scopes import (
|
||||
clear_tags,
|
||||
get_tags,
|
||||
new_context,
|
||||
scoped,
|
||||
tag,
|
||||
set_context_session,
|
||||
identify_context,
|
||||
)
|
||||
from posthog.types import FeatureFlag, FlagsAndPayloads
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -15,7 +23,10 @@ new_context = new_context
|
||||
tag = tag
|
||||
get_tags = get_tags
|
||||
clear_tags = clear_tags
|
||||
tracked = scoped
|
||||
scoped = scoped
|
||||
identify_context = identify_context
|
||||
set_context_session = set_context_session
|
||||
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: Optional[str]
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
@@ -569,9 +568,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]
|
||||
@@ -647,9 +651,18 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
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 +670,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 +704,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):
|
||||
|
||||
@@ -230,6 +230,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."""
|
||||
|
||||
@@ -230,6 +230,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."""
|
||||
|
||||
+46
-9
@@ -19,6 +19,8 @@ from posthog.exception_utils import (
|
||||
exc_info_from_error,
|
||||
exceptions_from_error_tuple,
|
||||
handle_in_app,
|
||||
exception_is_already_captured,
|
||||
mark_exception_as_captured,
|
||||
)
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||||
from posthog.poller import Poller
|
||||
@@ -31,7 +33,11 @@ from posthog.request import (
|
||||
get,
|
||||
remote_config,
|
||||
)
|
||||
from posthog.scopes import get_tags
|
||||
from posthog.scopes import (
|
||||
_get_current_context,
|
||||
get_context_distinct_id,
|
||||
get_context_session_id,
|
||||
)
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FeatureFlagResult,
|
||||
@@ -283,10 +289,17 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
properties = properties or {}
|
||||
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
if "$session_id" not in properties and get_context_session_id():
|
||||
properties["$session_id"] = get_context_session_id()
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"distinct_id": distinct_id,
|
||||
@@ -356,6 +369,9 @@ class Client(object):
|
||||
"""
|
||||
Get feature flags decision, using either flags() or decide() API based on rollout.
|
||||
"""
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
if disable_geoip is None:
|
||||
@@ -404,14 +420,22 @@ class Client(object):
|
||||
|
||||
properties = {**(properties or {}), **system_context()}
|
||||
|
||||
if "$session_id" not in properties and get_context_session_id():
|
||||
properties["$session_id"] = get_context_session_id()
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
require("event", event, string_types)
|
||||
|
||||
# Grab current context tags, if any exist
|
||||
context_tags = get_tags()
|
||||
if context_tags:
|
||||
properties.update(context_tags)
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
context_tags = current_context.collect_tags()
|
||||
# We want explicitly passed properties to override context tags
|
||||
context_tags.update(properties)
|
||||
properties = context_tags
|
||||
|
||||
msg = {
|
||||
"properties": properties,
|
||||
@@ -478,6 +502,9 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
@@ -508,6 +535,9 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
@@ -579,6 +609,9 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
@@ -611,6 +644,9 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
@@ -646,15 +682,16 @@ class Client(object):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
distinct_id = get_context_distinct_id()
|
||||
|
||||
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
|
||||
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
|
||||
try:
|
||||
properties = properties or {}
|
||||
|
||||
# Check if this exception has already been captured
|
||||
if exception is not None and hasattr(
|
||||
exception, "__posthog_exception_captured"
|
||||
):
|
||||
if exception is not None and exception_is_already_captured(exception):
|
||||
self.log.debug("Exception already captured, skipping")
|
||||
return
|
||||
|
||||
@@ -709,7 +746,7 @@ class Client(object):
|
||||
|
||||
# Mark the exception as captured to prevent duplicate captures
|
||||
if exception is not None:
|
||||
setattr(exception, "__posthog_exception_captured", True)
|
||||
mark_exception_as_captured(exception)
|
||||
|
||||
return res
|
||||
except Exception as e:
|
||||
|
||||
+62
-19
@@ -9,6 +9,7 @@ import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -75,7 +76,7 @@ if TYPE_CHECKING:
|
||||
# "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
|
||||
"profile": object,
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
@@ -136,9 +137,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")
|
||||
@@ -400,12 +398,7 @@ def serialize_frame(
|
||||
)
|
||||
|
||||
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
|
||||
# )
|
||||
# TODO - we don't support local variables, yet
|
||||
pass
|
||||
|
||||
return rv
|
||||
@@ -445,12 +438,14 @@ 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]
|
||||
@@ -464,10 +459,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"] = (
|
||||
@@ -591,9 +583,6 @@ 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(
|
||||
@@ -793,11 +782,40 @@ 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: (Union[BaseException, ExcInfo]) -> 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):
|
||||
# type: (Union[BaseException, ExcInfo]) -> None
|
||||
if isinstance(error, BaseException):
|
||||
setattr(error, "__posthog_exception_captured", True)
|
||||
# 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)
|
||||
|
||||
|
||||
def exc_info_from_error(error):
|
||||
# type: (Union[BaseException, ExcInfo]) -> 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,6 +840,31 @@ def exc_info_from_error(error):
|
||||
return exc_info
|
||||
|
||||
|
||||
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 event_from_exception(
|
||||
exc_info, # type: Union[BaseException, ExcInfo]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from posthog import scopes
|
||||
|
||||
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 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
|
||||
|
||||
def extract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
tags = {}
|
||||
|
||||
# Extract session ID from X-POSTHOG-SESSION-ID header
|
||||
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
||||
if session_id:
|
||||
scopes.set_context_session(session_id)
|
||||
|
||||
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header
|
||||
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID")
|
||||
if distinct_id:
|
||||
scopes.identify_context(distinct_id)
|
||||
|
||||
# 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
|
||||
|
||||
# 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 __call__(self, request):
|
||||
# type: (HttpRequest) -> HttpResponse
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes.new_context(self.capture_exceptions):
|
||||
for k, v in self.extract_tags(request).items():
|
||||
scopes.tag(k, v)
|
||||
|
||||
return self.get_response(request)
|
||||
+161
-38
@@ -1,58 +1,113 @@
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Dict, TypeVar, cast
|
||||
from typing import Optional, Any, Callable, Dict, TypeVar, cast
|
||||
|
||||
_context_stack: contextvars.ContextVar[list] = contextvars.ContextVar(
|
||||
"posthog_context_stack", default=[{}]
|
||||
|
||||
class ContextScope:
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
fresh: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
):
|
||||
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() -> Dict[str, Any]:
|
||||
return _context_stack.get()[-1]
|
||||
def _get_current_context() -> Optional[ContextScope]:
|
||||
return _context_stack.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def new_context(fresh=False):
|
||||
def new_context(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
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")
|
||||
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")
|
||||
# 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")
|
||||
|
||||
"""
|
||||
import posthog
|
||||
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)
|
||||
current_context = _get_current_context()
|
||||
new_context = ContextScope(current_context, fresh, capture_exceptions)
|
||||
_context_stack.set(new_context)
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
posthog.capture_exception(e)
|
||||
if new_context.capture_exceptions:
|
||||
capture_exception(e)
|
||||
raise
|
||||
finally:
|
||||
_context_stack.reset(token)
|
||||
_context_stack.set(new_context.get_parent())
|
||||
|
||||
|
||||
def tag(key: str, value: Any) -> None:
|
||||
@@ -66,9 +121,13 @@ def tag(key: str, value: Any) -> None:
|
||||
Example:
|
||||
posthog.tag("user_id", "123")
|
||||
"""
|
||||
_get_current_context()[key] = value
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.add_tag(key, value)
|
||||
|
||||
|
||||
# NOTE: we should probably also remove this - there's no reason for the user to ever
|
||||
# need to manually interact with the current tag set
|
||||
def get_tags() -> Dict[str, Any]:
|
||||
"""
|
||||
Get all tags from the current context. Note, modifying
|
||||
@@ -77,24 +136,88 @@ def get_tags() -> Dict[str, Any]:
|
||||
Returns:
|
||||
Dict of all tags in the current context
|
||||
"""
|
||||
return _get_current_context().copy()
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.collect_tags()
|
||||
return {}
|
||||
|
||||
|
||||
# NOTE: We should probably remove this function - the way to clear scope context
|
||||
# is by entering a new, fresh context, rather than by clearing the tags or other
|
||||
# scope data directly.
|
||||
def clear_tags() -> None:
|
||||
"""Clear all tags in the current context."""
|
||||
_get_current_context().clear()
|
||||
"""Clear all tags in the current context. Does not clear parent tags"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.tags.clear()
|
||||
|
||||
|
||||
def identify_context(distinct_id: str) -> None:
|
||||
"""
|
||||
Identify the current context with a distinct ID, associating all events captured in this or
|
||||
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
|
||||
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
|
||||
fresh context will clear the context-level distinct ID.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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=False):
|
||||
def scoped(fresh=False, capture_exceptions=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()
|
||||
@@ -114,7 +237,7 @@ def scoped(fresh=False):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
with new_context(fresh=fresh):
|
||||
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
POSTHOG_ID_TAG = "posthog_distinct_id"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
import posthog
|
||||
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"{posthog.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']}"
|
||||
)
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
return event
|
||||
@@ -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,250 @@ 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
|
||||
|
||||
@@ -26,6 +26,11 @@ try:
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseUsage,
|
||||
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(
|
||||
@@ -646,3 +704,73 @@ 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": '{"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)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from posthog.scopes 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()
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
@@ -118,22 +117,6 @@ class TestClient(unittest.TestCase):
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_distinct_id(self):
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
@@ -145,22 +128,6 @@ class TestClient(unittest.TestCase):
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation(self):
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
@@ -174,22 +141,6 @@ class TestClient(unittest.TestCase):
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://aloha.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(
|
||||
self,
|
||||
@@ -207,22 +158,6 @@ class TestClient(unittest.TestCase):
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://app.posthog.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_no_exception_given(self):
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
|
||||
+135
-53
@@ -1,7 +1,17 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from posthog.scopes import clear_tags, get_tags, new_context, scoped, tag
|
||||
from posthog.scopes import (
|
||||
clear_tags,
|
||||
get_tags,
|
||||
new_context,
|
||||
scoped,
|
||||
tag,
|
||||
identify_context,
|
||||
set_context_session,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
|
||||
|
||||
class TestScopes(unittest.TestCase):
|
||||
@@ -10,60 +20,64 @@ class TestScopes(unittest.TestCase):
|
||||
clear_tags()
|
||||
|
||||
def test_tag_and_get_tags(self):
|
||||
tag("key1", "value1")
|
||||
tag("key2", 2)
|
||||
with new_context(fresh=True):
|
||||
tag("key1", "value1")
|
||||
tag("key2", 2)
|
||||
|
||||
tags = get_tags()
|
||||
assert tags["key1"] == "value1"
|
||||
assert tags["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
|
||||
tag("key1", "value1")
|
||||
assert get_tags()["key1"] == "value1"
|
||||
|
||||
clear_tags()
|
||||
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")
|
||||
|
||||
def test_new_context_isolation(self):
|
||||
with new_context(fresh=True):
|
||||
tag("level2", "value2")
|
||||
# Set tag in outer context
|
||||
tag("outer", "value")
|
||||
|
||||
with new_context(fresh=True):
|
||||
tag("level3", "value3")
|
||||
assert get_tags() == {"level3": "value3"}
|
||||
# Inner context should start empty
|
||||
assert get_tags() == {}
|
||||
|
||||
# Back to level 2
|
||||
assert get_tags() == {"level2": "value2"}
|
||||
# Set tag in inner context
|
||||
tag("inner", "value")
|
||||
assert get_tags()["inner"] == "value"
|
||||
|
||||
# Back to level 1
|
||||
assert get_tags() == {"level1": "value1"}
|
||||
# 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):
|
||||
@@ -122,17 +136,85 @@ class TestScopes(unittest.TestCase):
|
||||
mock_capture.side_effect = check_context_on_capture
|
||||
|
||||
# Set up outer context
|
||||
tag("outer_context", "outer_value")
|
||||
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
|
||||
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)
|
||||
|
||||
# Outer context should still be intact
|
||||
assert get_tags()["outer_context"] == "outer_value"
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "4.6.1"
|
||||
VERSION = "5.3.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+8
-8
@@ -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 = [
|
||||
@@ -36,7 +36,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 +51,7 @@ dev = [
|
||||
"pydantic",
|
||||
"ruff",
|
||||
"setuptools",
|
||||
"packaging",
|
||||
"packaging",
|
||||
"wheel",
|
||||
"twine",
|
||||
"tomli",
|
||||
@@ -68,10 +67,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 +86,8 @@ packages = [
|
||||
"posthog.ai.anthropic",
|
||||
"posthog.ai.gemini",
|
||||
"posthog.test",
|
||||
"posthog.sentry",
|
||||
"posthog.exception_integrations",
|
||||
"posthog.integrations",
|
||||
]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user