diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index c9e92d54f7..ba7aadb883 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -31,7 +31,6 @@ from openhands.server.services.conversation_service import create_provider_token from openhands.server.shared import config from openhands.server.user_auth import get_access_token from openhands.server.user_auth.user_auth import get_user_auth -from openhands.utils.posthog_tracker import track_user_signup_completed with warnings.catch_warnings(): warnings.simplefilter('ignore') @@ -370,12 +369,6 @@ async def accept_tos(request: Request): logger.info(f'User {user_id} accepted TOS') - # Track user signup completion in PostHog - track_user_signup_completed( - user_id=user_id, - signup_timestamp=user_settings.accepted_tos.isoformat(), - ) - response = JSONResponse( status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url} ) diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index f1c0c5376b..5a8b59e2d7 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -28,7 +28,6 @@ from storage.subscription_access import SubscriptionAccess from openhands.server.user_auth import get_user_id from openhands.utils.http_session import httpx_verify_option -from openhands.utils.posthog_tracker import track_credits_purchased stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -458,20 +457,6 @@ async def success_callback(session_id: str, request: Request): ) session.commit() - # Track credits purchased in PostHog - try: - track_credits_purchased( - user_id=billing_session.user_id, - amount_usd=amount_subtotal / 100, # Convert cents to dollars - credits_added=add_credits, - stripe_session_id=session_id, - ) - except Exception as e: - logger.warning( - f'Failed to track credits purchase: {e}', - extra={'user_id': billing_session.user_id, 'error': str(e)}, - ) - return RedirectResponse( f'{request.base_url}settings/billing?checkout=success', status_code=302 ) diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index 3f2ad87674..958e5cb348 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -42,10 +42,6 @@ from openhands.core.exceptions import ( from openhands.core.logger import LOG_ALL_EVENTS from openhands.core.logger import openhands_logger as logger from openhands.core.schema import AgentState -from openhands.utils.posthog_tracker import ( - track_agent_task_completed, - track_credit_limit_reached, -) from openhands.events import ( EventSource, EventStream, @@ -713,20 +709,6 @@ class AgentController: EventSource.ENVIRONMENT, ) - # Track agent task completion in PostHog - if new_state == AgentState.FINISHED: - try: - # Get app_mode from environment, default to 'oss' - app_mode = os.environ.get('APP_MODE', 'oss') - track_agent_task_completed( - conversation_id=self.id, - user_id=self.user_id, - app_mode=app_mode, - ) - except Exception as e: - # Don't let tracking errors interrupt the agent - self.log('warning', f'Failed to track agent completion: {e}') - # Save state whenever agent state changes to ensure we don't lose state # in case of crashes or unexpected circumstances self.save_state() @@ -905,18 +887,6 @@ class AgentController: self.state_tracker.run_control_flags() except Exception as e: logger.warning('Control flag limits hit') - # Track credit limit reached if it's a budget exception - if 'budget' in str(e).lower() and self.state.budget_flag: - try: - track_credit_limit_reached( - conversation_id=self.id, - user_id=self.user_id, - current_budget=self.state.budget_flag.current_value, - max_budget=self.state.budget_flag.max_value, - ) - except Exception as track_error: - # Don't let tracking errors interrupt the agent - self.log('warning', f'Failed to track credit limit: {track_error}') await self._react_to_exception(e) return diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 1401bb0dcd..a6807a2e2a 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -26,13 +26,11 @@ from openhands.microagent.types import ( ) from openhands.server.dependencies import get_dependencies from openhands.server.shared import server_config -from openhands.server.types import AppMode from openhands.server.user_auth import ( get_access_token, get_provider_tokens, get_user_id, ) -from openhands.utils.posthog_tracker import alias_user_identities app = APIRouter(prefix='/api/user', dependencies=get_dependencies()) @@ -119,14 +117,6 @@ async def get_user( try: user: User = await client.get_user() - - # Alias git provider login with Keycloak user ID in PostHog (SaaS mode only) - if user_id and user.login and server_config.app_mode == AppMode.SAAS: - alias_user_identities( - keycloak_user_id=user_id, - git_login=user.login, - ) - return user except UnknownException as e: diff --git a/openhands/utils/posthog_tracker.py b/openhands/utils/posthog_tracker.py deleted file mode 100644 index c0859eddc7..0000000000 --- a/openhands/utils/posthog_tracker.py +++ /dev/null @@ -1,270 +0,0 @@ -"""PostHog tracking utilities for OpenHands events.""" - -import os - -from openhands.core.logger import openhands_logger as logger - -# Lazy import posthog to avoid import errors in environments where it's not installed -posthog = None - - -def _init_posthog(): - """Initialize PostHog client lazily.""" - global posthog - if posthog is None: - try: - import posthog as ph - - posthog = ph - posthog.api_key = os.environ.get( - 'POSTHOG_CLIENT_KEY', 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA' - ) - posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com') - except ImportError: - logger.warning( - 'PostHog not installed. Analytics tracking will be disabled.' - ) - posthog = None - - -def track_agent_task_completed( - conversation_id: str, - user_id: str | None = None, - app_mode: str | None = None, -) -> None: - """Track when an agent completes a task. - - Args: - conversation_id: The ID of the conversation/session - user_id: The ID of the user (optional, may be None for unauthenticated users) - app_mode: The application mode (saas/oss), optional - """ - _init_posthog() - - if posthog is None: - return - - # Use conversation_id as distinct_id if user_id is not available - # This ensures we can track completions even for anonymous users - distinct_id = user_id if user_id else f'conversation_{conversation_id}' - - try: - posthog.capture( - distinct_id=distinct_id, - event='agent_task_completed', - properties={ - 'conversation_id': conversation_id, - 'user_id': user_id, - 'app_mode': app_mode or 'unknown', - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'agent_task_completed', - 'conversation_id': conversation_id, - 'user_id': user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track agent_task_completed to PostHog: {e}', - extra={ - 'conversation_id': conversation_id, - 'error': str(e), - }, - ) - - -def track_user_signup_completed( - user_id: str, - signup_timestamp: str, -) -> None: - """Track when a user completes signup by accepting TOS. - - Args: - user_id: The ID of the user (Keycloak user ID) - signup_timestamp: ISO format timestamp of when TOS was accepted - """ - _init_posthog() - - if posthog is None: - return - - try: - posthog.capture( - distinct_id=user_id, - event='user_signup_completed', - properties={ - 'user_id': user_id, - 'signup_timestamp': signup_timestamp, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'user_signup_completed', - 'user_id': user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track user_signup_completed to PostHog: {e}', - extra={ - 'user_id': user_id, - 'error': str(e), - }, - ) - - -def track_credit_limit_reached( - conversation_id: str, - user_id: str | None = None, - current_budget: float = 0.0, - max_budget: float = 0.0, -) -> None: - """Track when a user reaches their credit limit during a conversation. - - Args: - conversation_id: The ID of the conversation/session - user_id: The ID of the user (optional, may be None for unauthenticated users) - current_budget: The current budget spent - max_budget: The maximum budget allowed - """ - _init_posthog() - - if posthog is None: - return - - distinct_id = user_id if user_id else f'conversation_{conversation_id}' - - try: - posthog.capture( - distinct_id=distinct_id, - event='credit_limit_reached', - properties={ - 'conversation_id': conversation_id, - 'user_id': user_id, - 'current_budget': current_budget, - 'max_budget': max_budget, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'credit_limit_reached', - 'conversation_id': conversation_id, - 'user_id': user_id, - 'current_budget': current_budget, - 'max_budget': max_budget, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track credit_limit_reached to PostHog: {e}', - extra={ - 'conversation_id': conversation_id, - 'error': str(e), - }, - ) - - -def track_credits_purchased( - user_id: str, - amount_usd: float, - credits_added: float, - stripe_session_id: str, -) -> None: - """Track when a user successfully purchases credits. - - Args: - user_id: The ID of the user (Keycloak user ID) - amount_usd: The amount paid in USD (cents converted to dollars) - credits_added: The number of credits added to the user's account - stripe_session_id: The Stripe checkout session ID - """ - _init_posthog() - - if posthog is None: - return - - try: - posthog.capture( - distinct_id=user_id, - event='credits_purchased', - properties={ - 'user_id': user_id, - 'amount_usd': amount_usd, - 'credits_added': credits_added, - 'stripe_session_id': stripe_session_id, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'credits_purchased', - 'user_id': user_id, - 'amount_usd': amount_usd, - 'credits_added': credits_added, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track credits_purchased to PostHog: {e}', - extra={ - 'user_id': user_id, - 'error': str(e), - }, - ) - - -def alias_user_identities( - keycloak_user_id: str, - git_login: str, -) -> None: - """Alias a user's Keycloak ID with their git provider login for unified tracking. - - This allows PostHog to link events tracked from the frontend (using git provider login) - with events tracked from the backend (using Keycloak user ID). - - PostHog Python alias syntax: alias(previous_id, distinct_id) - - previous_id: The old/previous distinct ID that will be merged - - distinct_id: The new/canonical distinct ID to merge into - - For our use case: - - Git provider login is the previous_id (first used in frontend, before backend auth) - - Keycloak user ID is the distinct_id (canonical backend ID) - - Result: All events with git login will be merged into Keycloak user ID - - Args: - keycloak_user_id: The Keycloak user ID (canonical distinct_id) - git_login: The git provider username (GitHub/GitLab/Bitbucket) to merge - - Reference: - https://github.com/PostHog/posthog-python/blob/master/posthog/client.py - """ - _init_posthog() - - if posthog is None: - return - - try: - # Merge git provider login into Keycloak user ID - # posthog.alias(previous_id, distinct_id) - official Python SDK signature - posthog.alias(git_login, keycloak_user_id) - logger.debug( - 'posthog_alias', - extra={ - 'previous_id': git_login, - 'distinct_id': keycloak_user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to alias user identities in PostHog: {e}', - extra={ - 'keycloak_user_id': keycloak_user_id, - 'git_login': git_login, - 'error': str(e), - }, - ) diff --git a/tests/unit/controller/test_agent_controller_posthog.py b/tests/unit/controller/test_agent_controller_posthog.py deleted file mode 100644 index 630c18e3aa..0000000000 --- a/tests/unit/controller/test_agent_controller_posthog.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Integration tests for PostHog tracking in AgentController.""" - -import asyncio -from unittest.mock import MagicMock, patch - -import pytest - -from openhands.controller.agent import Agent -from openhands.controller.agent_controller import AgentController -from openhands.core.config import OpenHandsConfig -from openhands.core.config.agent_config import AgentConfig -from openhands.core.config.llm_config import LLMConfig -from openhands.core.schema import AgentState -from openhands.events import EventSource, EventStream -from openhands.events.action.message import SystemMessageAction -from openhands.llm.llm_registry import LLMRegistry -from openhands.server.services.conversation_stats import ConversationStats -from openhands.storage.memory import InMemoryFileStore - - -@pytest.fixture(scope='function') -def event_loop(): - """Create event loop for async tests.""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest.fixture -def mock_agent_with_stats(): - """Create a mock agent with properly connected LLM registry and conversation stats.""" - import uuid - - # Create LLM registry - config = OpenHandsConfig() - llm_registry = LLMRegistry(config=config) - - # Create conversation stats - file_store = InMemoryFileStore({}) - conversation_id = f'test-conversation-{uuid.uuid4()}' - conversation_stats = ConversationStats( - file_store=file_store, conversation_id=conversation_id, user_id='test-user' - ) - - # Connect registry to stats - llm_registry.subscribe(conversation_stats.register_llm) - - # Create mock agent - agent = MagicMock(spec=Agent) - agent_config = MagicMock(spec=AgentConfig) - llm_config = LLMConfig( - model='gpt-4o', - api_key='test_key', - num_retries=2, - retry_min_wait=1, - retry_max_wait=2, - ) - agent_config.disabled_microagents = [] - agent_config.enable_mcp = True - agent_config.enable_stuck_detection = True - llm_registry.service_to_llm.clear() - mock_llm = llm_registry.get_llm('agent_llm', llm_config) - agent.llm = mock_llm - agent.name = 'test-agent' - agent.sandbox_plugins = [] - agent.config = agent_config - agent.llm_registry = llm_registry - agent.prompt_manager = MagicMock() - - # Add a proper system message mock - system_message = SystemMessageAction( - content='Test system message', tools=['test_tool'] - ) - system_message._source = EventSource.AGENT - system_message._id = -1 # Set invalid ID to avoid the ID check - agent.get_system_message.return_value = system_message - - return agent, conversation_stats, llm_registry - - -@pytest.fixture -def mock_event_stream(): - """Create a mock event stream.""" - mock = MagicMock( - spec=EventStream, - event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})), - ) - mock.get_latest_event_id.return_value = 0 - return mock - - -@pytest.mark.asyncio -async def test_agent_finish_triggers_posthog_tracking( - mock_agent_with_stats, mock_event_stream -): - """Test that setting agent state to FINISHED triggers PostHog tracking.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-123', - user_id='test-user-456', - confirmation_mode=False, - headless_mode=True, - ) - - with ( - patch('openhands.utils.posthog_tracker.posthog') as mock_posthog, - patch('os.environ.get') as mock_env_get, - ): - # Setup mocks - mock_posthog.capture = MagicMock() - mock_env_get.return_value = 'saas' - - # Initialize posthog in the tracker module - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Set agent state to FINISHED - await controller.set_agent_state_to(AgentState.FINISHED) - - # Verify PostHog tracking was called - mock_posthog.capture.assert_called_once() - call_args = mock_posthog.capture.call_args - - assert call_args[1]['distinct_id'] == 'test-user-456' - assert call_args[1]['event'] == 'agent_task_completed' - assert 'conversation_id' in call_args[1]['properties'] - assert call_args[1]['properties']['user_id'] == 'test-user-456' - assert call_args[1]['properties']['app_mode'] == 'saas' - - await controller.close() - - -@pytest.mark.asyncio -async def test_agent_finish_without_user_id(mock_agent_with_stats, mock_event_stream): - """Test tracking when user_id is None.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-789', - user_id=None, - confirmation_mode=False, - headless_mode=True, - ) - - with ( - patch('openhands.utils.posthog_tracker.posthog') as mock_posthog, - patch('os.environ.get') as mock_env_get, - ): - mock_posthog.capture = MagicMock() - mock_env_get.return_value = 'oss' - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - await controller.set_agent_state_to(AgentState.FINISHED) - - mock_posthog.capture.assert_called_once() - call_args = mock_posthog.capture.call_args - - # When user_id is None, distinct_id should be conversation_id - assert call_args[1]['distinct_id'].startswith('conversation_') - assert call_args[1]['properties']['user_id'] is None - - await controller.close() - - -@pytest.mark.asyncio -async def test_other_states_dont_trigger_tracking( - mock_agent_with_stats, mock_event_stream -): - """Test that non-FINISHED states don't trigger tracking.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-999', - confirmation_mode=False, - headless_mode=True, - ) - - with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog: - mock_posthog.capture = MagicMock() - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Try different states - await controller.set_agent_state_to(AgentState.RUNNING) - await controller.set_agent_state_to(AgentState.PAUSED) - await controller.set_agent_state_to(AgentState.STOPPED) - - # PostHog should not be called for non-FINISHED states - mock_posthog.capture.assert_not_called() - - await controller.close() - - -@pytest.mark.asyncio -async def test_tracking_error_doesnt_break_agent( - mock_agent_with_stats, mock_event_stream -): - """Test that tracking errors don't interrupt agent operation.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-error', - confirmation_mode=False, - headless_mode=True, - ) - - with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog: - mock_posthog.capture = MagicMock(side_effect=Exception('PostHog error')) - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Should not raise an exception - await controller.set_agent_state_to(AgentState.FINISHED) - - # Agent state should still be FINISHED despite tracking error - assert controller.state.agent_state == AgentState.FINISHED - - await controller.close() diff --git a/tests/unit/utils/test_posthog_tracker.py b/tests/unit/utils/test_posthog_tracker.py deleted file mode 100644 index cec0eff0cc..0000000000 --- a/tests/unit/utils/test_posthog_tracker.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Unit tests for PostHog tracking utilities.""" - -from unittest.mock import MagicMock, patch - -import pytest - -from openhands.utils.posthog_tracker import ( - alias_user_identities, - track_agent_task_completed, - track_credit_limit_reached, - track_credits_purchased, - track_user_signup_completed, -) - - -@pytest.fixture -def mock_posthog(): - """Mock the posthog module.""" - with patch('openhands.utils.posthog_tracker.posthog') as mock_ph: - mock_ph.capture = MagicMock() - yield mock_ph - - -def test_track_agent_task_completed_with_user_id(mock_posthog): - """Test tracking agent task completion with user ID.""" - # Initialize posthog manually in the test - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-123', - user_id='user-456', - app_mode='saas', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-456', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-123', - 'user_id': 'user-456', - 'app_mode': 'saas', - }, - ) - - -def test_track_agent_task_completed_without_user_id(mock_posthog): - """Test tracking agent task completion without user ID (anonymous).""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-789', - user_id=None, - app_mode='oss', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='conversation_test-conversation-789', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-789', - 'user_id': None, - 'app_mode': 'oss', - }, - ) - - -def test_track_agent_task_completed_default_app_mode(mock_posthog): - """Test tracking with default app_mode.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-999', - user_id='user-111', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-111', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-999', - 'user_id': 'user-111', - 'app_mode': 'unknown', - }, - ) - - -def test_track_agent_task_completed_handles_errors(mock_posthog): - """Test that tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_agent_task_completed( - conversation_id='test-conversation-error', - user_id='user-error', - app_mode='saas', - ) - - -def test_track_agent_task_completed_when_posthog_not_installed(): - """Test tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_agent_task_completed( - conversation_id='test-conversation-no-ph', - user_id='user-no-ph', - app_mode='oss', - ) - - -def test_track_user_signup_completed(mock_posthog): - """Test tracking user signup completion.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_user_signup_completed( - user_id='test-user-123', - signup_timestamp='2025-01-15T10:30:00Z', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='test-user-123', - event='user_signup_completed', - properties={ - 'user_id': 'test-user-123', - 'signup_timestamp': '2025-01-15T10:30:00Z', - }, - ) - - -def test_track_user_signup_completed_handles_errors(mock_posthog): - """Test that user signup tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_user_signup_completed( - user_id='test-user-error', - signup_timestamp='2025-01-15T12:00:00Z', - ) - - -def test_track_user_signup_completed_when_posthog_not_installed(): - """Test user signup tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_user_signup_completed( - user_id='test-user-no-ph', - signup_timestamp='2025-01-15T13:00:00Z', - ) - - -def test_track_credit_limit_reached_with_user_id(mock_posthog): - """Test tracking credit limit reached with user ID.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credit_limit_reached( - conversation_id='test-conversation-456', - user_id='user-789', - current_budget=10.50, - max_budget=10.00, - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-789', - event='credit_limit_reached', - properties={ - 'conversation_id': 'test-conversation-456', - 'user_id': 'user-789', - 'current_budget': 10.50, - 'max_budget': 10.00, - }, - ) - - -def test_track_credit_limit_reached_without_user_id(mock_posthog): - """Test tracking credit limit reached without user ID (anonymous).""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credit_limit_reached( - conversation_id='test-conversation-999', - user_id=None, - current_budget=5.25, - max_budget=5.00, - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='conversation_test-conversation-999', - event='credit_limit_reached', - properties={ - 'conversation_id': 'test-conversation-999', - 'user_id': None, - 'current_budget': 5.25, - 'max_budget': 5.00, - }, - ) - - -def test_track_credit_limit_reached_handles_errors(mock_posthog): - """Test that credit limit tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_credit_limit_reached( - conversation_id='test-conversation-error', - user_id='user-error', - current_budget=15.00, - max_budget=10.00, - ) - - -def test_track_credit_limit_reached_when_posthog_not_installed(): - """Test credit limit tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_credit_limit_reached( - conversation_id='test-conversation-no-ph', - user_id='user-no-ph', - current_budget=8.00, - max_budget=5.00, - ) - - -def test_track_credits_purchased(mock_posthog): - """Test tracking credits purchased.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credits_purchased( - user_id='test-user-999', - amount_usd=50.00, - credits_added=50.00, - stripe_session_id='cs_test_abc123', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='test-user-999', - event='credits_purchased', - properties={ - 'user_id': 'test-user-999', - 'amount_usd': 50.00, - 'credits_added': 50.00, - 'stripe_session_id': 'cs_test_abc123', - }, - ) - - -def test_track_credits_purchased_handles_errors(mock_posthog): - """Test that credits purchased tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_credits_purchased( - user_id='test-user-error', - amount_usd=100.00, - credits_added=100.00, - stripe_session_id='cs_test_error', - ) - - -def test_track_credits_purchased_when_posthog_not_installed(): - """Test credits purchased tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_credits_purchased( - user_id='test-user-no-ph', - amount_usd=25.00, - credits_added=25.00, - stripe_session_id='cs_test_no_ph', - ) - - -def test_alias_user_identities(mock_posthog): - """Test aliasing user identities. - - Verifies that posthog.alias(previous_id, distinct_id) is called correctly - where git_login is the previous_id and keycloak_user_id is the distinct_id. - """ - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.alias = MagicMock() - - alias_user_identities( - keycloak_user_id='keycloak-123', - git_login='git-user', - ) - - # Verify: posthog.alias(previous_id='git-user', distinct_id='keycloak-123') - mock_posthog.alias.assert_called_once_with('git-user', 'keycloak-123') - - -def test_alias_user_identities_handles_errors(mock_posthog): - """Test that aliasing errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.alias = MagicMock(side_effect=Exception('PostHog API error')) - - # Should not raise an exception - alias_user_identities( - keycloak_user_id='keycloak-error', - git_login='git-error', - ) - - -def test_alias_user_identities_when_posthog_not_installed(): - """Test aliasing when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - alias_user_identities( - keycloak_user_id='keycloak-no-ph', - git_login='git-no-ph', - )