mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
80 Commits
fix-llm-pr
...
APP-1167/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb5b985e0 | ||
|
|
28fcfaae25 | ||
|
|
57bcc69f64 | ||
|
|
1406937961 | ||
|
|
5d07387b4e | ||
|
|
d1637cbc3c | ||
|
|
0eab81f89b | ||
|
|
bd4a094eaf | ||
|
|
3ce4f629d6 | ||
|
|
78e8a6c986 | ||
|
|
bacbbad32a | ||
|
|
d077b48a19 | ||
|
|
5e96574730 | ||
|
|
589d12b5bd | ||
|
|
cf48f4c91b | ||
|
|
63b93b6dc3 | ||
|
|
6f0ee09629 | ||
|
|
ed461b3ec1 | ||
|
|
155da8dfd1 | ||
|
|
29aa4f26d8 | ||
|
|
5409004c8d | ||
|
|
918f366a76 | ||
|
|
9b02f06400 | ||
|
|
331c513042 | ||
|
|
61af4662f1 | ||
|
|
4b77beaaa5 | ||
|
|
c0f08a33c3 | ||
|
|
ddf2713483 | ||
|
|
d39de5998a | ||
|
|
782817c1c1 | ||
|
|
463777581e | ||
|
|
5c42ee7a6c | ||
|
|
aa9aed7016 | ||
|
|
894d0eb439 | ||
|
|
7c8e0b1eec | ||
|
|
1a5d024c47 | ||
|
|
0738e75dcf | ||
|
|
54766b4aeb | ||
|
|
4f65eae750 | ||
|
|
fdb6369476 | ||
|
|
77d672c68d | ||
|
|
21ac2a77ff | ||
|
|
b1c61c1534 | ||
|
|
f450c407b5 | ||
|
|
500ed84d01 | ||
|
|
999c18e072 | ||
|
|
a2e16d4819 | ||
|
|
2f5147836f | ||
|
|
ed0f104645 | ||
|
|
192cfd5d91 | ||
|
|
8c2d3d1b9d | ||
|
|
d3a274bbfa | ||
|
|
b9107ea3ad | ||
|
|
8d66a58943 | ||
|
|
7864e9a8e3 | ||
|
|
f0b7e36bab | ||
|
|
53e87a7c27 | ||
|
|
926ebf6906 | ||
|
|
7f25e9cad8 | ||
|
|
2689768c95 | ||
|
|
2f467558ed | ||
|
|
b42ab23e1f | ||
|
|
3c5c307930 | ||
|
|
d62d32af74 | ||
|
|
f6201dd0de | ||
|
|
ed4e2efd50 | ||
|
|
9b00b66efd | ||
|
|
d78d9c4d99 | ||
|
|
04577c6448 | ||
|
|
f8a0533f91 | ||
|
|
893a0db754 | ||
|
|
1f2bef34e3 | ||
|
|
62ed9e47cf | ||
|
|
7b87237d3e | ||
|
|
af74146f80 | ||
|
|
7760aba8e7 | ||
|
|
8550c91d0d | ||
|
|
d8db62b85b | ||
|
|
677f9bdd81 | ||
|
|
9b1ce6d330 |
12
enterprise/poetry.lock
generated
12
enterprise/poetry.lock
generated
@@ -7402,14 +7402,14 @@ tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "p
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "6.9.3"
|
||||
version = "7.9.12"
|
||||
description = "Integrate PostHog into any python application."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "posthog-6.9.3-py3-none-any.whl", hash = "sha256:c71e9cb7ac4ef13eb604f04c3161edd10b1d08a32499edd54437ba5eab591c58"},
|
||||
{file = "posthog-6.9.3.tar.gz", hash = "sha256:7d201774ea9eba156f1de46d34313e30b2384d523900fe8e425accc92486cc34"},
|
||||
{file = "posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0"},
|
||||
{file = "posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7423,7 +7423,7 @@ typing-extensions = ">=4.2.0"
|
||||
[package.extras]
|
||||
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
|
||||
langchain = ["langchain (>=0.2.0)"]
|
||||
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
test = ["anthropic (>=0.72)", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=1.0)", "langchain-community (>=0.4)", "langchain-core (>=1.0)", "langchain-openai (>=1.0)", "langgraph (>=1.0)", "mock (>=2.0.0)", "openai (>=2.0)", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
@@ -15264,4 +15264,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"
|
||||
content-hash = "55a09a40217bbbc876e5864b78c941d86a261e4111bce7e4495c1dd75df43fd7"
|
||||
|
||||
@@ -36,7 +36,7 @@ resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
posthog = "^6.0.0"
|
||||
posthog = "^7.0.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
|
||||
@@ -12,6 +12,9 @@ import socketio # noqa: E402
|
||||
from fastapi import Request, status # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
from server.app_lifespan.saas_app_lifespan_service import ( # noqa: E402
|
||||
SaasAppLifespanService,
|
||||
)
|
||||
from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402
|
||||
from server.auth.constants import ( # noqa: E402
|
||||
BITBUCKET_DATA_CENTER_HOST,
|
||||
@@ -23,7 +26,10 @@ from server.auth.constants import ( # noqa: E402
|
||||
)
|
||||
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
|
||||
from server.logger import logger # noqa: E402
|
||||
from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.middleware import ( # noqa: E402
|
||||
PostHogSessionMiddleware,
|
||||
SetAuthCookieMiddleware,
|
||||
)
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
@@ -38,6 +44,7 @@ from server.routes.integration.linear import linear_integration_router # noqa:
|
||||
from server.routes.integration.slack import slack_router # noqa: E402
|
||||
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
|
||||
from server.routes.oauth_device import oauth_device_router # noqa: E402
|
||||
from server.routes.onboarding import onboarding_router # noqa: E402
|
||||
from server.routes.org_invitations import ( # noqa: E402
|
||||
accept_router as invitation_accept_router,
|
||||
)
|
||||
@@ -65,6 +72,14 @@ from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
override_llm_models_dependency,
|
||||
)
|
||||
|
||||
# Patch global config with SaaS lifespan BEFORE openhands.server.app is imported.
|
||||
# app.py reads get_app_lifespan_service() at module level (line ~69), so this
|
||||
# must execute first.
|
||||
from openhands.app_server.config import get_global_config # noqa: E402
|
||||
|
||||
_config = get_global_config()
|
||||
_config.lifespan = SaasAppLifespanService()
|
||||
|
||||
from openhands.server.app import app as base_app # noqa: E402
|
||||
from openhands.server.listen_socket import sio # noqa: E402
|
||||
from openhands.server.middleware import ( # noqa: E402
|
||||
@@ -148,6 +163,7 @@ if BITBUCKET_DATA_CENTER_HOST:
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
base_app.include_router(onboarding_router) # Add route for onboarding submission
|
||||
base_app.include_router(
|
||||
event_webhook_router
|
||||
) # Add routes for Events in nested runtimes
|
||||
@@ -161,6 +177,7 @@ base_app.add_middleware(
|
||||
allow_headers=['*'],
|
||||
)
|
||||
base_app.add_middleware(CacheControlMiddleware)
|
||||
base_app.middleware('http')(PostHogSessionMiddleware())
|
||||
base_app.middleware('http')(SetAuthCookieMiddleware())
|
||||
|
||||
base_app.mount('/', SPAStaticFiles(directory=directory, html=True), name='dist')
|
||||
|
||||
0
enterprise/server/app_lifespan/__init__.py
Normal file
0
enterprise/server/app_lifespan/__init__.py
Normal file
46
enterprise/server/app_lifespan/saas_app_lifespan_service.py
Normal file
46
enterprise/server/app_lifespan/saas_app_lifespan_service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""SaaS-specific application lifespan service.
|
||||
|
||||
Initializes PostHog analytics on startup and flushes buffered events on
|
||||
clean shutdown so no events are lost when the server exits gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
|
||||
from openhands.analytics import get_analytics_service, init_analytics_service
|
||||
from openhands.app_server.app_lifespan.app_lifespan_service import AppLifespanService
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class SaasAppLifespanService(AppLifespanService):
|
||||
"""Lifespan service for the SaaS server.
|
||||
|
||||
On enter: initialises the PostHog analytics singleton from environment vars.
|
||||
On exit: calls ``analytics_service.shutdown()`` to flush any buffered events.
|
||||
"""
|
||||
|
||||
async def __aenter__(self):
|
||||
api_key = os.environ.get('POSTHOG_CLIENT_KEY', '')
|
||||
host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
|
||||
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', '')
|
||||
app_mode = AppMode.SAAS if 'saas' in config_cls.lower() else AppMode.OPENHANDS
|
||||
|
||||
init_analytics_service(
|
||||
api_key=api_key,
|
||||
host=host,
|
||||
app_mode=app_mode,
|
||||
is_feature_env=IS_FEATURE_ENV,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
try:
|
||||
svc = get_analytics_service()
|
||||
if svc is not None:
|
||||
svc.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Error shutting down analytics service')
|
||||
@@ -198,3 +198,19 @@ class SetAuthCookieMiddleware:
|
||||
await token_manager.logout(user_auth.refresh_token.get_secret_value())
|
||||
except Exception:
|
||||
logger.debug('Error logging out')
|
||||
|
||||
|
||||
class PostHogSessionMiddleware:
|
||||
"""Extract the PostHog session ID from the incoming request header.
|
||||
|
||||
Stores the value on ``request.state.posthog_session_id`` so that
|
||||
subsequent event-capture call sites can link server-side events to the
|
||||
corresponding frontend session-replay recording.
|
||||
|
||||
When the ``X-POSTHOG-SESSION-ID`` header is absent the attribute is set
|
||||
to ``None`` — never raises, never blocks.
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
request.state.posthog_session_id = request.headers.get('X-POSTHOG-SESSION-ID')
|
||||
return await call_next(request)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
import posthog
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
@@ -46,6 +45,7 @@ from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, TokenResponse
|
||||
@@ -123,6 +123,35 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
|
||||
return state, None, None
|
||||
|
||||
|
||||
async def _get_user_orgs_with_data(user_id: str, org_member_ids: list) -> list:
|
||||
"""Load Org objects for a user's org memberships.
|
||||
|
||||
Uses org_member.org_id list to batch-load Org objects, avoiding N+1
|
||||
by loading all orgs a user belongs to in one query via OrgStore.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID string
|
||||
org_member_ids: List of org_id UUIDs from user.org_members
|
||||
|
||||
Returns:
|
||||
List of Org objects the user belongs to
|
||||
"""
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
orgs = []
|
||||
for org_id in org_member_ids:
|
||||
try:
|
||||
org = await OrgStore.get_org_by_id(org_id)
|
||||
if org:
|
||||
orgs.append(org)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'auth:_get_user_orgs_with_data:failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return orgs
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -201,9 +230,11 @@ async def keycloak_callback(
|
||||
email = user_info.email
|
||||
user_id = user_info.sub
|
||||
user_info_dict = user_info.model_dump(exclude_none=True)
|
||||
is_new_user = False
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
user = await UserStore.create_user(user_id, user_info_dict)
|
||||
is_new_user = True
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info_dict)
|
||||
@@ -218,6 +249,36 @@ async def keycloak_callback(
|
||||
|
||||
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
|
||||
|
||||
# Analytics: user signed up event (fires only for new users, once per user)
|
||||
if is_new_user:
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
consented = (
|
||||
user.user_consents_to_analytics is True
|
||||
) # None = undecided = not consented
|
||||
org_id_str = str(user.current_org_id) if user.current_org_id else None
|
||||
|
||||
analytics.track_user_signed_up(
|
||||
distinct_id=user_id,
|
||||
idp=user_info.get('identity_provider', 'keycloak'),
|
||||
email_domain=email.split('@')[1]
|
||||
if email and '@' in email
|
||||
else None,
|
||||
invitation_source='invitation'
|
||||
if invitation_token
|
||||
else 'self_signup',
|
||||
org_id=org_id_str,
|
||||
consented=consented,
|
||||
)
|
||||
analytics.set_person_properties(
|
||||
distinct_id=user_id,
|
||||
properties={'signed_up_at': datetime.now(timezone.utc).isoformat()},
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:user_signed_up:failed')
|
||||
|
||||
# reCAPTCHA verification with Account Defender
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
if not recaptcha_token:
|
||||
@@ -334,36 +395,68 @@ async def keycloak_callback(
|
||||
f'keycloakAccessToken: {keycloak_access_token}, keycloakUserId: {user_id}'
|
||||
)
|
||||
|
||||
# adding in posthog tracking
|
||||
# Server-side identity — full person and org group tracking via AnalyticsService
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
consented = (
|
||||
user.user_consents_to_analytics is True
|
||||
) # None = undecided = not consented
|
||||
org_id_str = str(user.current_org_id) if user.current_org_id else None
|
||||
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
# Load current org for identify_user
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
try:
|
||||
posthog.set(
|
||||
distinct_id=posthog_user_id,
|
||||
properties={
|
||||
'user_id': posthog_user_id,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
current_org = (
|
||||
await OrgStore.get_org_by_id(user.current_org_id)
|
||||
if user.current_org_id
|
||||
else None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'auth:posthog_set:failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
|
||||
# Load org data for identify_user (orgs list with member_count)
|
||||
org_member_ids = (
|
||||
[om.org_id for om in user.org_members] if user.org_members else []
|
||||
)
|
||||
user_orgs = await _get_user_orgs_with_data(user_id, org_member_ids)
|
||||
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
orgs_data = []
|
||||
for org in user_orgs:
|
||||
try:
|
||||
member_count = await OrgMemberStore.get_org_members_count(org_id=org.id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'auth:identify_user:member_count_failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
member_count = None
|
||||
orgs_data.append(
|
||||
{'id': str(org.id), 'name': org.name, 'member_count': member_count}
|
||||
)
|
||||
|
||||
analytics.identify_user(
|
||||
distinct_id=user_id,
|
||||
consented=consented,
|
||||
email=email,
|
||||
org_id=org_id_str,
|
||||
org_name=current_org.name if current_org else None,
|
||||
idp=idp,
|
||||
orgs=orgs_data,
|
||||
)
|
||||
|
||||
analytics.track_user_logged_in(
|
||||
distinct_id=user_id,
|
||||
idp=idp,
|
||||
org_id=org_id_str,
|
||||
consented=consented,
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'user_logged_in',
|
||||
extra={
|
||||
'idp': idp,
|
||||
'idp_type': idp_type,
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from storage.org import Org
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -28,9 +29,7 @@ billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
|
||||
|
||||
|
||||
async def validate_billing_enabled() -> None:
|
||||
"""
|
||||
Validate that the billing feature flag is enabled
|
||||
"""
|
||||
"""Validate that the billing feature flag is enabled"""
|
||||
config = get_global_config()
|
||||
web_client_config = await config.web_client.get_web_client_config()
|
||||
if not web_client_config.feature_flags.enable_billing:
|
||||
@@ -299,6 +298,22 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Analytics: credit purchased event (fires after commit so event only fires on success)
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user:
|
||||
consented = user.user_consents_to_analytics is True
|
||||
analytics.track_credit_purchased(
|
||||
distinct_id=billing_session.user_id,
|
||||
amount_usd=add_credits,
|
||||
credit_balance_before=max_budget,
|
||||
credit_balance_after=new_max_budget,
|
||||
org_id=str(user.current_org_id) if user.current_org_id else None,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:credit_purchased:failed')
|
||||
|
||||
return RedirectResponse(
|
||||
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.analytics import get_analytics_service, resolve_context
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -311,6 +312,42 @@ async def device_verification_authenticated(
|
||||
'Device code authorized with API key successfully',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
|
||||
# Server-side identity tracking for device auth flow
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
try:
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
# Load current org name for identify_user
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
current_org = (
|
||||
await OrgStore.get_org_by_id(ctx.user.current_org_id)
|
||||
if ctx.user and ctx.user.current_org_id
|
||||
else None
|
||||
)
|
||||
|
||||
analytics.identify_user(
|
||||
distinct_id=user_id,
|
||||
consented=ctx.consented,
|
||||
org_id=ctx.org_id,
|
||||
org_name=current_org.name if current_org else None,
|
||||
idp='device_auth',
|
||||
)
|
||||
|
||||
analytics.track_user_logged_in(
|
||||
distinct_id=user_id,
|
||||
idp='device_auth',
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'oauth_device:analytics:failed',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Device authorized successfully!'},
|
||||
|
||||
68
enterprise/server/routes/onboarding.py
Normal file
68
enterprise/server/routes/onboarding.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Onboarding submission endpoint.
|
||||
|
||||
Receives user onboarding selections and fires analytics event.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
onboarding_router = APIRouter(prefix='/api', tags=['Onboarding'])
|
||||
|
||||
|
||||
class OnboardingSubmission(BaseModel):
|
||||
selections: dict[
|
||||
str, str
|
||||
] # step_id -> option_id (e.g., {"step1": "software_engineer", "step2": "solo", "step3": "new_features"})
|
||||
|
||||
|
||||
class OnboardingResponse(BaseModel):
|
||||
status: str
|
||||
redirect_url: str
|
||||
|
||||
|
||||
@onboarding_router.post('/onboarding', response_model=OnboardingResponse)
|
||||
async def submit_onboarding(
|
||||
body: OnboardingSubmission,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> OnboardingResponse:
|
||||
"""Submit onboarding form selections and fire analytics event."""
|
||||
# ACTV-03: onboarding completed
|
||||
try:
|
||||
from openhands.analytics import get_analytics_service, resolve_context
|
||||
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
analytics.track_onboarding_completed(
|
||||
distinct_id=user_id,
|
||||
role=body.selections.get('step1'),
|
||||
org_size=body.selections.get('step2'),
|
||||
use_case=body.selections.get('step3'),
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
|
||||
# Associate onboarding timestamp with org group
|
||||
if ctx.org_id:
|
||||
analytics.group_identify(
|
||||
group_type='org',
|
||||
group_key=ctx.org_id,
|
||||
properties={
|
||||
'onboarding_completed_at': datetime.now(
|
||||
timezone.utc
|
||||
).isoformat(),
|
||||
},
|
||||
distinct_id=user_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception('analytics:onboarding_completed:failed')
|
||||
|
||||
return OnboardingResponse(status='ok', redirect_url='/')
|
||||
@@ -22,6 +22,7 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -94,6 +95,28 @@ async def create_invitation(
|
||||
},
|
||||
)
|
||||
|
||||
# Analytics: track team members invited
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(user_id)
|
||||
consented = (
|
||||
user_obj.user_consents_to_analytics is True if user_obj else False
|
||||
)
|
||||
analytics.track_team_members_invited(
|
||||
distinct_id=user_id,
|
||||
org_id=str(org_id),
|
||||
invited_count=len(invitation_data.emails),
|
||||
successful_count=len(successful),
|
||||
failed_count=len(failed),
|
||||
role=invitation_data.role,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:team_members_invited:failed')
|
||||
|
||||
successful_responses = [
|
||||
await InvitationResponse.from_invitation(inv) for inv in successful
|
||||
]
|
||||
|
||||
@@ -54,6 +54,7 @@ from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -1105,6 +1106,29 @@ async def switch_org(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Refresh person profile with new active org on org switch
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
try:
|
||||
from openhands.analytics import resolve_context
|
||||
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
analytics.set_person_properties(
|
||||
distinct_id=user_id,
|
||||
properties={
|
||||
'org_id': str(org_id),
|
||||
'org_name': org.name,
|
||||
'plan_tier': None, # plan_tier not yet on Org model
|
||||
},
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'orgs:switch_org:analytics:failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM for the new current org
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from storage.conversation_work import ConversationWork
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event_store import EventStore
|
||||
@@ -31,6 +32,15 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
|
||||
# V0 terminal state sets for analytics
|
||||
_TERMINAL_ERROR_STATES = {AgentState.ERROR}
|
||||
_TERMINAL_FINISHED_STATES = {
|
||||
AgentState.FINISHED,
|
||||
AgentState.STOPPED,
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
}
|
||||
_ALL_TERMINAL_STATES = _TERMINAL_ERROR_STATES | _TERMINAL_FINISHED_STATES
|
||||
|
||||
|
||||
async def process_event(
|
||||
user_id: str, conversation_id: str, subpath: str, content: dict
|
||||
@@ -62,6 +72,120 @@ async def process_event(
|
||||
# Load and invoke all active callbacks for this conversation
|
||||
await invoke_conversation_callbacks(conversation_id, event)
|
||||
|
||||
# V0 best-effort analytics for terminal states
|
||||
if event.agent_state in _ALL_TERMINAL_STATES:
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
from openhands.analytics import resolve_context
|
||||
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
# Look up conversation metadata for cost/token data
|
||||
with session_maker() as meta_session:
|
||||
conv_meta = (
|
||||
meta_session.query(StoredConversationMetadata)
|
||||
.filter(
|
||||
StoredConversationMetadata.conversation_id
|
||||
== conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if event.agent_state in _TERMINAL_ERROR_STATES:
|
||||
analytics.track_conversation_errored(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
error_type='unknown', # V0: error classification not available from AgentState alone
|
||||
error_message=None,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta and hasattr(conv_meta, 'llm_model')
|
||||
else None,
|
||||
turn_count=None,
|
||||
terminal_state=event.agent_state.value
|
||||
if hasattr(event.agent_state, 'value')
|
||||
else str(event.agent_state),
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
else:
|
||||
analytics.track_conversation_finished(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
terminal_state=event.agent_state.value
|
||||
if hasattr(event.agent_state, 'value')
|
||||
else str(event.agent_state),
|
||||
turn_count=None,
|
||||
accumulated_cost_usd=conv_meta.accumulated_cost
|
||||
if conv_meta
|
||||
else None,
|
||||
prompt_tokens=conv_meta.prompt_tokens
|
||||
if conv_meta
|
||||
else None,
|
||||
completion_tokens=conv_meta.completion_tokens
|
||||
if conv_meta
|
||||
else None,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta and hasattr(conv_meta, 'llm_model')
|
||||
else None,
|
||||
trigger=None, # V0: trigger not available in callback context
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
|
||||
# ACTV-01: user activated (first finished conversation only)
|
||||
if event.agent_state == AgentState.FINISHED:
|
||||
try:
|
||||
import uuid as _uuid
|
||||
from datetime import timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
user_uuid = _uuid.UUID(user_id)
|
||||
with session_maker() as act_session:
|
||||
count_result = act_session.execute(
|
||||
sa_select(func.count()).where(
|
||||
StoredConversationMetadataSaas.user_id
|
||||
== user_uuid,
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
!= conversation_id,
|
||||
)
|
||||
)
|
||||
prior_count = count_result.scalar()
|
||||
|
||||
if prior_count == 0:
|
||||
tos_ts = ctx.user.accepted_tos if ctx.user else None
|
||||
if tos_ts is not None:
|
||||
if tos_ts.tzinfo is None:
|
||||
tos_ts = tos_ts.replace(tzinfo=timezone.utc)
|
||||
from datetime import datetime
|
||||
|
||||
time_to_activate_seconds = (
|
||||
datetime.now(timezone.utc) - tos_ts
|
||||
).total_seconds()
|
||||
else:
|
||||
time_to_activate_seconds = None
|
||||
|
||||
analytics.track_user_activated(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
time_to_activate_seconds=time_to_activate_seconds,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta
|
||||
else None,
|
||||
trigger=None, # V0: trigger not available in callback context
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:user_activated:v0:failed')
|
||||
except Exception:
|
||||
logger.exception('analytics:v0_terminal_state:failed')
|
||||
|
||||
# Update active working seconds if agent state is not Running
|
||||
if event.agent_state != AgentState.RUNNING:
|
||||
event_store = EventStore(conversation_id, file_store, user_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Store class for managing users."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
@@ -85,6 +86,9 @@ class UserStore:
|
||||
)
|
||||
user.email = user_info.get('email')
|
||||
user.email_verified = user_info.get('email_verified')
|
||||
# SaaS consent is implicit via Terms of Service — new SaaS users default to consented
|
||||
if 'saas' in (os.environ.get('OPENHANDS_CONFIG_CLS', '')).lower():
|
||||
user.user_consents_to_analytics = True
|
||||
session.add(user)
|
||||
|
||||
role = await RoleStore.get_role_by_name('owner')
|
||||
|
||||
@@ -186,7 +186,10 @@ async def test_keycloak_callback_success_with_valid_offline_token(
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.set_response_cookie') as mock_set_cookie,
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
patch('server.routes.auth.posthog') as mock_posthog,
|
||||
patch('server.routes.auth.get_analytics_service') as mock_posthog,
|
||||
patch(
|
||||
'storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock
|
||||
) as _mock_get_org,
|
||||
patch(
|
||||
'server.routes.auth._should_redirect_to_onboarding',
|
||||
new_callable=AsyncMock,
|
||||
@@ -242,7 +245,8 @@ async def test_keycloak_callback_success_with_valid_offline_token(
|
||||
secure=False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
mock_posthog.set.assert_called_once()
|
||||
mock_posthog.return_value.identify_user.assert_called()
|
||||
mock_posthog.return_value.track_user_logged_in.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -443,7 +447,10 @@ async def test_keycloak_callback_success_without_offline_token(
|
||||
patch('server.routes.auth.KEYCLOAK_REALM_NAME', 'test-realm'),
|
||||
patch('server.routes.auth.KEYCLOAK_CLIENT_ID', 'test-client'),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
patch('server.routes.auth.posthog') as mock_posthog,
|
||||
patch('server.routes.auth.get_analytics_service') as mock_posthog,
|
||||
patch(
|
||||
'storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock
|
||||
) as _mock_get_org,
|
||||
patch(
|
||||
'server.routes.auth._should_redirect_to_onboarding',
|
||||
new_callable=AsyncMock,
|
||||
@@ -504,7 +511,8 @@ async def test_keycloak_callback_success_without_offline_token(
|
||||
secure=False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
mock_posthog.set.assert_called_once()
|
||||
mock_posthog.return_value.identify_user.assert_called()
|
||||
mock_posthog.return_value.track_user_logged_in.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -525,7 +533,8 @@ async def test_keycloak_callback_redirects_to_keycloak_when_offline_token_invali
|
||||
patch('server.routes.auth.KEYCLOAK_REALM_NAME', 'test-realm'),
|
||||
patch('server.routes.auth.KEYCLOAK_CLIENT_ID', 'test-client'),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.OrgInvitationService') as mock_invitation_service,
|
||||
patch(
|
||||
'server.routes.auth._should_redirect_to_onboarding',
|
||||
@@ -1307,7 +1316,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
'storage.user_authorization_store.UserAuthorizationStore'
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1467,7 +1477,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.a_session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1557,7 +1568,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.a_session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1646,7 +1658,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.a_session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1732,7 +1745,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.a_session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1815,7 +1829,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
'storage.user_authorization_store.UserAuthorizationStore'
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1886,7 +1901,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
'storage.user_authorization_store.UserAuthorizationStore'
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -1963,7 +1979,8 @@ class TestKeycloakCallbackRecaptcha:
|
||||
'storage.user_authorization_store.UserAuthorizationStore'
|
||||
) as mock_user_auth_store,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
patch('server.routes.auth.logger') as mock_logger,
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
):
|
||||
@@ -2120,7 +2137,8 @@ async def test_keycloak_callback_calls_backfill_user_email_for_existing_user(
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.UserStore') as mock_user_store,
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.get_analytics_service'),
|
||||
patch('storage.org_store.OrgStore.get_org_by_id', new_callable=AsyncMock),
|
||||
):
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = 'test_user_id'
|
||||
|
||||
97
enterprise/tests/unit/test_posthog_session_middleware.py
Normal file
97
enterprise/tests/unit/test_posthog_session_middleware.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for PostHogSessionMiddleware."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request, Response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response():
|
||||
return MagicMock(spec=Response)
|
||||
|
||||
|
||||
def make_mock_request(headers: dict | None = None):
|
||||
"""Create a mock FastAPI Request with a state object and headers dict."""
|
||||
request = MagicMock(spec=Request)
|
||||
request.headers = headers or {}
|
||||
request.state = MagicMock()
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_session_id_from_header(mock_response):
|
||||
"""PostHogSessionMiddleware sets posthog_session_id from X-POSTHOG-SESSION-ID header."""
|
||||
from server.middleware import PostHogSessionMiddleware
|
||||
|
||||
session_id = 'sess_abc123'
|
||||
request = make_mock_request({'X-POSTHOG-SESSION-ID': session_id})
|
||||
call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
middleware = PostHogSessionMiddleware()
|
||||
result = await middleware(request, call_next)
|
||||
|
||||
assert request.state.posthog_session_id == session_id
|
||||
call_next.assert_called_once_with(request)
|
||||
assert result == mock_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_sets_none_when_header_absent(mock_response):
|
||||
"""PostHogSessionMiddleware sets posthog_session_id to None when header is absent."""
|
||||
from server.middleware import PostHogSessionMiddleware
|
||||
|
||||
request = make_mock_request({}) # No X-POSTHOG-SESSION-ID header
|
||||
call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
middleware = PostHogSessionMiddleware()
|
||||
result = await middleware(request, call_next)
|
||||
|
||||
assert request.state.posthog_session_id is None
|
||||
call_next.assert_called_once_with(request)
|
||||
assert result == mock_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_does_not_modify_response(mock_response):
|
||||
"""PostHogSessionMiddleware returns the response unchanged."""
|
||||
from server.middleware import PostHogSessionMiddleware
|
||||
|
||||
request = make_mock_request({'X-POSTHOG-SESSION-ID': 'sess_xyz'})
|
||||
call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
middleware = PostHogSessionMiddleware()
|
||||
result = await middleware(request, call_next)
|
||||
|
||||
assert result is mock_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_does_not_block_request(mock_response):
|
||||
"""PostHogSessionMiddleware always calls call_next (never blocks)."""
|
||||
from server.middleware import PostHogSessionMiddleware
|
||||
|
||||
request = make_mock_request({})
|
||||
call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
middleware = PostHogSessionMiddleware()
|
||||
await middleware(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_handles_case_insensitive_header(mock_response):
|
||||
"""PostHogSessionMiddleware uses .get() which handles header lookup."""
|
||||
from server.middleware import PostHogSessionMiddleware
|
||||
|
||||
# FastAPI/Starlette Headers are case-insensitive, but we test with dict mock
|
||||
# Test the exact header name used in the implementation
|
||||
session_id = 'sess_case_test'
|
||||
request = make_mock_request({'X-POSTHOG-SESSION-ID': session_id})
|
||||
call_next = AsyncMock(return_value=mock_response)
|
||||
|
||||
middleware = PostHogSessionMiddleware()
|
||||
await middleware(request, call_next)
|
||||
|
||||
assert request.state.posthog_session_id == session_id
|
||||
108
enterprise/tests/unit/test_saas_lifespan.py
Normal file
108
enterprise/tests/unit/test_saas_lifespan.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for SaasAppLifespanService."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_analytics_service():
|
||||
svc = MagicMock()
|
||||
svc.shutdown = MagicMock()
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aenter_calls_init_analytics_service():
|
||||
"""SaasAppLifespanService.__aenter__ initializes the analytics service."""
|
||||
from server.app_lifespan.saas_app_lifespan_service import SaasAppLifespanService
|
||||
|
||||
with patch(
|
||||
'server.app_lifespan.saas_app_lifespan_service.init_analytics_service'
|
||||
) as mock_init:
|
||||
svc = SaasAppLifespanService()
|
||||
await svc.__aenter__()
|
||||
mock_init.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aenter_passes_env_vars_to_init():
|
||||
"""SaasAppLifespanService reads config from env vars."""
|
||||
from server.app_lifespan.saas_app_lifespan_service import SaasAppLifespanService
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.app_lifespan.saas_app_lifespan_service.init_analytics_service'
|
||||
) as mock_init,
|
||||
patch.dict(
|
||||
'os.environ',
|
||||
{
|
||||
'POSTHOG_CLIENT_KEY': 'test-key',
|
||||
'POSTHOG_HOST': 'https://test.posthog.com',
|
||||
'OPENHANDS_CONFIG_CLS': 'enterprise.server.config.SaaSServerConfig',
|
||||
},
|
||||
),
|
||||
):
|
||||
svc = SaasAppLifespanService()
|
||||
await svc.__aenter__()
|
||||
|
||||
call_kwargs = mock_init.call_args
|
||||
assert call_kwargs.kwargs['api_key'] == 'test-key'
|
||||
assert call_kwargs.kwargs['host'] == 'https://test.posthog.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexit_calls_shutdown_when_service_exists(mock_analytics_service):
|
||||
"""SaasAppLifespanService.__aexit__ calls shutdown on the analytics service."""
|
||||
from server.app_lifespan.saas_app_lifespan_service import SaasAppLifespanService
|
||||
|
||||
with (
|
||||
patch('server.app_lifespan.saas_app_lifespan_service.init_analytics_service'),
|
||||
patch(
|
||||
'server.app_lifespan.saas_app_lifespan_service.get_analytics_service',
|
||||
return_value=mock_analytics_service,
|
||||
),
|
||||
):
|
||||
svc = SaasAppLifespanService()
|
||||
await svc.__aenter__()
|
||||
await svc.__aexit__(None, None, None)
|
||||
|
||||
mock_analytics_service.shutdown.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexit_does_not_raise_when_service_is_none():
|
||||
"""SaasAppLifespanService.__aexit__ does not raise if analytics service is None."""
|
||||
from server.app_lifespan.saas_app_lifespan_service import SaasAppLifespanService
|
||||
|
||||
with (
|
||||
patch('server.app_lifespan.saas_app_lifespan_service.init_analytics_service'),
|
||||
patch(
|
||||
'server.app_lifespan.saas_app_lifespan_service.get_analytics_service',
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
svc = SaasAppLifespanService()
|
||||
await svc.__aenter__()
|
||||
# Must not raise
|
||||
await svc.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexit_does_not_raise_on_shutdown_error(mock_analytics_service):
|
||||
"""SaasAppLifespanService.__aexit__ swallows errors from shutdown."""
|
||||
from server.app_lifespan.saas_app_lifespan_service import SaasAppLifespanService
|
||||
|
||||
mock_analytics_service.shutdown.side_effect = RuntimeError('connection closed')
|
||||
|
||||
with (
|
||||
patch('server.app_lifespan.saas_app_lifespan_service.init_analytics_service'),
|
||||
patch(
|
||||
'server.app_lifespan.saas_app_lifespan_service.get_analytics_service',
|
||||
return_value=mock_analytics_service,
|
||||
),
|
||||
):
|
||||
svc = SaasAppLifespanService()
|
||||
await svc.__aenter__()
|
||||
# Must not raise even if shutdown errors
|
||||
await svc.__aexit__(None, None, None)
|
||||
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
vi.mock("#/hooks/use-client-analytics", () => ({
|
||||
useClientAnalytics: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,20 +18,6 @@ describe("ContextMenuCTA", () => {
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "context_menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
|
||||
@@ -24,12 +24,6 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: undefined,
|
||||
|
||||
@@ -4,11 +4,10 @@ import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { LoginCTA } from "#/components/features/auth/login-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
vi.mock("#/hooks/use-client-analytics", () => ({
|
||||
useClientAnalytics: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -49,7 +48,7 @@ describe("LoginCTA", () => {
|
||||
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should track and navigate to information request page when Learn More is clicked", async () => {
|
||||
it("should navigate to information request page when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
@@ -58,9 +57,6 @@ describe("LoginCTA", () => {
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "login_page",
|
||||
});
|
||||
expect(screen.getByTestId("information-request-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -90,17 +86,4 @@ describe("LoginCTA", () => {
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should track device_verify location when Learn More is clicked in device verify mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "device_verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,19 +23,18 @@ vi.mock("react-i18next", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/hooks/use-client-analytics", () => ({
|
||||
useClientAnalytics: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock local storage
|
||||
vi.mock("#/utils/local-storage", () => ({
|
||||
setCTADismissed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
|
||||
describe("HomepageCTA", () => {
|
||||
@@ -118,18 +117,6 @@ describe("HomepageCTA", () => {
|
||||
});
|
||||
|
||||
describe("Learn More link behavior", () => {
|
||||
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "home_page",
|
||||
});
|
||||
});
|
||||
|
||||
it("has correct href and target attributes", () => {
|
||||
renderHomepageCTA();
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
} from "#/components/features/onboarding/information-request-form";
|
||||
import { EnterpriseFormData } from "#/utils/local-storage";
|
||||
|
||||
// Mock useTracking
|
||||
const mockTrackEnterpriseLeadFormSubmitted = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackEnterpriseLeadFormSubmitted: mockTrackEnterpriseLeadFormSubmitted,
|
||||
vi.mock("#/hooks/use-client-analytics", () => ({
|
||||
useClientAnalytics: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -215,16 +214,6 @@ describe("InformationRequestForm", () => {
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call tracking when form is submitted with empty fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should navigate to login page when form is submitted with all fields filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
@@ -243,70 +232,6 @@ describe("InformationRequestForm", () => {
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking with form data when form is submitted successfully", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter({ ...defaultProps, requestType: "saas" });
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "John Doe");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
|
||||
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Hello world");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "saas",
|
||||
name: "John Doe",
|
||||
company: "Acme Inc",
|
||||
email: "john@example.com",
|
||||
message: "Hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should call tracking with self-hosted request type", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), "Jane Smith");
|
||||
await user.type(screen.getByTestId("form-input-company"), "Tech Corp");
|
||||
await user.type(screen.getByTestId("form-input-email"), "jane@techcorp.com");
|
||||
await user.type(screen.getByTestId("form-input-message"), "Interested in self-hosted");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "self-hosted",
|
||||
name: "Jane Smith",
|
||||
company: "Tech Corp",
|
||||
email: "jane@techcorp.com",
|
||||
message: "Interested in self-hosted",
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim whitespace from form fields before tracking", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
await user.type(screen.getByTestId("form-input-name"), " John Doe ");
|
||||
await user.type(screen.getByTestId("form-input-company"), " Acme Inc ");
|
||||
await user.type(screen.getByTestId("form-input-email"), " john@example.com ");
|
||||
await user.type(screen.getByTestId("form-input-message"), " Hello world ");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
|
||||
requestType: "saas",
|
||||
name: "John Doe",
|
||||
company: "Acme Inc",
|
||||
email: "john@example.com",
|
||||
message: "Hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have valid aria-invalid state when field has value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
@@ -335,7 +260,6 @@ describe("InformationRequestForm", () => {
|
||||
// Should stay on form page, not navigate to login
|
||||
expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,8 +282,6 @@ describe("InformationRequestForm", () => {
|
||||
await user.click(submitButton);
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should only track once
|
||||
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
|
||||
// Should navigate to login page
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import OnboardingForm, { clientLoader } from "#/routes/onboarding-form";
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
const mockUseMe = vi.fn();
|
||||
const mockTrackOnboardingCompleted = vi.fn();
|
||||
|
||||
// Loader data set in beforeEach for each test suite
|
||||
let loaderData: { config: { app_mode: string; feature_flags: { deployment_mode: string } } };
|
||||
@@ -33,12 +32,6 @@ vi.mock("#/hooks/query/use-me", () => ({
|
||||
useMe: () => mockUseMe(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackOnboardingCompleted: mockTrackOnboardingCompleted,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mocks for clientLoader tests
|
||||
const mockQueryClientGetData = vi.fn();
|
||||
const mockQueryClientSetData = vi.fn();
|
||||
@@ -86,7 +79,6 @@ describe("OnboardingForm - Cloud Mode", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockTrackOnboardingCompleted.mockClear();
|
||||
loaderData = {
|
||||
config: {
|
||||
app_mode: "saas",
|
||||
@@ -190,28 +182,6 @@ describe("OnboardingForm - Cloud Mode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should track onboarding completion to PostHog in cloud mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderOnboardingForm();
|
||||
|
||||
// Complete the full cloud onboarding flow
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
|
||||
role: "software_engineer",
|
||||
orgSize: "org_2_10",
|
||||
useCase: ["new_features"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 5 options on step 1 (org size question)", async () => {
|
||||
await renderOnboardingForm();
|
||||
|
||||
@@ -368,7 +338,6 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockTrackOnboardingCompleted.mockClear();
|
||||
loaderData = {
|
||||
config: {
|
||||
app_mode: "saas",
|
||||
@@ -434,32 +403,6 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should track onboarding completion in self-hosted mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderOnboardingForm();
|
||||
|
||||
// Complete the full self-hosted onboarding flow (3 steps)
|
||||
const orgNameInput = screen.getByTestId("form-input-org_name");
|
||||
const orgDomainInput = screen.getByTestId("form-input-org_domain");
|
||||
await user.type(orgNameInput, "Test Company");
|
||||
await user.type(orgDomainInput, "test.com");
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
|
||||
// Note: role is not included since role question is cloud-only
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
|
||||
role: undefined,
|
||||
orgSize: "org_2_10",
|
||||
useCase: ["new_features"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should show all 3 progress bars filled on the last step", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderOnboardingForm();
|
||||
@@ -500,59 +443,6 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should NOT track onboarding completion for non-owners in self-hosted mode", async () => {
|
||||
// Override the mock to return a member (non-owner) role
|
||||
mockUseMe.mockReturnValue({ data: { role: "member" } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderOnboardingForm();
|
||||
|
||||
// Complete the full self-hosted onboarding flow (3 steps)
|
||||
const orgNameInput = screen.getByTestId("form-input-org_name");
|
||||
const orgDomainInput = screen.getByTestId("form-input-org_domain");
|
||||
await user.type(orgNameInput, "Test Company");
|
||||
await user.type(orgDomainInput, "test.com");
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Tracking should NOT be called for non-owners in self-hosted mode
|
||||
expect(mockTrackOnboardingCompleted).not.toHaveBeenCalled();
|
||||
|
||||
// But onboarding submission should still work
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT track onboarding completion for admins in self-hosted mode", async () => {
|
||||
// Override the mock to return an admin role
|
||||
mockUseMe.mockReturnValue({ data: { role: "admin" } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderOnboardingForm();
|
||||
|
||||
// Complete the full self-hosted onboarding flow (3 steps)
|
||||
const orgNameInput = screen.getByTestId("form-input-org_name");
|
||||
const orgDomainInput = screen.getByTestId("form-input-org_domain");
|
||||
await user.type(orgNameInput, "Test Company");
|
||||
await user.type(orgDomainInput, "test.com");
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Tracking should NOT be called for admins in self-hosted mode (only owners)
|
||||
expect(mockTrackOnboardingCompleted).not.toHaveBeenCalled();
|
||||
|
||||
// But onboarding submission should still work
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding-form clientLoader", () => {
|
||||
|
||||
@@ -23,10 +23,10 @@ vi.mock("#/hooks/use-breakpoint", () => ({
|
||||
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
|
||||
}));
|
||||
|
||||
// Mock useTracking hook for CTA
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
vi.mock("#/hooks/use-client-analytics", () => ({
|
||||
useClientAnalytics: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -136,6 +136,28 @@ describe("PostHogWrapper", () => {
|
||||
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
|
||||
});
|
||||
|
||||
it("should initialize PostHog with health monitoring config (web vitals, error tracking, network timing)", async () => {
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
<div data-testid="child" />
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("child");
|
||||
|
||||
expect(mockPostHogProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
capture_performance: {
|
||||
network_timing: true,
|
||||
web_vitals: true,
|
||||
},
|
||||
capture_exceptions: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => {
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
@@ -10,7 +10,10 @@ import { getAgentSettingValue } from "#/utils/sdk-settings-schema";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
@@ -22,20 +25,23 @@ describe("SettingsForm", () => {
|
||||
]);
|
||||
|
||||
it("should save the user settings and close the modal when the form is submitted", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouteStub />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent_settings: expect.objectContaining({
|
||||
llm: expect.objectContaining({
|
||||
model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"),
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent_settings: expect.objectContaining({
|
||||
llm: expect.objectContaining({
|
||||
model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,20 @@ import V1ConversationService from "#/api/conversation-service/v1-conversation-se
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackConversationCreated: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
v1_enabled: true,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("useCreateConversation", () => {
|
||||
it("passes suggested tasks to the V1 create conversation API", async () => {
|
||||
|
||||
61
frontend/__tests__/hooks/use-client-analytics.test.ts
Normal file
61
frontend/__tests__/hooks/use-client-analytics.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
const mockCapture = vi.fn();
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: vi.fn(() => ({
|
||||
capture: mockCapture,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { useClientAnalytics } from "#/hooks/use-client-analytics";
|
||||
|
||||
describe("useClientAnalytics", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("trackSaasSelfhostedInquiry calls posthog.capture with correct event and location", () => {
|
||||
const { result } = renderHook(() => useClientAnalytics());
|
||||
result.current.trackSaasSelfhostedInquiry({ location: "home_page" });
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledOnce();
|
||||
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry", {
|
||||
location: "home_page",
|
||||
});
|
||||
});
|
||||
|
||||
it("trackEnterpriseLeadFormSubmitted calls posthog.capture with correct event and form data", () => {
|
||||
const { result } = renderHook(() => useClientAnalytics());
|
||||
result.current.trackEnterpriseLeadFormSubmitted({
|
||||
requestType: "self-hosted",
|
||||
name: "Jane Doe",
|
||||
company: "Acme Corp",
|
||||
email: "jane@acme.com",
|
||||
message: "Interested in on-prem",
|
||||
});
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledOnce();
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
"enterprise_lead_form_submitted",
|
||||
{
|
||||
request_type: "self-hosted",
|
||||
name: "Jane Doe",
|
||||
company: "Acme Corp",
|
||||
email: "jane@acme.com",
|
||||
message: "Interested in on-prem",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when posthog is null", async () => {
|
||||
const posthogReact = await import("posthog-js/react");
|
||||
vi.mocked(posthogReact.usePostHog).mockReturnValueOnce(null as any);
|
||||
|
||||
const { result } = renderHook(() => useClientAnalytics());
|
||||
expect(() =>
|
||||
result.current.trackSaasSelfhostedInquiry({ location: "login_page" }),
|
||||
).not.toThrow();
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
createMockAgentErrorEvent,
|
||||
createMockConversationErrorEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
|
||||
|
||||
// Mock the tracking function
|
||||
const mockTrackCreditLimitReached = vi.fn();
|
||||
|
||||
// Mock useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackCreditLimitReached: mockTrackCreditLimitReached,
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
trackConversationCreated: vi.fn(),
|
||||
trackPushButtonClick: vi.fn(),
|
||||
trackPullButtonClick: vi.fn(),
|
||||
trackCreatePrButtonClick: vi.fn(),
|
||||
trackGitProviderConnected: vi.fn(),
|
||||
trackUserSignupCompleted: vi.fn(),
|
||||
trackCreditsPurchased: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useActiveConversation hook
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear all mocks before each test
|
||||
mockTrackCreditLimitReached.mockClear();
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to render components with all necessary providers
|
||||
function renderWithProviders(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-123",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-123",
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={null}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PostHog Analytics Tracking", () => {
|
||||
describe("Credit Limit Tracking", () => {
|
||||
it("should NOT track credit_limit_reached for non-budget errors", async () => {
|
||||
// Create a regular error without budget/credit keywords
|
||||
const mockRegularErrorEvent = createMockAgentErrorEvent({
|
||||
error: "Failed to execute command: Permission denied",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockRegularErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection and error to be processed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Verify that credit_limit_reached was NOT tracked
|
||||
expect(mockTrackCreditLimitReached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should track credit_limit_reached when ConversationErrorEvent contains budget error", async () => {
|
||||
const mockBudgetConversationError = createMockConversationErrorEvent({
|
||||
detail:
|
||||
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockBudgetConversationError));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,14 +33,6 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: (...args: unknown[]) => mockDisplayErrorToast(...args),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackCreditsPurchased = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackCreditsPurchased: mockTrackCreditsPurchased,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useBalance hook
|
||||
const mockUseBalance = vi.fn();
|
||||
vi.mock("#/hooks/query/use-balance", () => ({
|
||||
@@ -353,12 +345,6 @@ describe("Billing Route", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockDisplaySuccessToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockTrackCreditsPurchased).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackCreditsPurchased).toHaveBeenCalledWith({
|
||||
amountUsd: 25,
|
||||
stripeSessionId: "sess_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error toast exactly once on checkout cancel", async () => {
|
||||
@@ -385,8 +371,6 @@ describe("Billing Route", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockDisplayErrorToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockTrackCreditsPurchased).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,6 @@ vi.mock("react-router", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useTracking to avoid QueryClient dependency
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackEnterpriseLeadFormSubmitted: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("InformationRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -51,12 +51,6 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrlMock(config),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useInvitationMock, buildOAuthStateDataMock } = vi.hoisted(() => ({
|
||||
useInvitationMock: vi.fn(() => ({
|
||||
invitationToken: null as string | null,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
trackError,
|
||||
showErrorToast,
|
||||
@@ -8,12 +7,6 @@ import {
|
||||
import * as Actions from "#/services/actions";
|
||||
import * as CustomToast from "#/utils/custom-toast-handlers";
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: {
|
||||
captureException: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/services/actions", () => ({
|
||||
handleStatusMessage: vi.fn(),
|
||||
}));
|
||||
@@ -28,163 +21,48 @@ describe("Error Handler", () => {
|
||||
});
|
||||
|
||||
describe("trackError", () => {
|
||||
it("should send error to PostHog with basic info", () => {
|
||||
const error = {
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
posthog,
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Test error"),
|
||||
{
|
||||
error_source: "test",
|
||||
},
|
||||
);
|
||||
it("should be a no-op (PostHog capture removed)", () => {
|
||||
// trackError no longer does anything — error tracking is server-side
|
||||
expect(() =>
|
||||
trackError({ message: "Test error", source: "test" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should include additional metadata in PostHog event", () => {
|
||||
const error = {
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
metadata: {
|
||||
extra: "info",
|
||||
details: { foo: "bar" },
|
||||
},
|
||||
posthog,
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Test error"),
|
||||
{
|
||||
error_source: "test",
|
||||
extra: "info",
|
||||
details: { foo: "bar" },
|
||||
},
|
||||
);
|
||||
it("should accept ErrorDetails without throwing", () => {
|
||||
expect(() =>
|
||||
trackError({
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
metadata: { extra: "info" },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showErrorToast", () => {
|
||||
const errorToastSpy = vi.spyOn(CustomToast, "displayErrorToast");
|
||||
it("should log error and show toast", () => {
|
||||
const error = {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
posthog,
|
||||
};
|
||||
|
||||
showErrorToast(error);
|
||||
it("should show toast with the error message", () => {
|
||||
showErrorToast({ message: "Toast error", source: "toast-test" });
|
||||
|
||||
// Verify PostHog logging
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Toast error"),
|
||||
{
|
||||
error_source: "toast-test",
|
||||
},
|
||||
);
|
||||
|
||||
// Verify toast was shown
|
||||
expect(errorToastSpy).toHaveBeenCalled();
|
||||
expect(errorToastSpy).toHaveBeenCalledWith("Toast error");
|
||||
});
|
||||
|
||||
it("should include metadata in PostHog event when showing toast", () => {
|
||||
const error = {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
metadata: { context: "testing" },
|
||||
posthog,
|
||||
};
|
||||
it("should show toast even without source or metadata", () => {
|
||||
showErrorToast({ message: "Simple error" });
|
||||
|
||||
showErrorToast(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Toast error"),
|
||||
{
|
||||
error_source: "toast-test",
|
||||
context: "testing",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should log errors from different sources with appropriate metadata", () => {
|
||||
// Test agent status error
|
||||
showErrorToast({
|
||||
message: "Agent error",
|
||||
source: "agent-status",
|
||||
metadata: { id: "error.agent" },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Agent error"),
|
||||
{
|
||||
error_source: "agent-status",
|
||||
id: "error.agent",
|
||||
},
|
||||
);
|
||||
|
||||
showErrorToast({
|
||||
message: "Server error",
|
||||
source: "server",
|
||||
metadata: { error_code: 500, details: "Internal error" },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Server error"),
|
||||
{
|
||||
error_source: "server",
|
||||
error_code: 500,
|
||||
details: "Internal error",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should log feedback submission errors with conversation context", () => {
|
||||
const error = new Error("Feedback submission failed");
|
||||
showErrorToast({
|
||||
message: error.message,
|
||||
source: "feedback",
|
||||
metadata: { conversationId: "123", error },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Feedback submission failed"),
|
||||
{
|
||||
error_source: "feedback",
|
||||
conversationId: "123",
|
||||
error,
|
||||
},
|
||||
);
|
||||
expect(errorToastSpy).toHaveBeenCalledWith("Simple error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showChatError", () => {
|
||||
it("should log error and show chat error message", () => {
|
||||
const error = {
|
||||
it("should show chat error message via handleStatusMessage", () => {
|
||||
showChatError({
|
||||
message: "Chat error",
|
||||
source: "chat-test",
|
||||
msgId: "123",
|
||||
posthog,
|
||||
};
|
||||
});
|
||||
|
||||
showChatError(error);
|
||||
|
||||
// Verify PostHog logging
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
new Error("Chat error"),
|
||||
{
|
||||
error_source: "chat-test",
|
||||
},
|
||||
);
|
||||
|
||||
// Verify error message was shown in chat
|
||||
expect(Actions.handleStatusMessage).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
message: "Chat error",
|
||||
@@ -192,5 +70,19 @@ describe("Error Handler", () => {
|
||||
status_update: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show chat error without msgId", () => {
|
||||
showChatError({
|
||||
message: "Chat error no id",
|
||||
source: "chat-test",
|
||||
});
|
||||
|
||||
expect(Actions.handleStatusMessage).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
message: "Chat error no id",
|
||||
id: undefined,
|
||||
status_update: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
|
||||
import { useRecaptcha } from "#/hooks/use-recaptcha";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -43,7 +42,6 @@ export function LoginContent({
|
||||
buildOAuthStateData,
|
||||
}: LoginContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackLoginButtonClick } = useTracking();
|
||||
const { data: config } = useConfig();
|
||||
const { isEnterpriseCloud } = useAppMode();
|
||||
|
||||
@@ -76,12 +74,7 @@ export function LoginContent({
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleAuthRedirect = async (
|
||||
redirectUrl: string,
|
||||
provider: Provider,
|
||||
) => {
|
||||
trackLoginButtonClick({ provider });
|
||||
|
||||
const handleAuthRedirect = async (redirectUrl: string) => {
|
||||
const url = new URL(redirectUrl);
|
||||
const currentState =
|
||||
url.searchParams.get("state") || window.location.origin;
|
||||
@@ -116,31 +109,31 @@ export function LoginContent({
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
handleAuthRedirect(githubAuthUrl, "github");
|
||||
handleAuthRedirect(githubAuthUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
handleAuthRedirect(gitlabAuthUrl, "gitlab");
|
||||
handleAuthRedirect(gitlabAuthUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
handleAuthRedirect(bitbucketAuthUrl, "bitbucket");
|
||||
handleAuthRedirect(bitbucketAuthUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketDataCenterAuth = () => {
|
||||
if (bitbucketDataCenterAuthUrl) {
|
||||
handleAuthRedirect(bitbucketDataCenterAuthUrl, "bitbucket_data_center");
|
||||
handleAuthRedirect(bitbucketDataCenterAuthUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoAuthUrl) {
|
||||
handleAuthRedirect(enterpriseSsoAuthUrl, "enterprise_sso");
|
||||
handleAuthRedirect(enterpriseSsoAuthUrl);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useClientAnalytics } from "#/hooks/use-client-analytics";
|
||||
|
||||
type LoginCTAProps = {
|
||||
className?: string;
|
||||
@@ -21,7 +21,7 @@ export function LoginCTA({
|
||||
source = "login_page",
|
||||
}: LoginCTAProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
const { trackSaasSelfhostedInquiry } = useClientAnalytics();
|
||||
const isDeviceVerifySource = source === "device_verify";
|
||||
const learnMoreButtonClassName = cn(
|
||||
"inline-flex items-center justify-center",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@@ -12,7 +11,6 @@ import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click";
|
||||
@@ -36,17 +34,7 @@ import { getStatusColor, getStatusText } from "#/utils/utils";
|
||||
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
hasReplayJson: boolean | null,
|
||||
): string {
|
||||
if (hasRepository) return "github";
|
||||
if (hasReplayJson) return "replay";
|
||||
return "direct";
|
||||
}
|
||||
|
||||
export function ChatInterface() {
|
||||
const posthog = usePostHog();
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { errorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
@@ -111,7 +99,6 @@ export function ChatInterface() {
|
||||
};
|
||||
}, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]);
|
||||
|
||||
const { selectedRepository, replayJson } = useInitialQueryStore();
|
||||
const params = useParams();
|
||||
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
|
||||
|
||||
@@ -155,22 +142,6 @@ export function ChatInterface() {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (totalEvents === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
selectedRepository !== null,
|
||||
replayJson !== null,
|
||||
),
|
||||
query_character_length: content.length,
|
||||
replay_json_size: replayJson?.length,
|
||||
});
|
||||
} else {
|
||||
posthog.capture("user_message_sent", {
|
||||
session_message_count: totalEvents,
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate file sizes before any processing
|
||||
const allFiles = [...images, ...files];
|
||||
const validation = validateFiles(allFiles);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn, getCreatePRPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPrButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,8 +19,6 @@ export function GitControlBarPrButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackCreatePrButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
@@ -29,7 +26,6 @@ export function GitControlBarPrButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePrClick = () => {
|
||||
trackCreatePrButtonClick();
|
||||
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn, getGitPullPrompt } from "#/utils/utils";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPullButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -16,8 +15,6 @@ export function GitControlBarPullButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPullButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPullButtonClick } = useTracking();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@@ -27,7 +24,6 @@ export function GitControlBarPullButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePullClick = () => {
|
||||
trackPullButtonClick();
|
||||
onSuggestionsClick(getGitPullPrompt());
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn, getGitPushPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPushButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,8 +19,6 @@ export function GitControlBarPushButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPushButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPushButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
@@ -29,7 +26,6 @@ export function GitControlBarPushButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePushClick = () => {
|
||||
trackPushButtonClick();
|
||||
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useClientAnalytics } from "#/hooks/use-client-analytics";
|
||||
|
||||
export function ContextMenuCTA() {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
const { trackSaasSelfhostedInquiry } = useClientAnalytics();
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "context_menu" });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
@@ -46,7 +45,6 @@ export function ConversationCard({
|
||||
onContextMenuToggle,
|
||||
llmModel,
|
||||
}: ConversationCardProps) {
|
||||
const posthog = usePostHog();
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const { mutateAsync: downloadConversation } = useDownloadConversation();
|
||||
|
||||
@@ -83,7 +81,6 @@ export function ConversationCard({
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
posthog.capture("download_via_vscode_button_clicked");
|
||||
|
||||
// Fetch the VS Code URL from the API
|
||||
if (conversationId) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useClientAnalytics } from "#/hooks/use-client-analytics";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
|
||||
interface HomepageCTAProps {
|
||||
@@ -15,17 +15,17 @@ interface HomepageCTAProps {
|
||||
|
||||
export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
const { trackSaasSelfhostedInquiry } = useClientAnalytics();
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "home_page" });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCTADismissed("homepage");
|
||||
setShouldShowCTA(false);
|
||||
};
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "home_page" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card theme="dark" className={cn("w-[320px] cta-card-gradient")}>
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useClientAnalytics } from "#/hooks/use-client-analytics";
|
||||
import { Card } from "#/ui/card";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ export function InformationRequestForm({
|
||||
}: InformationRequestFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { trackEnterpriseLeadFormSubmitted } = useTracking();
|
||||
const { trackEnterpriseLeadFormSubmitted } = useClientAnalytics();
|
||||
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
||||
@@ -74,7 +74,13 @@ export function PostHogWrapper({ children }: { children: React.ReactNode }) {
|
||||
options={{
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
capture_performance: {
|
||||
network_timing: true,
|
||||
web_vitals: true,
|
||||
},
|
||||
capture_exceptions: true,
|
||||
bootstrap: bootstrapIds,
|
||||
__add_tracing_headers: [window.location.hostname],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { extractSettings } from "#/utils/settings-utils";
|
||||
@@ -21,7 +20,6 @@ interface SettingsFormProps {
|
||||
}
|
||||
|
||||
export function SettingsForm({ settings, onClose }: SettingsFormProps) {
|
||||
const posthog = usePostHog();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const location = useLocation();
|
||||
@@ -38,17 +36,6 @@ export function SettingsForm({ settings, onClose }: SettingsFormProps) {
|
||||
await saveUserSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
|
||||
const agentLlm =
|
||||
((newSettings.agent_settings as Record<string, unknown>)
|
||||
?.llm as Record<string, unknown>) ?? {};
|
||||
posthog.capture("settings_saved", {
|
||||
LLM_MODEL: agentLlm.model,
|
||||
LLM_API_KEY_SET: agentLlm.api_key ? "SET" : "UNSET",
|
||||
SEARCH_API_KEY_SET: newSettings.search_api_key ? "SET" : "UNSET",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
newSettings.remote_runtime_resource_factor,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
@@ -47,7 +46,6 @@ import EventService from "#/api/event-service/event-service.api";
|
||||
import PendingMessageService from "#/api/pending-message-service/pending-message-service.api";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { isBudgetOrCreditError, trackError } from "#/utils/error-handler";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -101,14 +99,12 @@ export function ConversationWebSocketProvider({
|
||||
const hasConnectedRefMain = React.useRef(false);
|
||||
const hasConnectedRefPlanning = React.useRef(false);
|
||||
|
||||
const posthog = usePostHog();
|
||||
const queryClient = useQueryClient();
|
||||
const { addEvent } = useEventStore();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { setExecutionStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
const { trackCreditLimitReached } = useTracking();
|
||||
|
||||
// History loading state - separate per connection
|
||||
const [isLoadingHistoryMain, setIsLoadingHistoryMain] = useState(true);
|
||||
@@ -388,13 +384,9 @@ export function ConversationWebSocketProvider({
|
||||
eventId: errorEvent.id,
|
||||
errorCode: errorEvent.code,
|
||||
},
|
||||
posthog,
|
||||
});
|
||||
if (isBudgetOrCreditError(errorEvent.detail)) {
|
||||
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
|
||||
trackCreditLimitReached({
|
||||
conversationId: conversationId || "unknown",
|
||||
});
|
||||
} else {
|
||||
setErrorMessage(errorEvent.detail);
|
||||
}
|
||||
@@ -412,9 +404,13 @@ export function ConversationWebSocketProvider({
|
||||
toolName: event.tool_name,
|
||||
toolCallId: event.tool_call_id,
|
||||
},
|
||||
posthog,
|
||||
});
|
||||
setErrorMessage(event.error);
|
||||
// Use friendly i18n message for budget/credit errors instead of raw error
|
||||
if (isBudgetOrCreditError(event.error)) {
|
||||
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
|
||||
} else {
|
||||
setErrorMessage(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
@@ -500,8 +496,6 @@ export function ConversationWebSocketProvider({
|
||||
appendInput,
|
||||
appendOutput,
|
||||
updateMetricsFromStats,
|
||||
trackCreditLimitReached,
|
||||
posthog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -545,13 +539,9 @@ export function ConversationWebSocketProvider({
|
||||
eventId: errorEvent.id,
|
||||
errorCode: errorEvent.code,
|
||||
},
|
||||
posthog,
|
||||
});
|
||||
if (isBudgetOrCreditError(errorEvent.detail)) {
|
||||
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
|
||||
trackCreditLimitReached({
|
||||
conversationId: conversationId || "unknown",
|
||||
});
|
||||
} else {
|
||||
setErrorMessage(errorEvent.detail);
|
||||
}
|
||||
@@ -569,9 +559,13 @@ export function ConversationWebSocketProvider({
|
||||
toolName: event.tool_name,
|
||||
toolCallId: event.tool_call_id,
|
||||
},
|
||||
posthog,
|
||||
});
|
||||
setErrorMessage(event.error);
|
||||
// Use friendly i18n message for budget/credit errors instead of raw error
|
||||
if (isBudgetOrCreditError(event.error)) {
|
||||
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
|
||||
} else {
|
||||
setErrorMessage(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
@@ -683,8 +677,6 @@ export function ConversationWebSocketProvider({
|
||||
readConversationFile,
|
||||
setPlanContent,
|
||||
updateMetricsFromStats,
|
||||
trackCreditLimitReached,
|
||||
posthog,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { usePostHog } from "posthog-js/react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface AcceptTosVariables {
|
||||
redirectUrl: string;
|
||||
@@ -16,7 +15,6 @@ interface AcceptTosResponse {
|
||||
export const useAcceptTos = () => {
|
||||
const posthog = usePostHog();
|
||||
const navigate = useNavigate();
|
||||
const { trackUserSignupCompleted } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ redirectUrl }: AcceptTosVariables) => {
|
||||
@@ -29,9 +27,6 @@ export const useAcceptTos = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: (response, { redirectUrl }) => {
|
||||
// Track user signup completion
|
||||
trackUserSignupCompleted();
|
||||
|
||||
// Get the redirect URL from the response
|
||||
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useAddGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackGitProviderConnected } = useTracking();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
@@ -15,18 +13,7 @@ export const useAddGitProviders = () => {
|
||||
}: {
|
||||
providers: Record<Provider, ProviderToken>;
|
||||
}) => SecretsService.addGitProvider(providers),
|
||||
onSuccess: async (_, { providers }) => {
|
||||
// Track which providers were connected (filter out empty tokens)
|
||||
const connectedProviders = Object.entries(providers)
|
||||
.filter(([, value]) => value.token && value.token.trim() !== "")
|
||||
.map(([key]) => key);
|
||||
|
||||
if (connectedProviders.length > 0) {
|
||||
trackGitProviderConnected({
|
||||
providers: connectedProviders,
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import V1ConversationService from "#/api/conversation-service/v1-conversation-se
|
||||
import { PluginSpec } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
@@ -30,7 +29,6 @@ interface CreateConversationResponse {
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackConversationCreated } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
@@ -72,11 +70,7 @@ export const useCreateConversation = () => {
|
||||
is_v1: true,
|
||||
};
|
||||
},
|
||||
onSuccess: async (_, { repository }) => {
|
||||
trackConversationCreated({
|
||||
hasRepository: !!repository,
|
||||
});
|
||||
|
||||
onSuccess: async () => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import {
|
||||
MCPConfig,
|
||||
Settings,
|
||||
SettingsScope,
|
||||
SettingsValue,
|
||||
} from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
import { Settings, SettingsScope, SettingsValue } from "#/types/settings";
|
||||
|
||||
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
|
||||
|
||||
@@ -62,26 +55,11 @@ const saveSettingsMutationFn = async (
|
||||
};
|
||||
|
||||
export const useSaveSettings = (scope: SettingsScope = "personal") => {
|
||||
const posthog = usePostHog();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: currentSettings } = useSettings(scope);
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: SettingsUpdate) => {
|
||||
const nextMcpConfig = settings.mcp_config as MCPConfig | undefined;
|
||||
const currentMcpConfig = currentSettings?.mcp_config as
|
||||
| MCPConfig
|
||||
| undefined;
|
||||
|
||||
if (nextMcpConfig && currentMcpConfig !== nextMcpConfig) {
|
||||
posthog.capture("mcp_config_updated", {
|
||||
has_mcp_config: true,
|
||||
sse_servers_count: nextMcpConfig.sse_servers?.length || 0,
|
||||
stdio_servers_count: nextMcpConfig.stdio_servers?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
await saveSettingsMutationFn(scope, settings);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -7,20 +7,27 @@ type SubmitOnboardingArgs = {
|
||||
selections: Record<string, string | string[]>;
|
||||
};
|
||||
|
||||
interface OnboardingResponse {
|
||||
status: string;
|
||||
redirect_url: string;
|
||||
}
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ selections }: SubmitOnboardingArgs) => {
|
||||
// Mark onboarding as complete
|
||||
await openHands.post("/api/complete_onboarding");
|
||||
return { selections };
|
||||
const { data } = await openHands.post<OnboardingResponse>(
|
||||
"/api/onboarding",
|
||||
{ selections },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
|
||||
const finalRedirectUrl = "/";
|
||||
const finalRedirectUrl = data.redirect_url || "/";
|
||||
// Check if the redirect URL is an external URL (starts with http or https)
|
||||
if (
|
||||
finalRedirectUrl.startsWith("http://") ||
|
||||
|
||||
43
frontend/src/hooks/use-client-analytics.ts
Normal file
43
frontend/src/hooks/use-client-analytics.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
|
||||
/**
|
||||
* Lightweight client-side analytics for UI-only events that have
|
||||
* no natural server round-trip. All server-side business events
|
||||
* go through the backend AnalyticsService instead.
|
||||
*/
|
||||
export const useClientAnalytics = () => {
|
||||
const posthog = usePostHog();
|
||||
|
||||
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
|
||||
posthog?.capture("saas_selfhosted_inquiry", {
|
||||
location,
|
||||
});
|
||||
};
|
||||
|
||||
const trackEnterpriseLeadFormSubmitted = ({
|
||||
requestType,
|
||||
name,
|
||||
company,
|
||||
email,
|
||||
message,
|
||||
}: {
|
||||
requestType: string;
|
||||
name: string;
|
||||
company: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}) => {
|
||||
posthog?.capture("enterprise_lead_form_submitted", {
|
||||
request_type: requestType,
|
||||
name,
|
||||
company,
|
||||
email,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackSaasSelfhostedInquiry,
|
||||
trackEnterpriseLeadFormSubmitted,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { downloadBlob } from "#/utils/utils";
|
||||
@@ -7,13 +6,11 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export const useDownloadConversation = () => {
|
||||
const posthog = usePostHog();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["conversations", "download"],
|
||||
mutationFn: async (conversationId: string) => {
|
||||
posthog.capture("download_trajectory_button_clicked");
|
||||
const blob =
|
||||
await V1ConversationService.downloadConversation(conversationId);
|
||||
downloadBlob(blob, `conversation_${conversationId}.zip`);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useConfig } from "./query/use-config";
|
||||
import { useSettings } from "./query/use-settings";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
/**
|
||||
* Hook that provides tracking functions with automatic data collection
|
||||
* from available hooks (config, settings, etc.)
|
||||
*/
|
||||
export const useTracking = () => {
|
||||
const posthog = usePostHog();
|
||||
const { data: config } = useConfig();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
// Common properties included in all tracking events
|
||||
const commonProperties = {
|
||||
app_surface: config?.app_mode || "unknown",
|
||||
plan_tier: null,
|
||||
current_url: window.location.href,
|
||||
user_email: settings?.email || settings?.git_user_email || null,
|
||||
};
|
||||
|
||||
const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {
|
||||
posthog.capture("login_button_clicked", {
|
||||
provider,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackConversationCreated = ({
|
||||
hasRepository,
|
||||
}: {
|
||||
hasRepository: boolean;
|
||||
}) => {
|
||||
posthog.capture("conversation_created", {
|
||||
has_repository: hasRepository,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackPushButtonClick = () => {
|
||||
posthog.capture("push_button_clicked", {
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackPullButtonClick = () => {
|
||||
posthog.capture("pull_button_clicked", {
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCreatePrButtonClick = () => {
|
||||
posthog.capture("create_pr_button_clicked", {
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackGitProviderConnected = ({
|
||||
providers,
|
||||
}: {
|
||||
providers: string[];
|
||||
}) => {
|
||||
posthog.capture("git_provider_connected", {
|
||||
providers,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackUserSignupCompleted = () => {
|
||||
posthog.capture("user_signup_completed", {
|
||||
signup_timestamp: new Date().toISOString(),
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCreditsPurchased = ({
|
||||
amountUsd,
|
||||
stripeSessionId,
|
||||
}: {
|
||||
amountUsd: number;
|
||||
stripeSessionId: string;
|
||||
}) => {
|
||||
posthog.capture("credits_purchased", {
|
||||
amount_usd: amountUsd,
|
||||
stripe_session_id: stripeSessionId,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCreditLimitReached = ({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) => {
|
||||
posthog.capture("credit_limit_reached", {
|
||||
conversation_id: conversationId,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackAddTeamMembersButtonClick = () => {
|
||||
posthog.capture("exp_add_team_members", {
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackOnboardingCompleted = ({
|
||||
role,
|
||||
orgSize,
|
||||
useCase,
|
||||
}: {
|
||||
role?: string;
|
||||
orgSize?: string;
|
||||
useCase?: string[];
|
||||
}) => {
|
||||
posthog.capture("onboarding_completed", {
|
||||
role,
|
||||
org_size: orgSize,
|
||||
use_case: useCase,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
|
||||
posthog.capture("saas_selfhosted_inquiry", {
|
||||
location,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackEnterpriseLeadFormSubmitted = ({
|
||||
requestType,
|
||||
name,
|
||||
company,
|
||||
email,
|
||||
message,
|
||||
}: {
|
||||
requestType: "saas" | "self-hosted";
|
||||
name: string;
|
||||
company: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}) => {
|
||||
posthog.capture("enterprise_lead_form_submitted", {
|
||||
request_type: requestType,
|
||||
name,
|
||||
company,
|
||||
email,
|
||||
message,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackLoginButtonClick,
|
||||
trackConversationCreated,
|
||||
trackPushButtonClick,
|
||||
trackPullButtonClick,
|
||||
trackCreatePrButtonClick,
|
||||
trackGitProviderConnected,
|
||||
trackUserSignupCompleted,
|
||||
trackCreditsPurchased,
|
||||
trackCreditLimitReached,
|
||||
trackAddTeamMembersButtonClick,
|
||||
trackOnboardingCompleted,
|
||||
trackSaasSelfhostedInquiry,
|
||||
trackEnterpriseLeadFormSubmitted,
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
import { getActiveOrganizationUser } from "#/utils/org/permission-checks";
|
||||
@@ -52,39 +51,20 @@ export const clientLoader = async () => {
|
||||
function BillingSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { trackCreditsPurchased } = useTracking();
|
||||
const { data: me } = useMe();
|
||||
const { hasPermission } = usePermission(me?.role ?? "member");
|
||||
const canAddCredits = !!me && hasPermission("add_credits");
|
||||
const checkoutStatus = searchParams.get("checkout");
|
||||
const amount = searchParams.get("amount");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (checkoutStatus === "success") {
|
||||
// Track credits purchased if we have the necessary data
|
||||
if (amount && sessionId) {
|
||||
trackCreditsPurchased({
|
||||
amountUsd: parseFloat(amount),
|
||||
stripeSessionId: sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS));
|
||||
|
||||
setSearchParams({});
|
||||
} else if (checkoutStatus === "cancel") {
|
||||
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [
|
||||
checkoutStatus,
|
||||
amount,
|
||||
sessionId,
|
||||
setSearchParams,
|
||||
t,
|
||||
trackCreditsPurchased,
|
||||
]);
|
||||
}, [checkoutStatus, setSearchParams, t]);
|
||||
|
||||
return <PaymentForm isDisabled={!canAddCredits} />;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
||||
import { useSubmitOnboarding } from "#/hooks/mutation/use-submit-onboarding";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import {
|
||||
ONBOARDING_FORM,
|
||||
OnboardingQuestion,
|
||||
@@ -81,9 +79,7 @@ function OnboardingForm() {
|
||||
const navigate = useNavigate();
|
||||
const loaderData = useLoaderData<typeof clientLoader>();
|
||||
const config = loaderData?.config;
|
||||
const { data: me } = useMe();
|
||||
const { mutate: submitOnboarding } = useSubmitOnboarding();
|
||||
const { trackOnboardingCompleted } = useTracking();
|
||||
|
||||
const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode(
|
||||
config?.feature_flags?.deployment_mode,
|
||||
@@ -167,26 +163,6 @@ function OnboardingForm() {
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
submitOnboarding({ selections: answers });
|
||||
|
||||
// Track onboarding completion based on deployment mode:
|
||||
// - Cloud mode: track ALL users
|
||||
// - Self-hosted mode: track only org owners (SuperAdmin)
|
||||
const deploymentMode = config?.feature_flags?.deployment_mode;
|
||||
const isOwner = me?.role === "owner";
|
||||
const shouldTrack =
|
||||
deploymentMode === "cloud" ||
|
||||
(deploymentMode === "self_hosted" && isOwner);
|
||||
|
||||
if (shouldTrack) {
|
||||
trackOnboardingCompleted({
|
||||
role: typeof answers.role === "string" ? answers.role : undefined,
|
||||
orgSize:
|
||||
typeof answers.org_size === "string" ? answers.org_size : undefined,
|
||||
useCase: Array.isArray(answers.use_case)
|
||||
? answers.use_case
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export function handleStatusMessage(message: StatusMessage) {
|
||||
message: message.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: message.id },
|
||||
posthog: undefined, // Service file - can't use hooks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PostHog } from "posthog-js";
|
||||
import { handleStatusMessage } from "#/services/actions";
|
||||
import { displayErrorToast } from "./custom-toast-handlers";
|
||||
|
||||
@@ -7,31 +6,19 @@ interface ErrorDetails {
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
msgId?: string;
|
||||
posthog?: PostHog;
|
||||
}
|
||||
|
||||
export function trackError({
|
||||
message,
|
||||
source,
|
||||
metadata = {},
|
||||
posthog,
|
||||
}: ErrorDetails) {
|
||||
if (!posthog) return;
|
||||
|
||||
const error = new Error(message);
|
||||
posthog.captureException(error, {
|
||||
error_source: source || "unknown",
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
// PostHog capture removed — error tracking is now handled server-side
|
||||
export function trackError(
|
||||
details: ErrorDetails, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
): void {}
|
||||
|
||||
export function showErrorToast({
|
||||
message,
|
||||
source,
|
||||
metadata = {},
|
||||
posthog,
|
||||
}: ErrorDetails) {
|
||||
trackError({ message, source, metadata, posthog });
|
||||
trackError({ message, source, metadata });
|
||||
displayErrorToast(message);
|
||||
}
|
||||
|
||||
@@ -40,9 +27,8 @@ export function showChatError({
|
||||
source,
|
||||
metadata = {},
|
||||
msgId,
|
||||
posthog,
|
||||
}: ErrorDetails) {
|
||||
trackError({ message, source, metadata, posthog });
|
||||
trackError({ message, source, metadata });
|
||||
handleStatusMessage({
|
||||
type: "error",
|
||||
message,
|
||||
|
||||
168
openhands/analytics/EVENTS.md
Normal file
168
openhands/analytics/EVENTS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# PostHog Analytics — Event Catalog
|
||||
|
||||
*Last updated: 2026-03-27*
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Analytics is split into three lanes:
|
||||
|
||||
1. **Server-side business events** (SaaS only) — captured through a centralized `AnalyticsService`. Covers the core product lifecycle: signups, logins, conversations, credits, activation, onboarding.
|
||||
2. **Client-side UI events** (SaaS only) — captured via `useClientAnalytics` hook for UI-only interactions that have no natural server round-trip (e.g. enterprise CTA clicks, lead form submissions).
|
||||
3. **Frontend automatic instrumentation** (SaaS and OSS) — web vitals, error tracking, network timing, pageviews. No explicit event code required.
|
||||
|
||||
Every event respects user consent.
|
||||
|
||||
---
|
||||
|
||||
## Backend Events
|
||||
|
||||
| # | Event | When It Fires | Key Properties |
|
||||
|---|---|---|---|
|
||||
| 1 | **user signed up** | New user completes OAuth registration (once per user) | `idp`, `email_domain`, `invitation_source` |
|
||||
| 2 | **user logged in** | Every successful authentication (Keycloak or device auth) | `idp` |
|
||||
| 3 | **conversation created** | A new conversation is initialized | `conversation_id`, `trigger`, `llm_model`, `agent_type`, `has_repository` |
|
||||
| 4 | **conversation finished** | Conversation reaches a successful or stopped terminal state | `conversation_id`, `terminal_state`, `accumulated_cost_usd`, `prompt_tokens`, `completion_tokens`, `llm_model`, `trigger` |
|
||||
| 5 | **conversation errored** | Conversation reaches an error or stuck state | `conversation_id`, `error_type`\*, `error_message`, `llm_model`, `terminal_state` |
|
||||
| 6 | **credit purchased** | Stripe checkout completes successfully | `amount_usd`, `credit_balance_before`, `credit_balance_after` |
|
||||
| 7 | **credit limit reached** | Conversation fails due to insufficient credits (fires alongside #5) | `conversation_id`, `credit_balance`, `llm_model` |
|
||||
| 8 | **user activated** | User's first conversation finishes successfully (once per user) | `conversation_id`, `time_to_activate_seconds`, `llm_model`, `trigger` |
|
||||
| 9 | **git provider connected** | User connects a git provider (GitHub, GitLab, etc.) | `provider_type` |
|
||||
| 10 | **onboarding completed** | User submits the onboarding form | `role`, `org_size`, `use_case` |
|
||||
| 11 | **saas selfhosted inquiry** | User clicks "Learn More" on an enterprise CTA | `location` |
|
||||
| 12 | **enterprise lead form submitted** | User submits the enterprise contact form | `request_type`, `name`, `company`, `email`, `message` |
|
||||
|
||||
\*Error types: `budget_exceeded`, `model_error`, `runtime_error`, `timeout`, `user_cancelled`, `unknown`
|
||||
|
||||
Every event also carries: `app_mode` (saas/oss), `is_feature_env`, and `org_id` when available.
|
||||
|
||||
**Note:** Backend events fire in SaaS only. The `AnalyticsService` is never initialized in OSS — `get_analytics_service()` returns `None` and all call sites are guarded.
|
||||
|
||||
---
|
||||
|
||||
## Identity & Group Tracking (SaaS only)
|
||||
|
||||
| Action | When | What's Set |
|
||||
|---|---|---|
|
||||
| **Identify user** | Login (Keycloak or device auth) | Person: `email`, `org_id`, `org_name`, `idp`, `last_login_at`. Group (org): `org_name`, `member_count`. |
|
||||
| **Update person** | Signup, org switch | `signed_up_at` on signup; `org_id`, `org_name` on org switch |
|
||||
| **Update org group** | Login, onboarding | `member_count`, `onboarding_completed_at` |
|
||||
|
||||
---
|
||||
|
||||
## Client-Side UI Events (SaaS only)
|
||||
|
||||
A small set of explicit frontend events captured via the `useClientAnalytics` hook. These are UI interactions with no natural server round-trip — they fire directly through the PostHog JS SDK.
|
||||
|
||||
| # | Event | When It Fires | Key Properties |
|
||||
|---|---|---|---|
|
||||
| 1 | **saas selfhosted inquiry** | User clicks "Learn More" on an enterprise CTA (login page, homepage, context menu, device verify) | `location` |
|
||||
| 2 | **enterprise lead form submitted** | User submits the enterprise contact form | `request_type`, `name`, `company`, `email`, `message` |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Automatic Instrumentation (SaaS and OSS)
|
||||
|
||||
The frontend initializes PostHog in both SaaS and OSS deployments (OSS uses a hardcoded fallback project key).
|
||||
|
||||
| Feature | What It Captures |
|
||||
|---|---|
|
||||
| **Web Vitals** | LCP, FCP, INP, CLS |
|
||||
| **Network Timing** | API request latencies |
|
||||
| **Error Tracking** | Uncaught JavaScript exceptions |
|
||||
| **Pageviews** | Automatic page navigation tracking |
|
||||
| **Session Linking** | Correlates frontend sessions with backend events via `X-POSTHOG-SESSION-ID` tracing header (SaaS only) |
|
||||
|
||||
Person profiles are created for identified users only. Session replay is **not configured in code** — whether it is active depends on the PostHog project's server-side settings.
|
||||
|
||||
---
|
||||
|
||||
## Event Lifecycle
|
||||
|
||||
```
|
||||
User signs up → user signed up + identify
|
||||
User logs in → user logged in + identify
|
||||
Onboarding → onboarding completed
|
||||
Git connect → git provider connected
|
||||
|
||||
Conversation starts → conversation created
|
||||
├─ Finishes OK → conversation finished (+ user activated if first ever)
|
||||
├─ Errors → conversation errored (+ credit limit reached if budget)
|
||||
└─ Stopped → conversation finished
|
||||
|
||||
Credit purchase → credit purchased
|
||||
Org switch → person properties updated (no event)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboards (Staging Project)
|
||||
|
||||
All dashboards below are tagged `analytics-overhaul` in the **Staging** PostHog project (ID 163845). They were created on 2026-03-05/06.
|
||||
|
||||
### [Conversion Funnel](https://us.posthog.com/project/163845/dashboard/1334830)
|
||||
|
||||
4-step ordered funnel with 30-day conversion window.
|
||||
|
||||
| Step | Event |
|
||||
|---|---|
|
||||
| 1 | user signed up |
|
||||
| 2 | conversation created |
|
||||
| 3 | conversation finished |
|
||||
| 4 | credit purchased |
|
||||
|
||||
### [User Retention](https://us.posthog.com/project/163845/dashboard/1334831)
|
||||
|
||||
Weekly trends comparing new signups to returning users who create conversations. Note: this is a trends approximation (signups vs conversation DAU), not a true cohort retention chart.
|
||||
|
||||
| Insight | Type | Events |
|
||||
|---|---|---|
|
||||
| Weekly Retention: Signup to Conversation | Trends (weekly) | `user signed up` (total), `conversation created` (DAU) |
|
||||
|
||||
### [Credit Usage](https://us.posthog.com/project/163845/dashboard/1334832)
|
||||
|
||||
| Insight | Type | Breakdown |
|
||||
|---|---|---|
|
||||
| Credit Purchased by Org | Trends (weekly) | `credit purchased` by `org_id` |
|
||||
| Credit Limit Reached by Org | Trends (weekly) | `credit limit reached` by `org_id` |
|
||||
| Avg Credit Balance After Purchase | Trends (weekly) | avg `credit_balance_after` on `credit purchased` |
|
||||
|
||||
### [Churn Signals](https://us.posthog.com/project/163845/dashboard/1334833)
|
||||
|
||||
| Insight | Type | Description |
|
||||
|---|---|---|
|
||||
| Churn Signal: Credit Limit Without Purchase | HogQL table | Users who hit credit limit in last 90 days with no subsequent purchase |
|
||||
|
||||
### [Usage Patterns](https://us.posthog.com/project/163845/dashboard/1334834)
|
||||
|
||||
| Insight | Type | Breakdown |
|
||||
|---|---|---|
|
||||
| Conversations by Model | Trends (weekly) | `conversation finished` by `llm_model` |
|
||||
| Conversations by Trigger | Trends (weekly) | `conversation finished` by `trigger` |
|
||||
| Avg Cost per Conversation | Trends (weekly) | avg `accumulated_cost_usd` on `conversation finished` |
|
||||
|
||||
### [Product Quality](https://us.posthog.com/project/163845/dashboard/1334835)
|
||||
|
||||
| Insight | Type | Breakdown |
|
||||
|---|---|---|
|
||||
| Success Rate by Terminal State | Trends (weekly) | `conversation finished` by `terminal_state` |
|
||||
| Error Rate by Model | Trends (weekly) | `conversation errored` by `llm_model` |
|
||||
|
||||
### [Frontend Health](https://us.posthog.com/project/163845/dashboard/1337892)
|
||||
|
||||
| Insight | Type | Description |
|
||||
|---|---|---|
|
||||
| Web Vitals -- LCP | Trends (daily, 30d) | Avg Largest Contentful Paint |
|
||||
| Web Vitals -- FCP | Trends (daily, 30d) | Avg First Contentful Paint |
|
||||
| Web Vitals -- INP | Trends (daily, 30d) | Avg Interaction to Next Paint |
|
||||
| Web Vitals -- CLS | Trends (daily, 30d) | Avg Cumulative Layout Shift |
|
||||
| JS Error Rate | Trends (daily, 30d) | Total `$exception` events per day |
|
||||
| Top JS Errors | Table (30d) | `$exception` broken down by `$exception_type` |
|
||||
|
||||
---
|
||||
|
||||
## Consent & Privacy
|
||||
|
||||
- All backend events are gated on `user_consents_to_analytics`. No server-side data is sent when consent is absent.
|
||||
- Frontend consent is synced: `posthog.opt_in_capturing()` / `posthog.opt_out_capturing()` mirrors the backend setting.
|
||||
- OSS deployments send frontend-only automatic instrumentation (web vitals, errors, pageviews) to a shared PostHog project. No backend business events are sent.
|
||||
- Feature/staging environments are isolated — distinct IDs are prefixed with `FEATURE_` so test traffic never pollutes production data.
|
||||
60
openhands/analytics/__init__.py
Normal file
60
openhands/analytics/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""OpenHands analytics package.
|
||||
|
||||
Provides a module-level singleton pattern for the AnalyticsService.
|
||||
|
||||
Usage::
|
||||
|
||||
from openhands.analytics import init_analytics_service, get_analytics_service
|
||||
|
||||
# At application startup:
|
||||
init_analytics_service(api_key=..., host=..., app_mode=..., is_feature_env=...)
|
||||
|
||||
# At call sites:
|
||||
svc = get_analytics_service()
|
||||
if svc:
|
||||
svc.capture(...)
|
||||
"""
|
||||
|
||||
from openhands.analytics.analytics_context import AnalyticsContext, resolve_context
|
||||
from openhands.analytics.analytics_service import AnalyticsService
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
_analytics_service: AnalyticsService | None = None
|
||||
|
||||
|
||||
def init_analytics_service(
|
||||
api_key: str,
|
||||
host: str,
|
||||
app_mode: AppMode,
|
||||
is_feature_env: bool,
|
||||
) -> AnalyticsService:
|
||||
"""Create and store the module-level AnalyticsService singleton.
|
||||
|
||||
Returns the newly created instance. Subsequent calls to
|
||||
:func:`get_analytics_service` will return the same object.
|
||||
"""
|
||||
global _analytics_service
|
||||
_analytics_service = AnalyticsService(
|
||||
api_key=api_key,
|
||||
host=host,
|
||||
app_mode=app_mode,
|
||||
is_feature_env=is_feature_env,
|
||||
)
|
||||
return _analytics_service
|
||||
|
||||
|
||||
def get_analytics_service() -> AnalyticsService | None:
|
||||
"""Return the module-level AnalyticsService singleton.
|
||||
|
||||
Returns ``None`` if :func:`init_analytics_service` has not been called yet.
|
||||
"""
|
||||
return _analytics_service
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AnalyticsContext',
|
||||
'AnalyticsService',
|
||||
'get_analytics_service',
|
||||
'init_analytics_service',
|
||||
'resolve_context',
|
||||
]
|
||||
43
openhands/analytics/analytics_constants.py
Normal file
43
openhands/analytics/analytics_constants.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Event name constants for PostHog analytics.
|
||||
|
||||
Naming convention: PostHog recommended object-action, lowercase with spaces.
|
||||
"""
|
||||
|
||||
# Phase 1 events
|
||||
USER_LOGGED_IN = 'user logged in'
|
||||
|
||||
# Phase 2 events
|
||||
USER_SIGNED_UP = 'user signed up'
|
||||
CONVERSATION_CREATED = 'conversation created'
|
||||
CONVERSATION_FINISHED = 'conversation finished'
|
||||
CONVERSATION_ERRORED = 'conversation errored'
|
||||
CREDIT_PURCHASED = 'credit purchased'
|
||||
CREDIT_LIMIT_REACHED = 'credit limit reached'
|
||||
|
||||
# Phase 4 events
|
||||
USER_ACTIVATED = 'user activated'
|
||||
GIT_PROVIDER_CONNECTED = 'git provider connected'
|
||||
ONBOARDING_COMPLETED = 'onboarding completed'
|
||||
SETTINGS_SAVED = 'settings saved'
|
||||
MCP_CONFIG_UPDATED = 'mcp config updated'
|
||||
TRAJECTORY_DOWNLOADED = 'trajectory downloaded'
|
||||
TEAM_MEMBERS_INVITED = 'team members invited'
|
||||
|
||||
# Enterprise lead-gen events
|
||||
SAAS_SELFHOSTED_INQUIRY = 'saas selfhosted inquiry'
|
||||
ENTERPRISE_LEAD_FORM_SUBMITTED = 'enterprise lead form submitted'
|
||||
|
||||
# UI interaction events
|
||||
DOWNLOAD_VIA_VSCODE_BUTTON_CLICKED = 'download via vscode button clicked'
|
||||
SETTINGS_SAVED = 'settings saved'
|
||||
LOGIN_BUTTON_CLICKED = 'login button clicked'
|
||||
PUSH_BUTTON_CLICKED = 'push button clicked'
|
||||
PULL_BUTTON_CLICKED = 'pull button clicked'
|
||||
CREATE_PR_BUTTON_CLICKED = 'create pr button clicked'
|
||||
EXP_ADD_TEAM_MEMBERS = 'exp add team members'
|
||||
MCP_CONFIG_UPDATED = 'mcp config updated'
|
||||
DOWNLOAD_TRAJECTORY_BUTTON_CLICKED = 'download trajectory button clicked'
|
||||
EXCEPTION_CAPTURED = 'exception captured'
|
||||
|
||||
# Error tracking (replaces frontend captureException)
|
||||
ERROR_CAPTURED = 'error captured'
|
||||
81
openhands/analytics/analytics_context.py
Normal file
81
openhands/analytics/analytics_context.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""AnalyticsContext: resolution helper for analytics call sites.
|
||||
|
||||
Provides a dataclass that bundles user_id, consent status, org_id, and the
|
||||
full user object into a single value. The async ``resolve_context`` factory
|
||||
performs the UserStore lookup with full error isolation so callers never need
|
||||
try/except around user resolution.
|
||||
|
||||
This module must NOT import from enterprise/ at module level. The UserStore
|
||||
import is deferred to the ``resolve_context`` function body, matching the
|
||||
established pattern used throughout the codebase (e.g., auth.py, oauth_device.py,
|
||||
conversation_callback_utils.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Sentinel reused by resolve_context for the safe-default path.
|
||||
_SAFE_DEFAULT_KWARGS: dict[str, Any] = {
|
||||
'consented': False,
|
||||
'org_id': None,
|
||||
'user': None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsContext:
|
||||
"""Resolved analytics context for a single user.
|
||||
|
||||
Attributes:
|
||||
user_id: Raw user ID string (always set).
|
||||
consented: Whether the user opted in to analytics. ``False`` is the
|
||||
safe default (None / missing / error all map to False).
|
||||
org_id: String org_id derived from ``user.current_org_id``, or
|
||||
``None`` when unavailable.
|
||||
user: The full User ORM object, or ``None`` when lookup failed.
|
||||
Typed as ``Any`` to avoid importing enterprise types.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
consented: bool
|
||||
org_id: str | None
|
||||
user: Any | None
|
||||
|
||||
|
||||
async def resolve_context(user_id: str) -> AnalyticsContext:
|
||||
"""Resolve a user_id into a fully-populated :class:`AnalyticsContext`.
|
||||
|
||||
Performs the UserStore lookup, extracts consent and org_id, and wraps
|
||||
everything in try/except so no exception ever leaks to the caller.
|
||||
|
||||
Returns a safe default (consented=False, org_id=None, user=None) when the
|
||||
user is not found or any error occurs.
|
||||
"""
|
||||
try:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
|
||||
if user is None:
|
||||
return AnalyticsContext(user_id=user_id, **_SAFE_DEFAULT_KWARGS)
|
||||
|
||||
# None = undecided = not consented (same logic as auth.py)
|
||||
consented = user.user_consents_to_analytics is True
|
||||
org_id = str(user.current_org_id) if user.current_org_id else None
|
||||
|
||||
return AnalyticsContext(
|
||||
user_id=user_id,
|
||||
consented=consented,
|
||||
org_id=org_id,
|
||||
user=user,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'resolve_context failed for user_id=%s, returning safe default',
|
||||
user_id,
|
||||
)
|
||||
return AnalyticsContext(user_id=user_id, **_SAFE_DEFAULT_KWARGS)
|
||||
780
openhands/analytics/analytics_service.py
Normal file
780
openhands/analytics/analytics_service.py
Normal file
@@ -0,0 +1,780 @@
|
||||
"""Core analytics service for OpenHands.
|
||||
|
||||
Provides a thin wrapper around the PostHog SDK with:
|
||||
- Consent gate: all calls are no-ops when consented=False
|
||||
- OSS/SaaS dual-mode: $process_person_profile is set to False in OSS mode;
|
||||
set_person_properties and group_identify are SaaS-only
|
||||
- Common properties: app_mode, is_feature_env added to every event
|
||||
- Feature-env distinct_id prefix: FEATURE_ prefix for staging/feature envs
|
||||
- SDK error isolation: all exceptions are caught and logged, never raised
|
||||
|
||||
This module must NOT import from enterprise/. It receives all configuration
|
||||
via constructor args.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
from openhands.analytics.analytics_constants import (
|
||||
CONVERSATION_CREATED,
|
||||
CONVERSATION_ERRORED,
|
||||
CONVERSATION_FINISHED,
|
||||
CREDIT_LIMIT_REACHED,
|
||||
CREDIT_PURCHASED,
|
||||
ENTERPRISE_LEAD_FORM_SUBMITTED,
|
||||
ERROR_CAPTURED,
|
||||
GIT_PROVIDER_CONNECTED,
|
||||
MCP_CONFIG_UPDATED,
|
||||
ONBOARDING_COMPLETED,
|
||||
SAAS_SELFHOSTED_INQUIRY,
|
||||
SETTINGS_SAVED,
|
||||
TEAM_MEMBERS_INVITED,
|
||||
TRAJECTORY_DOWNLOADED,
|
||||
USER_ACTIVATED,
|
||||
USER_LOGGED_IN,
|
||||
USER_SIGNED_UP,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Server-side analytics service backed by PostHog.
|
||||
|
||||
Args:
|
||||
api_key: PostHog project API key. Pass an empty string to disable.
|
||||
host: PostHog ingest host URL.
|
||||
app_mode: AppMode.OPENHANDS (OSS) or AppMode.SAAS.
|
||||
is_feature_env: True when running in a feature/staging environment.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
host: str,
|
||||
app_mode: AppMode,
|
||||
is_feature_env: bool,
|
||||
) -> None:
|
||||
self._app_mode = app_mode
|
||||
self._is_feature_env = is_feature_env
|
||||
self._client: Posthog = Posthog(
|
||||
project_api_key=api_key,
|
||||
host=host,
|
||||
disabled=not api_key,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def capture(
|
||||
self,
|
||||
distinct_id: str,
|
||||
event: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Capture a server-side event.
|
||||
|
||||
Consent gate: returns immediately when consented=False.
|
||||
Common properties (app_mode, is_feature_env, and optionally org_id /
|
||||
$session_id / $process_person_profile) are merged with caller-provided
|
||||
properties before forwarding to PostHog.
|
||||
"""
|
||||
if not consented:
|
||||
return
|
||||
|
||||
merged = self._common_properties(org_id=org_id, session_id=session_id)
|
||||
if properties:
|
||||
merged.update(properties)
|
||||
|
||||
try:
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id(distinct_id),
|
||||
event=event,
|
||||
properties=merged,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('AnalyticsService.capture failed')
|
||||
|
||||
def set_person_properties(
|
||||
self,
|
||||
distinct_id: str,
|
||||
properties: dict[str, Any],
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Set person properties in PostHog (SaaS-only).
|
||||
|
||||
No-op in OSS mode or when consented=False.
|
||||
"""
|
||||
if not consented:
|
||||
return
|
||||
if self._app_mode != AppMode.SAAS:
|
||||
return
|
||||
|
||||
try:
|
||||
self._client.set(
|
||||
distinct_id=self._distinct_id(distinct_id),
|
||||
properties=properties,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('AnalyticsService.set_person_properties failed')
|
||||
|
||||
def group_identify(
|
||||
self,
|
||||
group_type: str,
|
||||
group_key: str,
|
||||
properties: dict[str, Any],
|
||||
distinct_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Associate a group with properties (SaaS-only).
|
||||
|
||||
No-op in OSS mode or when consented=False.
|
||||
"""
|
||||
if not consented:
|
||||
return
|
||||
if self._app_mode != AppMode.SAAS:
|
||||
return
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
'group_type': group_type,
|
||||
'group_key': group_key,
|
||||
'properties': properties,
|
||||
}
|
||||
if distinct_id is not None:
|
||||
kwargs['distinct_id'] = self._distinct_id(distinct_id)
|
||||
self._client.group_identify(**kwargs)
|
||||
except Exception:
|
||||
logger.exception('AnalyticsService.group_identify failed')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Typed event methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def track_user_signed_up(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
idp: str,
|
||||
email_domain: str | None = None,
|
||||
invitation_source: str = 'self_signup',
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'user signed up' event.
|
||||
|
||||
Fired when a new user completes registration.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=USER_SIGNED_UP,
|
||||
properties={
|
||||
'idp': idp,
|
||||
'email_domain': email_domain,
|
||||
'invitation_source': invitation_source,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_user_logged_in(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
idp: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'user logged in' event.
|
||||
|
||||
Fired when an existing user authenticates.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=USER_LOGGED_IN,
|
||||
properties={
|
||||
'idp': idp,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_conversation_created(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
trigger: str | None = None,
|
||||
llm_model: str | None = None,
|
||||
agent_type: str = 'default',
|
||||
has_repository: bool = False,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'conversation created' event.
|
||||
|
||||
Fired when a new conversation is started.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=CONVERSATION_CREATED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'trigger': trigger,
|
||||
'llm_model': llm_model,
|
||||
'agent_type': agent_type,
|
||||
'has_repository': has_repository,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_conversation_finished(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
terminal_state: str,
|
||||
turn_count: int | None = None,
|
||||
accumulated_cost_usd: float | None = None,
|
||||
prompt_tokens: int | None = None,
|
||||
completion_tokens: int | None = None,
|
||||
llm_model: str | None = None,
|
||||
trigger: str | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'conversation finished' event.
|
||||
|
||||
Fired when a conversation reaches a terminal state.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=CONVERSATION_FINISHED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'terminal_state': terminal_state,
|
||||
'turn_count': turn_count,
|
||||
'accumulated_cost_usd': accumulated_cost_usd,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'completion_tokens': completion_tokens,
|
||||
'llm_model': llm_model,
|
||||
'trigger': trigger,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_conversation_errored(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
error_type: str,
|
||||
error_message: str | None = None,
|
||||
llm_model: str | None = None,
|
||||
turn_count: int | None = None,
|
||||
terminal_state: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'conversation errored' event.
|
||||
|
||||
Fired when a conversation ends in an error state.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=CONVERSATION_ERRORED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'error_type': error_type,
|
||||
'error_message': error_message,
|
||||
'llm_model': llm_model,
|
||||
'turn_count': turn_count,
|
||||
'terminal_state': terminal_state,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_credit_purchased(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
amount_usd: float,
|
||||
credit_balance_before: float | None = None,
|
||||
credit_balance_after: float | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'credit purchased' event.
|
||||
|
||||
Fired when a user completes a credit purchase.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=CREDIT_PURCHASED,
|
||||
properties={
|
||||
'amount_usd': amount_usd,
|
||||
'credit_balance_before': credit_balance_before,
|
||||
'credit_balance_after': credit_balance_after,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_credit_limit_reached(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
credit_balance: float | None = None,
|
||||
llm_model: str | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'credit limit reached' event.
|
||||
|
||||
Fired when a conversation is blocked by insufficient credits.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=CREDIT_LIMIT_REACHED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'credit_balance': credit_balance,
|
||||
'llm_model': llm_model,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_error_captured(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
error_type: str,
|
||||
error_message: str | None = None,
|
||||
error_source: str,
|
||||
event_id: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'error captured' event.
|
||||
|
||||
Replaces frontend posthog.captureException() for server-side error tracking.
|
||||
Fired when ConversationErrorEvent, ServerErrorEvent, or AgentErrorEvent
|
||||
is received via webhook.
|
||||
|
||||
Args:
|
||||
distinct_id: User identifier
|
||||
conversation_id: The conversation where the error occurred
|
||||
error_type: Error code/type (e.g., 'MaxIterationsReached', 'ACPPromptError')
|
||||
error_message: Human-readable error detail (truncated to 500 chars)
|
||||
error_source: Origin of error ('conversation', 'server', or 'agent')
|
||||
event_id: Unique event identifier
|
||||
tool_name: For agent errors, the tool that failed
|
||||
tool_call_id: For agent errors, the tool call ID
|
||||
org_id: Organization identifier
|
||||
session_id: PostHog session ID
|
||||
consented: Whether user consented to analytics
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=ERROR_CAPTURED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'error_type': error_type,
|
||||
'error_message': error_message,
|
||||
'error_source': error_source,
|
||||
'event_id': event_id,
|
||||
'tool_name': tool_name,
|
||||
'tool_call_id': tool_call_id,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_user_activated(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
time_to_activate_seconds: float | None = None,
|
||||
llm_model: str | None = None,
|
||||
trigger: str | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'user activated' event.
|
||||
|
||||
Fired when a user completes their first successful conversation.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=USER_ACTIVATED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'time_to_activate_seconds': time_to_activate_seconds,
|
||||
'llm_model': llm_model,
|
||||
'trigger': trigger,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_git_provider_connected(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
provider_type: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'git provider connected' event.
|
||||
|
||||
Fired when a user connects a git provider (GitHub, GitLab, etc.).
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=GIT_PROVIDER_CONNECTED,
|
||||
properties={
|
||||
'provider_type': provider_type,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_onboarding_completed(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
role: str | None = None,
|
||||
org_size: str | None = None,
|
||||
use_case: str | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'onboarding completed' event.
|
||||
|
||||
Fired when a user finishes the onboarding flow.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=ONBOARDING_COMPLETED,
|
||||
properties={
|
||||
'role': role,
|
||||
'org_size': org_size,
|
||||
'use_case': use_case,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_saas_selfhosted_inquiry(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
location: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'saas selfhosted inquiry' event.
|
||||
|
||||
Fired when a user clicks 'Learn More' on an enterprise CTA.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=SAAS_SELFHOSTED_INQUIRY,
|
||||
properties={
|
||||
'location': location,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_enterprise_lead_form_submitted(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
request_type: str,
|
||||
name: str,
|
||||
company: str,
|
||||
email: str,
|
||||
message: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'enterprise lead form submitted' event.
|
||||
|
||||
Fired when a user submits the enterprise contact form.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=ENTERPRISE_LEAD_FORM_SUBMITTED,
|
||||
properties={
|
||||
'request_type': request_type,
|
||||
'name': name,
|
||||
'company': company,
|
||||
'email': email,
|
||||
'message': message,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_settings_saved(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
settings_changed: list[str] | None = None,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'settings saved' event.
|
||||
|
||||
Fired when a user saves their settings.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=SETTINGS_SAVED,
|
||||
properties={
|
||||
'settings_changed': settings_changed,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_mcp_config_updated(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
has_mcp_config: bool = True,
|
||||
sse_servers_count: int = 0,
|
||||
stdio_servers_count: int = 0,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'mcp config updated' event.
|
||||
|
||||
Fired when a user updates their MCP server configuration.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=MCP_CONFIG_UPDATED,
|
||||
properties={
|
||||
'has_mcp_config': has_mcp_config,
|
||||
'sse_servers_count': sse_servers_count,
|
||||
'stdio_servers_count': stdio_servers_count,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_trajectory_downloaded(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'trajectory downloaded' event.
|
||||
|
||||
Fired when a user downloads a conversation trajectory.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=TRAJECTORY_DOWNLOADED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def track_team_members_invited(
|
||||
self,
|
||||
distinct_id: str,
|
||||
*,
|
||||
org_id: str,
|
||||
invited_count: int,
|
||||
successful_count: int,
|
||||
failed_count: int,
|
||||
role: str,
|
||||
session_id: str | None = None,
|
||||
consented: bool = True,
|
||||
) -> None:
|
||||
"""Track 'team members invited' event.
|
||||
|
||||
Fired when a user invites team members to their organization.
|
||||
"""
|
||||
self.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=TEAM_MEMBERS_INVITED,
|
||||
properties={
|
||||
'invited_count': invited_count,
|
||||
'successful_count': successful_count,
|
||||
'failed_count': failed_count,
|
||||
'role': role,
|
||||
},
|
||||
org_id=org_id,
|
||||
session_id=session_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
def identify_user(
|
||||
self,
|
||||
distinct_id: str,
|
||||
consented: bool = True,
|
||||
email: str | None = None,
|
||||
org_id: str | None = None,
|
||||
org_name: str | None = None,
|
||||
idp: str | None = None,
|
||||
orgs: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Identify a user and their org memberships in PostHog.
|
||||
|
||||
Consolidates the duplicated ``set_person_properties`` +
|
||||
``group_identify`` pattern from auth.py and oauth_device.py into
|
||||
a single call.
|
||||
|
||||
Consent gate: returns immediately when ``consented=False``.
|
||||
SaaS gate: returns immediately in OSS mode (person profiles are
|
||||
SaaS-only).
|
||||
|
||||
Args:
|
||||
distinct_id: User ID string.
|
||||
consented: Whether user has opted in to analytics.
|
||||
email: User email address.
|
||||
org_id: Current org ID string.
|
||||
org_name: Current org display name.
|
||||
idp: Identity provider (e.g. ``"github"``, ``"google"``).
|
||||
orgs: List of org dicts with keys ``id``, ``name``,
|
||||
``member_count`` for group_identify calls.
|
||||
"""
|
||||
if not consented:
|
||||
return
|
||||
if self._app_mode != AppMode.SAAS:
|
||||
return
|
||||
|
||||
try:
|
||||
# Person properties
|
||||
self.set_person_properties(
|
||||
distinct_id=distinct_id,
|
||||
properties={
|
||||
'email': email,
|
||||
'org_id': org_id,
|
||||
'org_name': org_name,
|
||||
'plan_tier': None,
|
||||
'idp': idp,
|
||||
'last_login_at': datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
# Group identify for each org membership
|
||||
if orgs:
|
||||
for org in orgs:
|
||||
self.group_identify(
|
||||
group_type='org',
|
||||
group_key=org['id'],
|
||||
properties={
|
||||
'org_name': org.get('name'),
|
||||
'plan_tier': None,
|
||||
'created_at': None,
|
||||
'member_count': org.get('member_count'),
|
||||
},
|
||||
distinct_id=distinct_id,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('AnalyticsService.identify_user failed')
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush and shut down the PostHog client.
|
||||
|
||||
Safe to call multiple times. SDK errors are logged, not raised.
|
||||
"""
|
||||
try:
|
||||
self._client.shutdown()
|
||||
except Exception:
|
||||
logger.exception('AnalyticsService.shutdown failed')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _distinct_id(self, user_id: str) -> str:
|
||||
"""Return the PostHog distinct_id for the given user.
|
||||
|
||||
In feature/staging environments, prefixes with 'FEATURE_' to keep
|
||||
test traffic separate from production profiles.
|
||||
"""
|
||||
if self._is_feature_env:
|
||||
return f'FEATURE_{user_id}'
|
||||
return user_id
|
||||
|
||||
def _common_properties(
|
||||
self,
|
||||
org_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the base property dict included on every event."""
|
||||
props: dict[str, Any] = {
|
||||
'app_mode': self._app_mode.value,
|
||||
'is_feature_env': self._is_feature_env,
|
||||
}
|
||||
|
||||
if org_id is not None:
|
||||
props['org_id'] = org_id
|
||||
|
||||
if session_id is not None:
|
||||
props['$session_id'] = session_id
|
||||
|
||||
# PostHog person profiles are not useful in OSS mode (no user accounts)
|
||||
if self._app_mode != AppMode.SAAS:
|
||||
props['$process_person_profile'] = False
|
||||
|
||||
return props
|
||||
39
openhands/analytics/oss_install_id.py
Normal file
39
openhands/analytics/oss_install_id.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""OSS install ID utility.
|
||||
|
||||
Provides a stable distinct_id for OSS installations by persisting a UUID
|
||||
to a file in the persistence directory.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_or_create_install_id(persistence_dir: Path) -> str:
|
||||
"""Return a stable install UUID for an OSS installation.
|
||||
|
||||
On first call, generates a new UUID4, writes it to
|
||||
``{persistence_dir}/analytics_id.txt``, and returns it.
|
||||
On subsequent calls, reads and returns the stored UUID.
|
||||
|
||||
On any IOError (e.g., read-only filesystem), returns an ephemeral UUID
|
||||
without crashing — the caller still gets a usable ID.
|
||||
"""
|
||||
id_file = persistence_dir / 'analytics_id.txt'
|
||||
|
||||
try:
|
||||
if id_file.exists():
|
||||
stored = id_file.read_text().strip()
|
||||
if stored:
|
||||
return stored
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
id_file.write_text(new_id)
|
||||
except OSError:
|
||||
# File system is not writable — return ephemeral UUID
|
||||
pass
|
||||
|
||||
return new_id
|
||||
@@ -16,6 +16,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.agent_server.models import Success
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.app_server.app_conversation.app_conversation_info_service import (
|
||||
AppConversationInfoService,
|
||||
)
|
||||
@@ -928,6 +929,7 @@ async def export_conversation(
|
||||
app_conversation_service: AppConversationService = (
|
||||
app_conversation_service_dependency
|
||||
),
|
||||
user_context: UserContext = user_context_dependency,
|
||||
):
|
||||
"""Download a conversation trajectory as a zip file.
|
||||
|
||||
@@ -945,6 +947,25 @@ async def export_conversation(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
# Analytics: track trajectory download
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
user_id = await user_context.get_user_id()
|
||||
if analytics and user_id:
|
||||
user_info = await user_context.get_user_info()
|
||||
consented = (
|
||||
user_info.user_consents_to_analytics
|
||||
if user_info and user_info.user_consents_to_analytics is not None
|
||||
else False
|
||||
)
|
||||
analytics.track_trajectory_downloaded(
|
||||
distinct_id=user_id,
|
||||
conversation_id=str(conversation_id),
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:trajectory_downloaded:failed')
|
||||
|
||||
# Return as a downloadable zip file
|
||||
return Response(
|
||||
content=zip_content,
|
||||
|
||||
@@ -12,7 +12,8 @@ from jwt import InvalidTokenError
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands import tools # type: ignore[attr-defined]
|
||||
from openhands.agent_server.models import ConversationInfo, Success
|
||||
from openhands.agent_server.models import ConversationInfo, ServerErrorEvent, Success
|
||||
from openhands.analytics import analytics_constants, get_analytics_service
|
||||
from openhands.app_server.app_conversation.app_conversation_info_service import (
|
||||
AppConversationInfoService,
|
||||
)
|
||||
@@ -40,7 +41,8 @@ from openhands.app_server.user.specifiy_user_context import (
|
||||
)
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk import ConversationExecutionStatus, Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
from openhands.sdk.event import AgentErrorEvent, ConversationStateUpdateEvent
|
||||
from openhands.sdk.event.conversation_error import ConversationErrorEvent
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
@@ -56,6 +58,29 @@ app_mode = get_global_config().app_mode
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _classify_error_type(error_message: str | None) -> str:
|
||||
"""Classify conversation error into broad categories for dashboard filtering.
|
||||
|
||||
Categories: budget_exceeded, model_error, runtime_error, timeout, user_cancelled, unknown.
|
||||
Uses best-effort string matching per CONTEXT.md decision.
|
||||
"""
|
||||
if not error_message:
|
||||
return 'unknown'
|
||||
msg_lower = error_message.lower()
|
||||
if 'budget' in msg_lower or 'budgetexceeded' in msg_lower:
|
||||
return 'budget_exceeded'
|
||||
if 'timeout' in msg_lower or 'timed out' in msg_lower:
|
||||
return 'timeout'
|
||||
if 'cancel' in msg_lower:
|
||||
return 'user_cancelled'
|
||||
if any(
|
||||
kw in msg_lower
|
||||
for kw in ('model', 'llm', 'api key', 'rate limit', 'authentication')
|
||||
):
|
||||
return 'model_error'
|
||||
return 'runtime_error'
|
||||
|
||||
|
||||
def merge_conversation_tags(
|
||||
existing_tags: dict[str, str] | None,
|
||||
incoming_tags: dict[str, str] | None,
|
||||
@@ -118,6 +143,78 @@ def detect_automation_trigger(
|
||||
return None
|
||||
|
||||
|
||||
async def _track_error_events(
|
||||
events: list[Event],
|
||||
conversation_id: UUID,
|
||||
app_conversation_info: AppConversationInfo,
|
||||
) -> None:
|
||||
"""Track error events to analytics (replaces frontend captureException).
|
||||
|
||||
Processes ConversationErrorEvent, ServerErrorEvent, and AgentErrorEvent
|
||||
from the event stream and sends them to PostHog analytics.
|
||||
"""
|
||||
analytics = get_analytics_service()
|
||||
if not analytics or not app_conversation_info.created_by_user_id:
|
||||
return
|
||||
|
||||
for event in events:
|
||||
error_data: dict | None = None
|
||||
|
||||
if isinstance(event, ConversationErrorEvent):
|
||||
error_data = {
|
||||
'error_type': event.code,
|
||||
'error_message': event.detail[:500] if event.detail else None,
|
||||
'error_source': 'conversation',
|
||||
'event_id': str(event.id) if event.id else None,
|
||||
}
|
||||
elif isinstance(event, ServerErrorEvent):
|
||||
error_data = {
|
||||
'error_type': event.code,
|
||||
'error_message': event.detail[:500] if event.detail else None,
|
||||
'error_source': 'server',
|
||||
'event_id': str(event.id) if event.id else None,
|
||||
}
|
||||
elif isinstance(event, AgentErrorEvent):
|
||||
error_data = {
|
||||
'error_type': 'AgentError',
|
||||
'error_message': event.error[:500] if event.error else None,
|
||||
'error_source': 'agent',
|
||||
'event_id': str(event.id) if event.id else None,
|
||||
'tool_name': event.tool_name,
|
||||
'tool_call_id': event.tool_call_id,
|
||||
}
|
||||
|
||||
if error_data:
|
||||
try:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(
|
||||
app_conversation_info.created_by_user_id
|
||||
)
|
||||
if user_obj:
|
||||
consented = user_obj.user_consents_to_analytics is True
|
||||
org_id = (
|
||||
str(user_obj.current_org_id)
|
||||
if user_obj.current_org_id
|
||||
else None
|
||||
)
|
||||
|
||||
analytics.track_error_captured(
|
||||
distinct_id=app_conversation_info.created_by_user_id,
|
||||
conversation_id=str(conversation_id),
|
||||
error_type=error_data['error_type'],
|
||||
error_message=error_data.get('error_message'),
|
||||
error_source=error_data['error_source'],
|
||||
event_id=error_data.get('event_id'),
|
||||
tool_name=error_data.get('tool_name'),
|
||||
tool_call_id=error_data.get('tool_call_id'),
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception('analytics:error_captured:failed')
|
||||
|
||||
|
||||
async def valid_sandbox(
|
||||
request: Request,
|
||||
session_api_key: str = Depends(
|
||||
@@ -237,6 +334,38 @@ async def on_conversation_update(
|
||||
app_conversation_info
|
||||
)
|
||||
|
||||
# Analytics: conversation created
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and sandbox_info.created_by_user_id:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(sandbox_info.created_by_user_id)
|
||||
if user_obj:
|
||||
consented = user_obj.user_consents_to_analytics is True
|
||||
org_id = (
|
||||
str(user_obj.current_org_id) if user_obj.current_org_id else None
|
||||
)
|
||||
analytics.capture(
|
||||
distinct_id=sandbox_info.created_by_user_id,
|
||||
event=analytics_constants.CONVERSATION_CREATED,
|
||||
properties={
|
||||
'conversation_id': str(conversation_info.id),
|
||||
'trigger': existing.trigger.value if existing.trigger else None,
|
||||
'llm_model': (
|
||||
conversation_info.agent.llm.model
|
||||
if conversation_info.agent and conversation_info.agent.llm
|
||||
else None
|
||||
),
|
||||
'agent_type': 'default',
|
||||
'has_repository': existing.selected_repository is not None,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception('analytics:conversation_created:failed')
|
||||
|
||||
return Success()
|
||||
|
||||
|
||||
@@ -255,6 +384,9 @@ async def on_event(
|
||||
*[event_service.save_event(conversation_id, event) for event in events]
|
||||
)
|
||||
|
||||
# Track error events (replaces frontend captureException)
|
||||
await _track_error_events(events, conversation_id, app_conversation_info)
|
||||
|
||||
# Process stats events for V1 conversations
|
||||
for event in events:
|
||||
if isinstance(event, ConversationStateUpdateEvent) and event.key == 'stats':
|
||||
@@ -262,6 +394,186 @@ async def on_event(
|
||||
event, conversation_id
|
||||
)
|
||||
|
||||
# Analytics: conversation terminal state detection
|
||||
for event in events:
|
||||
if (
|
||||
isinstance(event, ConversationStateUpdateEvent)
|
||||
and event.key == 'execution_status'
|
||||
):
|
||||
try:
|
||||
exec_status = ConversationExecutionStatus(event.value)
|
||||
if exec_status.is_terminal():
|
||||
analytics = get_analytics_service()
|
||||
if analytics and app_conversation_info.created_by_user_id:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(
|
||||
app_conversation_info.created_by_user_id
|
||||
)
|
||||
if user_obj:
|
||||
consented = user_obj.user_consents_to_analytics is True
|
||||
org_id = (
|
||||
str(user_obj.current_org_id)
|
||||
if user_obj.current_org_id
|
||||
else None
|
||||
)
|
||||
|
||||
# Extract metrics from stored conversation info (updated by process_stats_event above)
|
||||
metrics = app_conversation_info.metrics
|
||||
accumulated_cost = (
|
||||
metrics.accumulated_cost if metrics else None
|
||||
)
|
||||
prompt_tokens = (
|
||||
metrics.accumulated_token_usage.prompt_tokens
|
||||
if metrics and metrics.accumulated_token_usage
|
||||
else None
|
||||
)
|
||||
completion_tokens = (
|
||||
metrics.accumulated_token_usage.completion_tokens
|
||||
if metrics and metrics.accumulated_token_usage
|
||||
else None
|
||||
)
|
||||
|
||||
is_error = exec_status in (
|
||||
ConversationExecutionStatus.ERROR,
|
||||
ConversationExecutionStatus.STUCK,
|
||||
)
|
||||
|
||||
if is_error:
|
||||
# Look for the last error info in events batch
|
||||
error_message = None
|
||||
for ev in events:
|
||||
if (
|
||||
isinstance(ev, ConversationStateUpdateEvent)
|
||||
and ev.key == 'last_error'
|
||||
):
|
||||
error_message = (
|
||||
str(ev.value)[:500]
|
||||
if ev.value
|
||||
else None
|
||||
)
|
||||
|
||||
error_type = _classify_error_type(error_message)
|
||||
|
||||
# BIZZ-06: conversation errored
|
||||
analytics.capture(
|
||||
distinct_id=app_conversation_info.created_by_user_id,
|
||||
event=analytics_constants.CONVERSATION_ERRORED,
|
||||
properties={
|
||||
'conversation_id': str(conversation_id),
|
||||
'error_type': error_type,
|
||||
'error_message': error_message,
|
||||
'llm_model': app_conversation_info.llm_model,
|
||||
'turn_count': None, # Not derivable from MetricsSnapshot alone
|
||||
'terminal_state': exec_status.value,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
# BIZZ-03: credit limit reached (fires alongside conversation errored)
|
||||
if error_type == 'budget_exceeded':
|
||||
analytics.capture(
|
||||
distinct_id=app_conversation_info.created_by_user_id,
|
||||
event=analytics_constants.CREDIT_LIMIT_REACHED,
|
||||
properties={
|
||||
'conversation_id': str(conversation_id),
|
||||
'credit_balance': None, # Not available in webhook context
|
||||
'llm_model': app_conversation_info.llm_model,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
else:
|
||||
# BIZZ-05: conversation finished (includes FINISHED, STOPPED, etc.)
|
||||
analytics.capture(
|
||||
distinct_id=app_conversation_info.created_by_user_id,
|
||||
event=analytics_constants.CONVERSATION_FINISHED,
|
||||
properties={
|
||||
'conversation_id': str(conversation_id),
|
||||
'terminal_state': exec_status.value,
|
||||
'turn_count': None, # Not derivable from MetricsSnapshot alone
|
||||
'accumulated_cost_usd': accumulated_cost,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'completion_tokens': completion_tokens,
|
||||
'llm_model': app_conversation_info.llm_model,
|
||||
'trigger': app_conversation_info.trigger.value
|
||||
if app_conversation_info.trigger
|
||||
else None,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
# ACTV-01: user activated (first finished conversation only)
|
||||
if (
|
||||
exec_status
|
||||
== ConversationExecutionStatus.FINISHED
|
||||
):
|
||||
try:
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from storage.database import (
|
||||
a_session_maker,
|
||||
)
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
user_uuid = _uuid.UUID(
|
||||
app_conversation_info.created_by_user_id
|
||||
)
|
||||
async with a_session_maker() as act_session:
|
||||
count_result = await act_session.execute(
|
||||
sa_select(func.count()).where(
|
||||
StoredConversationMetadataSaas.user_id
|
||||
== user_uuid,
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
!= str(conversation_id),
|
||||
)
|
||||
)
|
||||
prior_count = count_result.scalar()
|
||||
|
||||
if prior_count == 0:
|
||||
tos_ts = user_obj.accepted_tos
|
||||
if tos_ts is not None:
|
||||
if tos_ts.tzinfo is None:
|
||||
tos_ts = tos_ts.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
time_to_activate_seconds = (
|
||||
datetime.now(timezone.utc)
|
||||
- tos_ts
|
||||
).total_seconds()
|
||||
else:
|
||||
time_to_activate_seconds = None
|
||||
|
||||
analytics.capture(
|
||||
distinct_id=app_conversation_info.created_by_user_id,
|
||||
event=analytics_constants.USER_ACTIVATED,
|
||||
properties={
|
||||
'conversation_id': str(
|
||||
conversation_id
|
||||
),
|
||||
'time_to_activate_seconds': time_to_activate_seconds,
|
||||
'llm_model': app_conversation_info.llm_model,
|
||||
'trigger': app_conversation_info.trigger.value
|
||||
if app_conversation_info.trigger
|
||||
else None,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'analytics:user_activated:failed'
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception('analytics:conversation_terminal:failed')
|
||||
|
||||
asyncio.create_task(
|
||||
_run_callbacks_in_bg_and_close(
|
||||
conversation_id, app_conversation_info.created_by_user_id, events
|
||||
|
||||
@@ -15,6 +15,7 @@ from openhands.app_server.secrets.secrets_models import (
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.models import EditResponse
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
CustomSecret,
|
||||
@@ -28,6 +29,7 @@ from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets,
|
||||
get_secrets_store,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
@@ -98,6 +100,7 @@ async def store_provider_tokens(
|
||||
provider_info: POSTProviderModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> EditResponse:
|
||||
"""Store git provider tokens.
|
||||
|
||||
@@ -133,6 +136,35 @@ async def store_provider_tokens(
|
||||
)
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
# ACTV-02: git provider connected analytics
|
||||
try:
|
||||
from openhands.analytics import analytics_constants, get_analytics_service
|
||||
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id and provider_info.provider_tokens:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(user_id)
|
||||
if user_obj:
|
||||
consented = user_obj.user_consents_to_analytics is True
|
||||
org_id_str = (
|
||||
str(user_obj.current_org_id) if user_obj.current_org_id else None
|
||||
)
|
||||
for provider_type, token_value in provider_info.provider_tokens.items():
|
||||
# Only fire for providers with actual token, not host-only updates
|
||||
if token_value.token:
|
||||
analytics.capture(
|
||||
distinct_id=user_id,
|
||||
event=analytics_constants.GIT_PROVIDER_CONNECTED,
|
||||
properties={
|
||||
'provider_type': provider_type.value,
|
||||
},
|
||||
org_id=org_id_str,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:git_provider_connected:failed')
|
||||
|
||||
return EditResponse(
|
||||
message='Git providers stored',
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
@@ -24,6 +25,7 @@ from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_id,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
@@ -176,6 +178,7 @@ async def load_settings(
|
||||
async def store_settings(
|
||||
payload: dict[str, Any],
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> JSONResponse:
|
||||
"""Store user settings.
|
||||
|
||||
@@ -225,6 +228,48 @@ async def store_settings(
|
||||
)
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
# Analytics: track settings saved and MCP config updates
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
consented = settings.user_consents_to_analytics is True
|
||||
|
||||
# Track settings saved
|
||||
settings_changed = list(payload.keys())
|
||||
analytics.track_settings_saved(
|
||||
distinct_id=user_id,
|
||||
settings_changed=settings_changed,
|
||||
consented=consented,
|
||||
)
|
||||
|
||||
# Track MCP config update if MCP settings changed
|
||||
agent_settings = payload.get('agent_settings', {})
|
||||
if isinstance(agent_settings, dict):
|
||||
mcp_config = agent_settings.get('mcp_config')
|
||||
if mcp_config:
|
||||
mcp_servers = mcp_config.get('mcpServers', {})
|
||||
# Count SSE vs stdio servers
|
||||
sse_count = sum(
|
||||
1
|
||||
for s in mcp_servers.values()
|
||||
if isinstance(s, dict) and s.get('url')
|
||||
)
|
||||
stdio_count = sum(
|
||||
1
|
||||
for s in mcp_servers.values()
|
||||
if isinstance(s, dict) and s.get('command')
|
||||
)
|
||||
analytics.track_mcp_config_updated(
|
||||
distinct_id=user_id,
|
||||
has_mcp_config=bool(mcp_servers),
|
||||
sse_servers_count=sse_count,
|
||||
stdio_servers_count=stdio_count,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:settings_saved:failed')
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.analytics import analytics_constants, get_analytics_service
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
@@ -73,6 +74,41 @@ async def initialize_conversation(
|
||||
)
|
||||
|
||||
await conversation_store.save_metadata(conversation_metadata)
|
||||
|
||||
# Analytics: conversation created (V0 best-effort — llm_model not available at this point)
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(user_id)
|
||||
if user_obj:
|
||||
consented = user_obj.user_consents_to_analytics is True
|
||||
org_id = (
|
||||
str(user_obj.current_org_id)
|
||||
if user_obj.current_org_id
|
||||
else None
|
||||
)
|
||||
analytics.capture(
|
||||
distinct_id=user_id,
|
||||
event=analytics_constants.CONVERSATION_CREATED,
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'trigger': (
|
||||
conversation_trigger.value
|
||||
if conversation_trigger
|
||||
else None
|
||||
),
|
||||
'llm_model': None, # V0: llm_model not available at conversation init time
|
||||
'agent_type': 'default',
|
||||
'has_repository': selected_repository is not None,
|
||||
},
|
||||
org_id=org_id,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:conversation_created:v0:failed')
|
||||
|
||||
return conversation_metadata
|
||||
|
||||
conversation_metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
159
tests/unit/test_analytics_context.py
Normal file
159
tests/unit/test_analytics_context.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Tests for AnalyticsContext dataclass and resolve_context factory."""
|
||||
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.analytics.analytics_context import AnalyticsContext, resolve_context
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user_store_module(get_user_by_id_mock: AsyncMock) -> dict[str, ModuleType]:
|
||||
"""Build a fake ``storage.user_store`` module containing a mock UserStore.
|
||||
|
||||
Returns a dict suitable for ``patch.dict(sys.modules, ...)``.
|
||||
"""
|
||||
mock_user_store_class = MagicMock()
|
||||
mock_user_store_class.get_user_by_id = get_user_by_id_mock
|
||||
|
||||
user_store_mod = ModuleType('storage.user_store')
|
||||
user_store_mod.UserStore = mock_user_store_class # type: ignore[attr-defined]
|
||||
|
||||
# Ensure 'storage' parent package exists in sys.modules too.
|
||||
storage_mod = sys.modules.get('storage') or ModuleType('storage')
|
||||
|
||||
return {'storage': storage_mod, 'storage.user_store': user_store_mod}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AnalyticsContext dataclass tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalyticsContext:
|
||||
"""Tests for AnalyticsContext dataclass construction and field storage."""
|
||||
|
||||
def test_context_stores_all_fields_correctly(self):
|
||||
"""AnalyticsContext constructed with explicit values stores user_id, consented, org_id, user fields correctly."""
|
||||
mock_user = MagicMock()
|
||||
ctx = AnalyticsContext(
|
||||
user_id='user-123',
|
||||
consented=True,
|
||||
org_id='org-456',
|
||||
user=mock_user,
|
||||
)
|
||||
assert ctx.user_id == 'user-123'
|
||||
assert ctx.consented is True
|
||||
assert ctx.org_id == 'org-456'
|
||||
assert ctx.user is mock_user
|
||||
|
||||
def test_context_default_safe_values(self):
|
||||
"""AnalyticsContext can be created with safe defaults (consented=False, org_id=None, user=None)."""
|
||||
ctx = AnalyticsContext(
|
||||
user_id='user-123',
|
||||
consented=False,
|
||||
org_id=None,
|
||||
user=None,
|
||||
)
|
||||
assert ctx.user_id == 'user-123'
|
||||
assert ctx.consented is False
|
||||
assert ctx.org_id is None
|
||||
assert ctx.user is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_context factory tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveContext:
|
||||
"""Tests for resolve_context async factory function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_with_valid_user(self):
|
||||
"""resolve_context with valid user_id returns AnalyticsContext with consented from user, org_id from user."""
|
||||
mock_user = MagicMock()
|
||||
mock_user.user_consents_to_analytics = True
|
||||
mock_user.current_org_id = 'org-abc-123'
|
||||
|
||||
modules = _make_user_store_module(AsyncMock(return_value=mock_user))
|
||||
with patch.dict(sys.modules, modules):
|
||||
ctx = await resolve_context('user-42')
|
||||
|
||||
assert ctx.user_id == 'user-42'
|
||||
assert ctx.consented is True
|
||||
assert ctx.org_id == 'org-abc-123'
|
||||
assert ctx.user is mock_user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_consent_none_means_false(self):
|
||||
"""resolve_context with user.user_consents_to_analytics=None returns consented=False."""
|
||||
mock_user = MagicMock()
|
||||
mock_user.user_consents_to_analytics = None
|
||||
mock_user.current_org_id = 'org-1'
|
||||
|
||||
modules = _make_user_store_module(AsyncMock(return_value=mock_user))
|
||||
with patch.dict(sys.modules, modules):
|
||||
ctx = await resolve_context('user-42')
|
||||
|
||||
assert ctx.consented is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_org_id_none(self):
|
||||
"""resolve_context with user.current_org_id=None returns org_id=None."""
|
||||
mock_user = MagicMock()
|
||||
mock_user.user_consents_to_analytics = True
|
||||
mock_user.current_org_id = None
|
||||
|
||||
modules = _make_user_store_module(AsyncMock(return_value=mock_user))
|
||||
with patch.dict(sys.modules, modules):
|
||||
ctx = await resolve_context('user-42')
|
||||
|
||||
assert ctx.org_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_user_not_found(self):
|
||||
"""resolve_context when UserStore returns None returns safe default."""
|
||||
modules = _make_user_store_module(AsyncMock(return_value=None))
|
||||
with patch.dict(sys.modules, modules):
|
||||
ctx = await resolve_context('nonexistent-user')
|
||||
|
||||
assert ctx.user_id == 'nonexistent-user'
|
||||
assert ctx.consented is False
|
||||
assert ctx.org_id is None
|
||||
assert ctx.user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_user_store_raises_exception(self):
|
||||
"""resolve_context when UserStore raises Exception returns safe default (no exception leaks)."""
|
||||
modules = _make_user_store_module(
|
||||
AsyncMock(side_effect=RuntimeError('DB connection failed'))
|
||||
)
|
||||
with patch.dict(sys.modules, modules):
|
||||
ctx = await resolve_context('user-42')
|
||||
|
||||
assert ctx.user_id == 'user-42'
|
||||
assert ctx.consented is False
|
||||
assert ctx.org_id is None
|
||||
assert ctx.user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_context_logs_warning_on_failure(self):
|
||||
"""resolve_context logs a warning when user lookup fails."""
|
||||
modules = _make_user_store_module(
|
||||
AsyncMock(side_effect=RuntimeError('DB error'))
|
||||
)
|
||||
with (
|
||||
patch.dict(sys.modules, modules),
|
||||
patch('openhands.analytics.analytics_context.logger') as mock_logger,
|
||||
):
|
||||
await resolve_context('user-42')
|
||||
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert 'user-42' in str(call_args)
|
||||
836
tests/unit/test_analytics_service.py
Normal file
836
tests/unit/test_analytics_service.py
Normal file
@@ -0,0 +1,836 @@
|
||||
"""Tests for the AnalyticsService and related utilities."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.analytics import (
|
||||
AnalyticsService,
|
||||
get_analytics_service,
|
||||
init_analytics_service,
|
||||
)
|
||||
from openhands.analytics.analytics_constants import (
|
||||
CONVERSATION_CREATED,
|
||||
CONVERSATION_ERRORED,
|
||||
CONVERSATION_FINISHED,
|
||||
CREDIT_LIMIT_REACHED,
|
||||
CREDIT_PURCHASED,
|
||||
ENTERPRISE_LEAD_FORM_SUBMITTED,
|
||||
GIT_PROVIDER_CONNECTED,
|
||||
ONBOARDING_COMPLETED,
|
||||
SAAS_SELFHOSTED_INQUIRY,
|
||||
USER_ACTIVATED,
|
||||
USER_LOGGED_IN,
|
||||
USER_SIGNED_UP,
|
||||
)
|
||||
from openhands.analytics.analytics_service import AnalyticsService as DirectService
|
||||
from openhands.analytics.oss_install_id import get_or_create_install_id
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset the module-level singleton before each test."""
|
||||
import openhands.analytics as analytics_module
|
||||
|
||||
original = analytics_module._analytics_service
|
||||
analytics_module._analytics_service = None
|
||||
yield
|
||||
analytics_module._analytics_service = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_posthog():
|
||||
"""Patch posthog.Posthog and return the mock instance."""
|
||||
with patch(
|
||||
'openhands.analytics.analytics_service.Posthog', autospec=True
|
||||
) as MockClass:
|
||||
mock_client = MagicMock()
|
||||
MockClass.return_value = mock_client
|
||||
yield MockClass, mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oss_service(mock_posthog):
|
||||
"""AnalyticsService in OSS mode."""
|
||||
MockClass, mock_client = mock_posthog
|
||||
service = DirectService(
|
||||
api_key='test-key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.OPENHANDS,
|
||||
is_feature_env=False,
|
||||
)
|
||||
return service, mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saas_service(mock_posthog):
|
||||
"""AnalyticsService in SaaS mode."""
|
||||
MockClass, mock_client = mock_posthog
|
||||
service = DirectService(
|
||||
api_key='test-key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.SAAS,
|
||||
is_feature_env=False,
|
||||
)
|
||||
return service, mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feature_env_service(mock_posthog):
|
||||
"""AnalyticsService in feature-env mode (OSS)."""
|
||||
MockClass, mock_client = mock_posthog
|
||||
service = DirectService(
|
||||
api_key='test-key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.OPENHANDS,
|
||||
is_feature_env=True,
|
||||
)
|
||||
return service, mock_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_get_analytics_service_returns_none_before_init(self):
|
||||
"""get_analytics_service() returns None before init_analytics_service() is called."""
|
||||
assert get_analytics_service() is None
|
||||
|
||||
def test_init_analytics_service_returns_instance(self, mock_posthog):
|
||||
"""init_analytics_service() returns an AnalyticsService instance."""
|
||||
service = init_analytics_service(
|
||||
api_key='key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.SAAS,
|
||||
is_feature_env=False,
|
||||
)
|
||||
assert isinstance(service, AnalyticsService)
|
||||
|
||||
def test_get_analytics_service_returns_instance_after_init(self, mock_posthog):
|
||||
"""get_analytics_service() returns the initialized instance."""
|
||||
init_analytics_service(
|
||||
api_key='key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.SAAS,
|
||||
is_feature_env=False,
|
||||
)
|
||||
result = get_analytics_service()
|
||||
assert isinstance(result, AnalyticsService)
|
||||
|
||||
def test_init_analytics_service_stores_singleton(self, mock_posthog):
|
||||
"""Calling init twice returns the same object from get_analytics_service."""
|
||||
svc1 = init_analytics_service(
|
||||
api_key='key',
|
||||
host='https://posthog.example.com',
|
||||
app_mode=AppMode.SAAS,
|
||||
is_feature_env=False,
|
||||
)
|
||||
svc2 = get_analytics_service()
|
||||
assert svc1 is svc2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consent gate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConsentGate:
|
||||
def test_capture_with_consented_false_makes_zero_posthog_calls(self, oss_service):
|
||||
"""capture() with consented=False produces zero PostHog client calls."""
|
||||
service, mock_client = oss_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
consented=False,
|
||||
)
|
||||
mock_client.capture.assert_not_called()
|
||||
|
||||
def test_capture_with_consented_true_makes_posthog_call(self, oss_service):
|
||||
"""capture() with consented=True calls the PostHog client."""
|
||||
service, mock_client = oss_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
consented=True,
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
|
||||
def test_set_person_properties_with_consented_false_is_noop(self, saas_service):
|
||||
"""set_person_properties() with consented=False does not call SDK."""
|
||||
service, mock_client = saas_service
|
||||
service.set_person_properties(
|
||||
distinct_id='user123',
|
||||
properties={'name': 'Alice'},
|
||||
consented=False,
|
||||
)
|
||||
mock_client.set.assert_not_called()
|
||||
|
||||
def test_group_identify_with_consented_false_is_noop(self, saas_service):
|
||||
"""group_identify() with consented=False does not call SDK."""
|
||||
service, mock_client = saas_service
|
||||
service.group_identify(
|
||||
group_type='org',
|
||||
group_key='org123',
|
||||
properties={'name': 'Acme'},
|
||||
consented=False,
|
||||
)
|
||||
mock_client.group_identify.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS vs SaaS mode tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOssSaasMode:
|
||||
def test_capture_oss_mode_includes_process_person_profile_false(self, oss_service):
|
||||
"""In OSS mode, captured events include '$process_person_profile': False."""
|
||||
service, mock_client = oss_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props.get('$process_person_profile') is False
|
||||
|
||||
def test_capture_saas_mode_does_not_include_process_person_profile(
|
||||
self, saas_service
|
||||
):
|
||||
"""In SaaS mode, captured events do NOT include '$process_person_profile'."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert '$process_person_profile' not in props
|
||||
|
||||
def test_set_person_properties_oss_mode_is_noop(self, oss_service):
|
||||
"""set_person_properties() in OSS mode does not call SDK."""
|
||||
service, mock_client = oss_service
|
||||
service.set_person_properties(
|
||||
distinct_id='user123',
|
||||
properties={'name': 'Alice'},
|
||||
consented=True,
|
||||
)
|
||||
mock_client.set.assert_not_called()
|
||||
|
||||
def test_set_person_properties_saas_mode_calls_sdk(self, saas_service):
|
||||
"""set_person_properties() in SaaS mode calls SDK when consented."""
|
||||
service, mock_client = saas_service
|
||||
service.set_person_properties(
|
||||
distinct_id='user123',
|
||||
properties={'name': 'Alice'},
|
||||
consented=True,
|
||||
)
|
||||
mock_client.set.assert_called_once()
|
||||
|
||||
def test_group_identify_oss_mode_is_noop(self, oss_service):
|
||||
"""group_identify() in OSS mode does not call SDK."""
|
||||
service, mock_client = oss_service
|
||||
service.group_identify(
|
||||
group_type='org',
|
||||
group_key='org123',
|
||||
properties={'name': 'Acme'},
|
||||
consented=True,
|
||||
)
|
||||
mock_client.group_identify.assert_not_called()
|
||||
|
||||
def test_group_identify_saas_mode_calls_sdk(self, saas_service):
|
||||
"""group_identify() in SaaS mode calls SDK when consented."""
|
||||
service, mock_client = saas_service
|
||||
service.group_identify(
|
||||
group_type='org',
|
||||
group_key='org123',
|
||||
properties={'name': 'Acme'},
|
||||
consented=True,
|
||||
)
|
||||
mock_client.group_identify.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common properties tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCommonProperties:
|
||||
def test_capture_always_includes_app_mode(self, saas_service):
|
||||
"""Every captured event includes app_mode in properties."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(distinct_id='user123', event='test event', consented=True)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert 'app_mode' in props
|
||||
assert props['app_mode'] == AppMode.SAAS.value
|
||||
|
||||
def test_capture_always_includes_is_feature_env(self, saas_service):
|
||||
"""Every captured event includes is_feature_env in properties."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(distinct_id='user123', event='test event', consented=True)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert 'is_feature_env' in props
|
||||
assert props['is_feature_env'] is False
|
||||
|
||||
def test_capture_includes_org_id_when_provided(self, saas_service):
|
||||
"""capture() includes org_id when provided."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
org_id='org-abc',
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props.get('org_id') == 'org-abc'
|
||||
|
||||
def test_capture_omits_org_id_when_not_provided(self, saas_service):
|
||||
"""capture() omits org_id when not provided."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(distinct_id='user123', event='test event', consented=True)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert 'org_id' not in props
|
||||
|
||||
def test_capture_includes_session_id_when_provided(self, saas_service):
|
||||
"""capture() includes $session_id when session_id provided."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
session_id='sess-xyz',
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props.get('$session_id') == 'sess-xyz'
|
||||
|
||||
def test_capture_omits_session_id_when_not_provided(self, saas_service):
|
||||
"""capture() omits $session_id when not provided."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(distinct_id='user123', event='test event', consented=True)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert '$session_id' not in props
|
||||
|
||||
def test_capture_merges_caller_properties(self, saas_service):
|
||||
"""capture() merges caller-provided properties with common ones."""
|
||||
service, mock_client = saas_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
properties={'custom_prop': 'custom_val'},
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props.get('custom_prop') == 'custom_val'
|
||||
assert 'app_mode' in props # common props still present
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# distinct_id / feature-env tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDistinctId:
|
||||
def test_distinct_id_normal_mode_returns_raw_user_id(self, saas_service):
|
||||
"""_distinct_id() returns raw user_id when is_feature_env=False."""
|
||||
service, _ = saas_service
|
||||
assert service._distinct_id('user123') == 'user123'
|
||||
|
||||
def test_distinct_id_feature_env_returns_prefixed_user_id(
|
||||
self, feature_env_service
|
||||
):
|
||||
"""_distinct_id() returns 'FEATURE_{user_id}' when is_feature_env=True."""
|
||||
service, _ = feature_env_service
|
||||
assert service._distinct_id('user123') == 'FEATURE_user123'
|
||||
|
||||
def test_capture_uses_distinct_id_helper(self, feature_env_service):
|
||||
"""capture() passes the prefixed distinct_id to the PostHog client."""
|
||||
service, mock_client = feature_env_service
|
||||
service.capture(
|
||||
distinct_id='user123',
|
||||
event='test event',
|
||||
consented=True,
|
||||
)
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs.get('distinct_id') == 'FEATURE_user123'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShutdown:
|
||||
def test_shutdown_calls_client_shutdown(self, saas_service):
|
||||
"""shutdown() calls client.shutdown() without raising."""
|
||||
service, mock_client = saas_service
|
||||
service.shutdown()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_shutdown_logs_errors_without_raising(self, saas_service):
|
||||
"""shutdown() catches SDK errors and does not propagate them."""
|
||||
service, mock_client = saas_service
|
||||
mock_client.shutdown.side_effect = RuntimeError('SDK error')
|
||||
# Should not raise
|
||||
service.shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SDK error handling tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_capture_logs_sdk_errors_without_raising(self, saas_service):
|
||||
"""capture() catches SDK errors and does not raise to caller."""
|
||||
service, mock_client = saas_service
|
||||
mock_client.capture.side_effect = RuntimeError('Network error')
|
||||
# Should not raise
|
||||
service.capture(distinct_id='user123', event='test event', consented=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event constants tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventConstants:
|
||||
def test_event_constants_are_lowercase_strings_with_spaces(self):
|
||||
"""Event constants follow PostHog naming convention (lowercase, spaces)."""
|
||||
for const in [
|
||||
USER_LOGGED_IN,
|
||||
USER_SIGNED_UP,
|
||||
CONVERSATION_CREATED,
|
||||
CONVERSATION_FINISHED,
|
||||
CONVERSATION_ERRORED,
|
||||
CREDIT_PURCHASED,
|
||||
CREDIT_LIMIT_REACHED,
|
||||
USER_ACTIVATED,
|
||||
GIT_PROVIDER_CONNECTED,
|
||||
ONBOARDING_COMPLETED,
|
||||
]:
|
||||
assert isinstance(const, str)
|
||||
assert const == const.lower(), f'{const!r} is not lowercase'
|
||||
assert ' ' in const, f'{const!r} does not contain spaces'
|
||||
|
||||
def test_user_logged_in_constant_value(self):
|
||||
"""USER_LOGGED_IN has the correct value."""
|
||||
assert USER_LOGGED_IN == 'user logged in'
|
||||
|
||||
def test_user_signed_up_constant_value(self):
|
||||
"""USER_SIGNED_UP has the correct value."""
|
||||
assert USER_SIGNED_UP == 'user signed up'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS install ID tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOssInstallId:
|
||||
def test_get_or_create_install_id_creates_file_on_first_call(self, tmp_path):
|
||||
"""get_or_create_install_id() creates the analytics_id.txt file on first call."""
|
||||
result = get_or_create_install_id(tmp_path)
|
||||
assert (tmp_path / 'analytics_id.txt').exists()
|
||||
assert result is not None
|
||||
|
||||
def test_get_or_create_install_id_returns_valid_uuid(self, tmp_path):
|
||||
"""get_or_create_install_id() returns a valid UUID string."""
|
||||
result = get_or_create_install_id(tmp_path)
|
||||
parsed = uuid.UUID(result) # raises if invalid
|
||||
assert str(parsed) == result
|
||||
|
||||
def test_get_or_create_install_id_returns_same_uuid_on_second_call(self, tmp_path):
|
||||
"""get_or_create_install_id() returns the same UUID on subsequent calls."""
|
||||
first = get_or_create_install_id(tmp_path)
|
||||
second = get_or_create_install_id(tmp_path)
|
||||
assert first == second
|
||||
|
||||
def test_get_or_create_install_id_returns_uuid_on_file_write_failure(
|
||||
self, tmp_path
|
||||
):
|
||||
"""get_or_create_install_id() returns an ephemeral UUID when file write fails."""
|
||||
read_only_dir = tmp_path / 'readonly'
|
||||
read_only_dir.mkdir()
|
||||
read_only_dir.chmod(0o444) # read-only
|
||||
|
||||
try:
|
||||
result = get_or_create_install_id(read_only_dir)
|
||||
# Should still return a valid UUID, not crash
|
||||
parsed = uuid.UUID(result)
|
||||
assert str(parsed) == result
|
||||
finally:
|
||||
read_only_dir.chmod(0o755) # restore for cleanup
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# identify_user tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIdentifyUser:
|
||||
"""Tests for the identify_user method on AnalyticsService."""
|
||||
|
||||
def test_identify_user_consent_false_is_noop(self, saas_service):
|
||||
"""identify_user with consented=False is a complete no-op."""
|
||||
service, mock_client = saas_service
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=False,
|
||||
email='a@b.com',
|
||||
org_id='org-1',
|
||||
)
|
||||
mock_client.set.assert_not_called()
|
||||
mock_client.group_identify.assert_not_called()
|
||||
|
||||
def test_identify_user_oss_mode_is_noop(self, oss_service):
|
||||
"""identify_user in OSS mode is a complete no-op."""
|
||||
service, mock_client = oss_service
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
email='a@b.com',
|
||||
org_id='org-1',
|
||||
)
|
||||
mock_client.set.assert_not_called()
|
||||
mock_client.group_identify.assert_not_called()
|
||||
|
||||
def test_identify_user_saas_sets_person_properties(self, saas_service):
|
||||
"""identify_user in SaaS mode with consent calls set_person_properties with expected fields."""
|
||||
service, mock_client = saas_service
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
email='alice@example.com',
|
||||
org_id='org-42',
|
||||
org_name='Acme Corp',
|
||||
idp='github',
|
||||
)
|
||||
mock_client.set.assert_called_once()
|
||||
_, kwargs = mock_client.set.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props['email'] == 'alice@example.com'
|
||||
assert props['org_id'] == 'org-42'
|
||||
assert props['org_name'] == 'Acme Corp'
|
||||
assert props['idp'] == 'github'
|
||||
assert 'last_login_at' in props
|
||||
assert 'plan_tier' in props
|
||||
|
||||
def test_identify_user_saas_with_orgs_calls_group_identify(self, saas_service):
|
||||
"""identify_user in SaaS mode with orgs calls group_identify for each org."""
|
||||
service, mock_client = saas_service
|
||||
orgs = [
|
||||
{'id': 'org-1', 'name': 'Org One', 'member_count': 5},
|
||||
{'id': 'org-2', 'name': 'Org Two', 'member_count': 10},
|
||||
]
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
orgs=orgs,
|
||||
)
|
||||
assert mock_client.group_identify.call_count == 2
|
||||
|
||||
def test_identify_user_with_user_none_skips_all(self, saas_service):
|
||||
"""identify_user with no email/org/orgs still calls set_person_properties (with None values)."""
|
||||
service, mock_client = saas_service
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
)
|
||||
# set_person_properties is still called (with None fields)
|
||||
mock_client.set.assert_called_once()
|
||||
# but group_identify is NOT called since no orgs provided
|
||||
mock_client.group_identify.assert_not_called()
|
||||
|
||||
def test_identify_user_catches_exception(self, saas_service):
|
||||
"""identify_user catches any exception internally and does not raise."""
|
||||
service, mock_client = saas_service
|
||||
mock_client.set.side_effect = RuntimeError('PostHog SDK error')
|
||||
# Should not raise
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
email='a@b.com',
|
||||
)
|
||||
|
||||
def test_identify_user_org_with_no_name(self, saas_service):
|
||||
"""identify_user with org that has no name handles it gracefully."""
|
||||
service, mock_client = saas_service
|
||||
orgs = [
|
||||
{'id': 'org-1', 'name': None, 'member_count': 3},
|
||||
]
|
||||
service.identify_user(
|
||||
distinct_id='user-1',
|
||||
consented=True,
|
||||
orgs=orgs,
|
||||
)
|
||||
assert mock_client.group_identify.call_count == 1
|
||||
_, kwargs = mock_client.group_identify.call_args
|
||||
props = kwargs.get('properties', {})
|
||||
assert props.get('org_name') is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed event methods tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTypedEventMethods:
|
||||
"""Tests for the 10 typed event methods on AnalyticsService."""
|
||||
|
||||
def test_track_user_signed_up(self, saas_service):
|
||||
"""track_user_signed_up calls capture with USER_SIGNED_UP and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_user_signed_up(
|
||||
distinct_id='user-1',
|
||||
idp='github',
|
||||
email_domain='example.com',
|
||||
invitation_source='invite_link',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == USER_SIGNED_UP
|
||||
props = kwargs['properties']
|
||||
assert props['idp'] == 'github'
|
||||
assert props['email_domain'] == 'example.com'
|
||||
assert props['invitation_source'] == 'invite_link'
|
||||
|
||||
def test_track_user_logged_in(self, saas_service):
|
||||
"""track_user_logged_in calls capture with USER_LOGGED_IN and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_user_logged_in(
|
||||
distinct_id='user-1',
|
||||
idp='google',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == USER_LOGGED_IN
|
||||
props = kwargs['properties']
|
||||
assert props['idp'] == 'google'
|
||||
|
||||
def test_track_conversation_created(self, saas_service):
|
||||
"""track_conversation_created calls capture with CONVERSATION_CREATED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_conversation_created(
|
||||
distinct_id='user-1',
|
||||
conversation_id='conv-abc',
|
||||
trigger='ui',
|
||||
llm_model='gpt-4',
|
||||
agent_type='CodeActAgent',
|
||||
has_repository=True,
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == CONVERSATION_CREATED
|
||||
props = kwargs['properties']
|
||||
assert props['conversation_id'] == 'conv-abc'
|
||||
assert props['trigger'] == 'ui'
|
||||
assert props['llm_model'] == 'gpt-4'
|
||||
assert props['agent_type'] == 'CodeActAgent'
|
||||
assert props['has_repository'] is True
|
||||
|
||||
def test_track_conversation_finished(self, saas_service):
|
||||
"""track_conversation_finished calls capture with CONVERSATION_FINISHED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_conversation_finished(
|
||||
distinct_id='user-1',
|
||||
conversation_id='conv-abc',
|
||||
terminal_state='completed',
|
||||
turn_count=5,
|
||||
accumulated_cost_usd=0.15,
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=500,
|
||||
llm_model='gpt-4',
|
||||
trigger='ui',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == CONVERSATION_FINISHED
|
||||
props = kwargs['properties']
|
||||
assert props['conversation_id'] == 'conv-abc'
|
||||
assert props['terminal_state'] == 'completed'
|
||||
assert props['turn_count'] == 5
|
||||
assert props['accumulated_cost_usd'] == 0.15
|
||||
assert props['prompt_tokens'] == 1000
|
||||
assert props['completion_tokens'] == 500
|
||||
assert props['llm_model'] == 'gpt-4'
|
||||
assert props['trigger'] == 'ui'
|
||||
|
||||
def test_track_conversation_errored(self, saas_service):
|
||||
"""track_conversation_errored calls capture with CONVERSATION_ERRORED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_conversation_errored(
|
||||
distinct_id='user-1',
|
||||
conversation_id='conv-abc',
|
||||
error_type='LLMError',
|
||||
error_message='Rate limit exceeded',
|
||||
llm_model='gpt-4',
|
||||
turn_count=3,
|
||||
terminal_state='error',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == CONVERSATION_ERRORED
|
||||
props = kwargs['properties']
|
||||
assert props['conversation_id'] == 'conv-abc'
|
||||
assert props['error_type'] == 'LLMError'
|
||||
assert props['error_message'] == 'Rate limit exceeded'
|
||||
assert props['llm_model'] == 'gpt-4'
|
||||
assert props['turn_count'] == 3
|
||||
assert props['terminal_state'] == 'error'
|
||||
|
||||
def test_track_credit_purchased(self, saas_service):
|
||||
"""track_credit_purchased calls capture with CREDIT_PURCHASED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_credit_purchased(
|
||||
distinct_id='user-1',
|
||||
amount_usd=10.0,
|
||||
credit_balance_before=5.0,
|
||||
credit_balance_after=15.0,
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == CREDIT_PURCHASED
|
||||
props = kwargs['properties']
|
||||
assert props['amount_usd'] == 10.0
|
||||
assert props['credit_balance_before'] == 5.0
|
||||
assert props['credit_balance_after'] == 15.0
|
||||
|
||||
def test_track_credit_limit_reached(self, saas_service):
|
||||
"""track_credit_limit_reached calls capture with CREDIT_LIMIT_REACHED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_credit_limit_reached(
|
||||
distinct_id='user-1',
|
||||
conversation_id='conv-abc',
|
||||
credit_balance=0.0,
|
||||
llm_model='gpt-4',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == CREDIT_LIMIT_REACHED
|
||||
props = kwargs['properties']
|
||||
assert props['conversation_id'] == 'conv-abc'
|
||||
assert props['credit_balance'] == 0.0
|
||||
assert props['llm_model'] == 'gpt-4'
|
||||
|
||||
def test_track_user_activated(self, saas_service):
|
||||
"""track_user_activated calls capture with USER_ACTIVATED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_user_activated(
|
||||
distinct_id='user-1',
|
||||
conversation_id='conv-abc',
|
||||
time_to_activate_seconds=120.5,
|
||||
llm_model='gpt-4',
|
||||
trigger='webhook',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == USER_ACTIVATED
|
||||
props = kwargs['properties']
|
||||
assert props['conversation_id'] == 'conv-abc'
|
||||
assert props['time_to_activate_seconds'] == 120.5
|
||||
assert props['llm_model'] == 'gpt-4'
|
||||
assert props['trigger'] == 'webhook'
|
||||
|
||||
def test_track_git_provider_connected(self, saas_service):
|
||||
"""track_git_provider_connected calls capture with GIT_PROVIDER_CONNECTED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_git_provider_connected(
|
||||
distinct_id='user-1',
|
||||
provider_type='github',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == GIT_PROVIDER_CONNECTED
|
||||
props = kwargs['properties']
|
||||
assert props['provider_type'] == 'github'
|
||||
|
||||
def test_track_onboarding_completed(self, saas_service):
|
||||
"""track_onboarding_completed calls capture with ONBOARDING_COMPLETED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_onboarding_completed(
|
||||
distinct_id='user-1',
|
||||
role='developer',
|
||||
org_size='11-50',
|
||||
use_case='code_review',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == ONBOARDING_COMPLETED
|
||||
props = kwargs['properties']
|
||||
assert props['role'] == 'developer'
|
||||
assert props['org_size'] == '11-50'
|
||||
assert props['use_case'] == 'code_review'
|
||||
|
||||
def test_track_saas_selfhosted_inquiry(self, saas_service):
|
||||
"""track_saas_selfhosted_inquiry calls capture with SAAS_SELFHOSTED_INQUIRY and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_saas_selfhosted_inquiry(
|
||||
distinct_id='user-1',
|
||||
location='home_page',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == SAAS_SELFHOSTED_INQUIRY
|
||||
props = kwargs['properties']
|
||||
assert props['location'] == 'home_page'
|
||||
|
||||
def test_track_enterprise_lead_form_submitted(self, saas_service):
|
||||
"""track_enterprise_lead_form_submitted calls capture with ENTERPRISE_LEAD_FORM_SUBMITTED and correct properties."""
|
||||
service, mock_client = saas_service
|
||||
service.track_enterprise_lead_form_submitted(
|
||||
distinct_id='user-1',
|
||||
request_type='self-hosted',
|
||||
name='Jane Doe',
|
||||
company='Acme Corp',
|
||||
email='jane@acme.com',
|
||||
message='Interested in on-prem deployment',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
assert kwargs['event'] == ENTERPRISE_LEAD_FORM_SUBMITTED
|
||||
props = kwargs['properties']
|
||||
assert props['request_type'] == 'self-hosted'
|
||||
assert props['name'] == 'Jane Doe'
|
||||
assert props['company'] == 'Acme Corp'
|
||||
assert props['email'] == 'jane@acme.com'
|
||||
assert props['message'] == 'Interested in on-prem deployment'
|
||||
|
||||
def test_typed_method_consent_false_is_noop(self, saas_service):
|
||||
"""A typed method with consented=False results in no capture call."""
|
||||
service, mock_client = saas_service
|
||||
service.track_user_logged_in(
|
||||
distinct_id='user-1',
|
||||
idp='github',
|
||||
consented=False,
|
||||
)
|
||||
mock_client.capture.assert_not_called()
|
||||
|
||||
def test_typed_method_passes_org_id(self, saas_service):
|
||||
"""A typed method passes org_id through to self.capture."""
|
||||
service, mock_client = saas_service
|
||||
service.track_user_logged_in(
|
||||
distinct_id='user-1',
|
||||
idp='github',
|
||||
org_id='org-99',
|
||||
)
|
||||
mock_client.capture.assert_called_once()
|
||||
_, kwargs = mock_client.capture.call_args
|
||||
props = kwargs['properties']
|
||||
assert props.get('org_id') == 'org-99'
|
||||
Reference in New Issue
Block a user