Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .sampo/changesets/prompts-capture-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Add `capture_errors` option to `Prompts` that reports prompt fetch failures to PostHog error tracking via `capture_exception()` when enabled.
21 changes: 21 additions & 0 deletions posthog/ai/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ class Prompts:
host='https://us.posthog.com',
)

# With error tracking: prompt fetch failures are reported to PostHog
prompts = Prompts(posthog, capture_errors=True)

# Fetch with caching and fallback
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')

Expand All @@ -116,6 +119,7 @@ def __init__(
project_api_key: Optional[str] = None,
host: Optional[str] = None,
default_cache_ttl_seconds: Optional[int] = None,
capture_errors: bool = False,
):
"""
Initialize Prompts.
Expand All @@ -126,12 +130,16 @@ def __init__(
project_api_key: Direct project API key (optional if posthog provided)
host: PostHog host (defaults to app endpoint)
default_cache_ttl_seconds: Default cache TTL (defaults to 300)
capture_errors: If True and a PostHog client is provided, prompt fetch
failures are reported to PostHog error tracking via capture_exception().
"""
self._default_cache_ttl_seconds = (
default_cache_ttl_seconds or DEFAULT_CACHE_TTL_SECONDS
)
self._cache: Dict[PromptCacheKey, CachedPrompt] = {}
self._has_warned_deprecation = False
self._client = posthog
self._capture_errors = capture_errors

if posthog is not None:
self._personal_api_key = getattr(posthog, "personal_api_key", None) or ""
Expand Down Expand Up @@ -296,6 +304,8 @@ def _get_internal(
)

except Exception as error:
self._maybe_capture_error(error)

prompt_reference = _prompt_reference(name, version)
# Return stale cache (with warning)
if cached is not None:
Expand Down Expand Up @@ -363,6 +373,17 @@ def clear_cache(
for key in keys_to_clear:
self._cache.pop(key, None)

def _maybe_capture_error(self, error: Exception) -> None:
"""Report a prompt fetch error to PostHog error tracking if enabled."""
if not self._capture_errors or self._client is None:
return
if not hasattr(self._client, "capture_exception"):
return
try:
self._client.capture_exception(error)
except Exception:
log.debug("[PostHog Prompts] Failed to capture exception to error tracking")

def _fetch_prompt_from_api(
self, name: str, version: Optional[int] = None
) -> Dict[str, Any]:
Expand Down
115 changes: 115 additions & 0 deletions posthog/test/ai/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,121 @@ def test_handle_variables_with_dots(self):
self.assertEqual(result, "Company: Acme")


class TestPromptsCaptureErrors(TestPrompts):
"""Tests for the capture_errors option."""

@patch("posthog.ai.prompts._get_session")
def test_capture_exception_called_on_fetch_failure_with_fallback(
self, mock_get_session
):
"""Should call capture_exception on fetch failure when capture_errors=True."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")

posthog = self.create_mock_posthog()
prompts = Prompts(posthog, capture_errors=True)

result = prompts.get("test-prompt", fallback="fallback prompt")

self.assertEqual(result, "fallback prompt")
posthog.capture_exception.assert_called_once()
captured_exc = posthog.capture_exception.call_args[0][0]
self.assertIn("Network error", str(captured_exc))

@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_capture_exception_called_on_fetch_failure_with_stale_cache(
self, mock_time, mock_get_session
):
"""Should call capture_exception when falling back to stale cache."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
Exception("Network error"),
]
mock_time.return_value = 1000.0

posthog = self.create_mock_posthog()
prompts = Prompts(posthog, capture_errors=True)

# First call populates cache
prompts.get("test-prompt", cache_ttl_seconds=60)

# Expire cache
mock_time.return_value = 1061.0

# Second call falls back to stale cache
result = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result, self.mock_prompt_response["prompt"])
posthog.capture_exception.assert_called_once()

@patch("posthog.ai.prompts._get_session")
def test_capture_exception_called_when_error_is_raised(self, mock_get_session):
"""Should call capture_exception even when the error is re-raised (no fallback, no cache)."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")

posthog = self.create_mock_posthog()
prompts = Prompts(posthog, capture_errors=True)

with self.assertRaises(Exception):
prompts.get("test-prompt")

posthog.capture_exception.assert_called_once()

@patch("posthog.ai.prompts._get_session")
def test_no_capture_exception_when_capture_errors_is_false(self, mock_get_session):
"""Should NOT call capture_exception when capture_errors=False (default)."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")

posthog = self.create_mock_posthog()
prompts = Prompts(posthog)

prompts.get("test-prompt", fallback="fallback prompt")

posthog.capture_exception.assert_not_called()

@patch("posthog.ai.prompts._get_session")
def test_no_capture_exception_without_client(self, mock_get_session):
"""Should not error when capture_errors=True but no client provided."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")

prompts = Prompts(personal_api_key="phx_test_key", capture_errors=True)

result = prompts.get("test-prompt", fallback="fallback prompt")

self.assertEqual(result, "fallback prompt")
Comment thread
andrewm4894 marked this conversation as resolved.

@patch("posthog.ai.prompts._get_session")
def test_no_capture_exception_on_successful_fetch(self, mock_get_session):
"""Should NOT call capture_exception on successful fetch."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)

posthog = self.create_mock_posthog()
prompts = Prompts(posthog, capture_errors=True)

prompts.get("test-prompt")

posthog.capture_exception.assert_not_called()

@patch("posthog.ai.prompts._get_session")
def test_capture_exception_failure_does_not_affect_fallback(self, mock_get_session):
"""If capture_exception itself throws, the fallback should still be returned."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")

posthog = self.create_mock_posthog()
posthog.capture_exception.side_effect = Exception("capture failed")
prompts = Prompts(posthog, capture_errors=True)

result = prompts.get("test-prompt", fallback="fallback prompt")

self.assertEqual(result, "fallback prompt")

Comment thread
andrewm4894 marked this conversation as resolved.

class TestPromptsClearCache(TestPrompts):
"""Tests for the Prompts.clear_cache() method."""

Expand Down
Loading