Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .sampo/changesets/langchain-async-client-failed-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
pypi/posthog: patch
---

`AsyncPosthog` no longer breaks two exception paths that read `enable_exception_autocapture`, a setting `AsyncPosthog` does not have:

- The LangChain callback handler no longer loses a failed run's `$ai_generation`, `$ai_span` or `$ai_trace` event. LangChain swallowed the error, so the event was never sent. The failed event is now sent without an `$exception`.
- `new_context(client=<AsyncPosthog>)` no longer raises `AttributeError`. Without `capture_exceptions`, it now leaves exceptions uncaptured; pass `capture_exceptions=True` to capture them.
6 changes: 3 additions & 3 deletions posthog/ai/langchain/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,9 +593,9 @@ def _capture_exception_and_update_properties(
self, exception: BaseException, event_properties: Dict[str, Any]
) -> Dict[str, Any]:
# An `$exception` event carries the message and stack, which echo prompts and output.
if not self._ph_client.enable_exception_autocapture or _privacy_mode_enabled(
self._ph_client, self._privacy_mode
):
if not getattr(
self._ph_client, "enable_exception_autocapture", False
) or _privacy_mode_enabled(self._ph_client, self._privacy_mode):
return event_properties

exception_id = self._ph_client.capture_exception(
Expand Down
2 changes: 1 addition & 1 deletion posthog/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def _get_active_span_context() -> Dict[str, Optional[str]]:

def _default_capture_exceptions(client: Optional["Client"] = None) -> bool:
if client is not None:
return client.enable_exception_autocapture
return getattr(client, "enable_exception_autocapture", False)

from . import default_client, enable_exception_autocapture

Expand Down
20 changes: 20 additions & 0 deletions posthog/test/ai/langchain/test_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2808,6 +2808,26 @@ def failing_span(_):
assert span_props["$ai_error"] == "ValueError: test error"


def test_exception_autocapture_absent_on_client_still_sends_the_span():
"""A client without the autocapture setting, such as AsyncPosthog, still gets the failed span."""
mock_client = MagicMock(spec=["capture", "capture_exception", "privacy_mode"])
mock_client.privacy_mode = False

def failing_span(_):
raise ValueError("test error")

callbacks = [CallbackHandler(mock_client)]
with pytest.raises(ValueError):
RunnableLambda(failing_span).invoke({}, config={"callbacks": callbacks})

assert mock_client.capture_exception.call_count == 0
assert mock_client.capture.call_count == 1
span_props = mock_client.capture.call_args[1]["properties"]
assert span_props["$ai_is_error"] is True
assert span_props["$ai_error"] == "ValueError: test error"
assert "$exception_event_id" not in span_props


@pytest.mark.parametrize(
"autocapture, client_privacy_mode, handler_privacy_mode, expected_error",
[
Expand Down
16 changes: 16 additions & 0 deletions posthog/test/test_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from unittest.mock import MagicMock, patch

import posthog
from posthog.async_client import AsyncPosthog
from posthog.client import Client
from posthog.contexts import (
get_tags,
Expand Down Expand Up @@ -227,6 +228,21 @@ def test_new_context_defaults_to_custom_client_exception_autocapture_disabled(se

client.capture_exception.assert_not_called()

def test_new_context_with_async_client_defaults_to_no_exception_capture(self):
client = AsyncPosthog("phc_test", disabled=True)
client.capture_exception = MagicMock()

with self.assertRaises(RuntimeError):
with new_context(client=client):
raise RuntimeError("Context exception")
client.capture_exception.assert_not_called()

test_exception = RuntimeError("Context exception")
with self.assertRaises(RuntimeError):
with new_context(client=client, capture_exceptions=True):
raise test_exception
client.capture_exception.assert_called_once_with(test_exception)

def test_identify_context(self):
with new_context(fresh=True):
# Initially no distinct ID
Expand Down
Loading