Compare commits

...

80 Commits

Author SHA1 Message Date
HeyItsChloe
0fb5b985e0 Merge branch 'main' into APP-1167/complete-posthog 2026-04-17 14:45:21 -07:00
HeyItsChloe
28fcfaae25 Merge branch 'APP-1167/complete-posthog' of https://github.com/OpenHands/OpenHands into APP-1167/complete-posthog 2026-04-17 12:26:49 -07:00
HeyItsChloe
57bcc69f64 added error tracking 2026-04-17 12:26:27 -07:00
openhands
1406937961 fix: format test file to pass ruff-format
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 18:00:55 +00:00
openhands
5d07387b4e fix: add git provider connected analytics and fix failing test
- Add git provider connected analytics tracking to V1 secrets router
- Fix test_keycloak_callback_redirects_to_keycloak_when_offline_token_invalid
  by adding missing OrgStore.get_org_by_id mock

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 17:52:54 +00:00
openhands
d1637cbc3c fix: resolve CI failures from merge conflict resolution
- Remove unused get_user_id import from secrets.py (ruff lint)
- Update test mock from posthog to get_analytics_service in test_auth_routes.py

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-16 16:49:50 +00:00
openhands
0eab81f89b Merge remote-tracking branch 'origin/main' into APP-1167/complete-posthog
# Conflicts:
#	enterprise/tests/unit/test_auth_routes.py
#	frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx
#	frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx
#	frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx
#	frontend/__tests__/components/features/user/user-context-menu.test.tsx
#	frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx
#	frontend/__tests__/posthog-tracking.test.tsx
#	frontend/__tests__/routes/billing.test.tsx
#	frontend/src/components/features/chat/chat-interface.tsx
#	frontend/src/components/features/device-verify/enterprise-banner.tsx
#	frontend/src/components/shared/modals/settings/settings-form.tsx
#	frontend/src/context/ws-client-provider.tsx
#	frontend/src/contexts/conversation-websocket-context.tsx
#	frontend/src/hooks/mutation/use-create-conversation.ts
#	frontend/src/hooks/mutation/use-save-settings.ts
#	frontend/src/hooks/mutation/use-submit-onboarding.ts
#	frontend/src/hooks/use-conversation-name-context-menu.ts
#	frontend/src/routes/billing.tsx
#	frontend/src/routes/onboarding-form.tsx
#	openhands/app_server/event_callback/webhook_router.py
#	openhands/server/routes/secrets.py
#	openhands/server/services/conversation_service.py
2026-04-16 16:35:51 +00:00
sp.wack
bd4a094eaf Merge branch 'main' into feat/revise-posthog 2026-03-27 18:45:53 +04:00
sp.wack
3ce4f629d6 Apply suggestion from @amanape 2026-03-27 18:45:38 +04:00
amanape
78e8a6c986 feat: add enterprise lead-gen events and useClientAnalytics hook for UI-only tracking 2026-03-27 18:24:19 +04:00
amanape
bacbbad32a Merge main into feat/revise-posthog - resolve conflicts, remove useTracking from new main files 2026-03-27 18:03:13 +04:00
openhands
d077b48a19 fix: remove extra blank line in auth.py
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 15:33:42 +00:00
amanape
5e96574730 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/server/routes/auth.py
2026-03-11 17:54:27 +04:00
amanape
589d12b5bd fix: resolve lint errors and update test assertions for analytics migration 2026-03-07 01:14:41 +04:00
sp.wack
cf48f4c91b Delete scripts/posthog-frontend-health-dashboard.py 2026-03-06 23:05:16 +04:00
amanape
63b93b6dc3 chore: remove .planning/ from git tracking 2026-03-06 22:04:30 +04:00
amanape
6f0ee09629 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/server/routes/auth.py
2026-03-06 21:44:41 +04:00
amanape
ed461b3ec1 feat(07-02): add PostHog Frontend Health dashboard creation script with 6 tiles (LCP, FCP, INP, CLS, error rate, top errors) 2026-03-06 20:43:11 +04:00
amanape
155da8dfd1 test(07-01): add test verifying PostHog health monitoring config options 2026-03-06 20:39:32 +04:00
amanape
29aa4f26d8 feat(07-01): add web vitals, error tracking, and network timing config to PostHogProvider 2026-03-06 20:39:03 +04:00
amanape
5409004c8d docs(07): create phase plan for frontend health monitoring 2026-03-06 20:35:02 +04:00
amanape
918f366a76 feat(06-01): migrate oauth_device.py analytics to Foundation patterns (resolve_context, identify_user, typed methods) 2026-03-06 20:18:54 +04:00
amanape
9b02f06400 feat(06-02): migrate billing.py, onboarding.py, and orgs.py to typed event methods + resolve_context 2026-03-06 20:18:22 +04:00
amanape
331c513042 feat(06-01): migrate auth.py analytics to Foundation patterns (resolve_context, identify_user, typed methods) 2026-03-06 20:18:17 +04:00
amanape
61af4662f1 feat(06-02): migrate conversation_callback_utils.py to resolve_context + typed event methods 2026-03-06 20:17:29 +04:00
amanape
4b77beaaa5 docs(06-migration): create phase plan for analytics call site migration 2026-03-06 20:13:14 +04:00
amanape
c0f08a33c3 feat(05-02): add 10 typed event methods to AnalyticsService 2026-03-06 19:55:08 +04:00
amanape
ddf2713483 test(05-02): add failing tests for 10 typed event methods on AnalyticsService 2026-03-06 19:54:14 +04:00
amanape
d39de5998a feat(05-01): add identify_user method to AnalyticsService consolidating person+group identify 2026-03-06 19:50:17 +04:00
amanape
782817c1c1 test(05-01): add failing tests for identify_user method on AnalyticsService 2026-03-06 19:49:41 +04:00
amanape
463777581e feat(05-01): add AnalyticsContext dataclass and resolve_context factory with full test coverage 2026-03-06 19:49:01 +04:00
amanape
5c42ee7a6c test(05-01): add failing tests for AnalyticsContext dataclass and resolve_context factory 2026-03-06 19:47:06 +04:00
amanape
aa9aed7016 docs(05-foundation): create phase plan (2 plans, 2 waves) 2026-03-06 19:41:22 +04:00
amanape
894d0eb439 fix: use correct import paths for enterprise modules in container runtime
The Docker build copies enterprise/ contents flat into /app/, so
`from enterprise.storage.X` doesn't exist at runtime — must use
`from storage.X` to match the actual module layout.
2026-03-06 00:37:34 +04:00
amanape
7c8e0b1eec fix(q6): add missing await on OrgStore.get_org_by_id in oauth_device.py 2026-03-05 22:51:19 +04:00
chuckbutkus
1a5d024c47 Merge branch 'main' into feat/revise-posthog 2026-03-05 12:33:30 -05:00
Chuck Butkus
0738e75dcf Fix async call 2026-03-05 12:30:49 -05:00
openhands
54766b4aeb fix: update PostHog tests and add missing await in auth.py
- Update test_auth_routes.py to use get_analytics_service instead of posthog
- Add OrgStore.get_org_by_id mocks to all tests using analytics
- Fix missing await for OrgStore.get_org_by_id call in auth.py
- Update assertions from .set to .set_person_properties

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 14:27:41 +00:00
amanape
4f65eae750 Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
#	frontend/src/components/features/context-menu/account-settings-context-menu.tsx
2026-03-05 17:45:34 +04:00
amanape
fdb6369476 reset 9 more formatting-only files to origin/main 2026-03-05 17:42:25 +04:00
amanape
77d672c68d revert unrelated ruff formatting changes from 23 files 2026-03-05 17:39:27 +04:00
Tim O'Farrell
21ac2a77ff Merge branch 'main' into feat/revise-posthog 2026-03-04 11:35:00 -07:00
openhands
b1c61c1534 fix: apply ruff formatting to webhook_router.py
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-04 14:24:22 +00:00
amanape
f450c407b5 Merge branch 'main' into feat/revise-posthog 2026-03-04 17:51:55 +04:00
openhands
500ed84d01 fix: apply enterprise linting fixes
- Fix import order to follow conventions
- Reformat assert statements for better readability
- Remove unnecessary blank lines

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 20:54:43 +00:00
amanape
999c18e072 fix: run ruff format on all enterprise files and fix remaining test mocks 2026-03-04 00:38:00 +04:00
amanape
a2e16d4819 fix: resolve ruff lint/format errors and update test mocks for analytics service 2026-03-04 00:23:30 +04:00
sp.wack
2f5147836f Merge branch 'main' into feat/revise-posthog 2026-03-03 23:24:28 +04:00
amanape
ed0f104645 fix(quick-3): remove stale experiments package from enterprise/pyproject.toml 2026-03-03 23:22:59 +04:00
amanape
192cfd5d91 chore(quick-2): regenerate enterprise/poetry.lock for posthog ^7.0.0 and pillow ^12.1.1
- Sync lock file after pyproject.toml bumped posthog ^6.0.0 -> ^7.0.0 and pillow ^12.1.0 -> ^12.1.1
2026-03-03 22:22:54 +04:00
amanape
8c2d3d1b9d fix(quick-2): fix ruff lint and format errors in webhook_router.py and secrets.py
- Fix I001 import ordering in webhook_router.py (top-level block: analytics before app_server/config; inline block: stdlib uuid before datetime)
- Apply ruff-format to both files (line length, quote style, indentation)
2026-03-03 22:22:32 +04:00
amanape
d3a274bbfa Merge remote-tracking branch 'origin/main' into feat/revise-posthog
# Conflicts:
#	enterprise/poetry.lock
#	frontend/src/components/features/auth/login-content.tsx
2026-03-03 22:10:39 +04:00
amanape
b9107ea3ad chore: remove .planning/ from tracking and add to .gitignore 2026-03-03 18:03:24 +04:00
amanape
8d66a58943 docs(04-01): complete activation events plan summary and state update 2026-03-03 17:24:13 +04:00
amanape
7864e9a8e3 docs(04-02): complete onboarding submission endpoint plan 2026-03-03 17:22:33 +04:00
amanape
f0b7e36bab feat(04-01): add GIT_PROVIDER_CONNECTED event capture in store_provider_tokens 2026-03-03 17:21:58 +04:00
amanape
53e87a7c27 feat(04-01): add USER_ACTIVATED event capture for first finished conversation 2026-03-03 17:21:19 +04:00
amanape
926ebf6906 feat(04-02): wire frontend onboarding hook to real API endpoint 2026-03-03 17:20:33 +04:00
amanape
7f25e9cad8 feat(04-02): create backend onboarding endpoint with analytics capture 2026-03-03 17:20:16 +04:00
amanape
2689768c95 docs(04-activation-and-dashboards): create phase plan 2026-03-03 05:00:34 +04:00
amanape
2f467558ed docs(04): research phase 4 activation and dashboards 2026-03-03 04:54:12 +04:00
amanape
b42ab23e1f feat(03-02): delete posthog-tracking test and remove useTracking mocks from 5 test files 2026-03-03 04:00:50 +04:00
amanape
3c5c307930 feat(03-02): remove all direct posthog.capture() calls from source files 2026-03-03 03:57:52 +04:00
amanape
d62d32af74 feat(03-01): add PostHog tracing headers and remove useTracking hook 2026-03-03 03:55:52 +04:00
amanape
f6201dd0de feat(02-03): add V0 best-effort terminal state analytics in conversation_callback_utils 2026-03-03 03:32:42 +04:00
amanape
ed4e2efd50 feat(02-03): add V1 terminal state analytics in on_event webhook 2026-03-03 03:32:42 +04:00
amanape
9b00b66efd feat(02-02): capture 'conversation created' event in V0 conversation_service 2026-03-03 03:16:22 +04:00
amanape
d78d9c4d99 feat(02-01): capture 'credit purchased' event in success_callback 2026-03-03 03:16:14 +04:00
amanape
04577c6448 feat(02-02): capture 'conversation created' event in V1 webhook_router 2026-03-03 03:15:55 +04:00
amanape
f8a0533f91 feat(02-01): capture 'user signed up' event in keycloak_callback 2026-03-03 03:15:49 +04:00
amanape
893a0db754 feat(01-02): wire SaasAppLifespanService and PostHogSessionMiddleware into saas_server.py 2026-03-03 02:42:51 +04:00
amanape
1f2bef34e3 feat(01-03): add analytics identity to device auth and org switch flows 2026-03-03 02:42:51 +04:00
amanape
62ed9e47cf feat(01-03): replace posthog.set() with full analytics identity in keycloak_callback 2026-03-03 02:42:51 +04:00
amanape
7b87237d3e feat(01-02): implement SaasAppLifespanService and PostHogSessionMiddleware 2026-03-03 02:42:51 +04:00
amanape
af74146f80 test(01-02): add failing tests for SaasAppLifespanService and PostHogSessionMiddleware 2026-03-03 02:42:51 +04:00
amanape
7760aba8e7 feat(01-03): set user_consents_to_analytics=True for new SaaS users in UserStore.create_user() 2026-03-03 02:42:51 +04:00
amanape
8550c91d0d chore(01-04): remove enterprise/experiments/ directory 2026-03-03 02:42:51 +04:00
amanape
d8db62b85b chore(01-01): bump PostHog SDK constraint to ^7.0.0 in enterprise/pyproject.toml 2026-03-03 02:42:51 +04:00
amanape
677f9bdd81 feat(01-01): implement AnalyticsService, constants, and OSS install ID module 2026-03-03 02:42:51 +04:00
amanape
9b1ce6d330 test(01-01): add failing tests for AnalyticsService, OSS install ID, and constants 2026-03-03 02:42:51 +04:00
71 changed files with 3623 additions and 1052 deletions

12
enterprise/poetry.lock generated
View File

@@ -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"

View File

@@ -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 = "*"

View File

@@ -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')

View 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')

View File

@@ -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)

View File

@@ -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,
},
)

View File

@@ -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
)

View File

@@ -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!'},

View 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='/')

View File

@@ -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
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')

View File

@@ -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'

View 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

View 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)

View File

@@ -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 />);

View File

@@ -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,

View File

@@ -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",
});
});
});

View File

@@ -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();

View File

@@ -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();
});

View File

@@ -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", () => {

View File

@@ -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(),
}),
}));

View File

@@ -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>

View File

@@ -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"),
}),
}),
}),
}),
);
);
});
});
});

View File

@@ -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 () => {

View 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();
});
});

View File

@@ -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",
}),
);
});
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,
});
});
});
});

View File

@@ -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);
}
};

View File

@@ -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",

View File

@@ -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);

View File

@@ -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));
};

View File

@@ -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());
};

View File

@@ -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));
};

View File

@@ -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" });

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);

View File

@@ -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}

View File

@@ -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,
});
},
});
};

View File

@@ -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,
],
);

View File

@@ -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;

View File

@@ -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],
});

View File

@@ -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"],
});

View File

@@ -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 () => {

View File

@@ -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://") ||

View 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,
};
};

View File

@@ -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`);

View File

@@ -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,
};
};

View File

@@ -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} />;
}

View File

@@ -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);
}

View File

@@ -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
});
}
}

View File

@@ -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,

View 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.

View 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',
]

View 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'

View 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)

View 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

View 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

View File

@@ -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,

View File

@@ -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

View File

@@ -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',
)

View File

@@ -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'},

View File

@@ -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)

View 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)

View 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'