diff --git a/api/conftest.py b/api/conftest.py index c726961..33781ec 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -359,7 +359,7 @@ async def test_client_factory(db_session): Usage: async def test_something(test_client_factory, db_session): # Create a custom user - user = await db_session.get_or_create_user_by_provider_id("custom_user_123") + user, _ = await db_session.get_or_create_user_by_provider_id("custom_user_123") # Create a test client for this user async with test_client_factory(user) as client: diff --git a/api/db/user_client.py b/api/db/user_client.py index 6c98164..cc2acb4 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -11,7 +11,10 @@ from api.schemas.user_configuration import UserConfiguration class UserClient(BaseDBClient): - async def get_or_create_user_by_provider_id(self, provider_id: str) -> UserModel: + async def get_or_create_user_by_provider_id( + self, provider_id: str + ) -> tuple[UserModel, bool]: + """Return (user, was_created) tuple.""" async with self.async_session() as session: # First try to get existing user result = await session.execute( @@ -19,36 +22,39 @@ class UserClient(BaseDBClient): ) user = result.scalars().first() + if user is not None: + return user, False + + # Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING + # This is atomic and handles race conditions at the database level + from sqlalchemy.dialects.postgresql import insert + + stmt = insert(UserModel.__table__).values( + provider_id=provider_id, + created_at=datetime.now(timezone.utc), + selected_organization_id=None, # Will be set later + is_superuser=False, # Default value + ) + # ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op + stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"]) + + result = await session.execute(stmt) + await session.commit() + was_created = result.rowcount > 0 + + # Now fetch the user (either the one we just created or the one that existed) + result = await session.execute( + select(UserModel).where(UserModel.provider_id == provider_id) + ) + user = result.scalars().first() + if user is None: - # Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING - # This is atomic and handles race conditions at the database level - from sqlalchemy.dialects.postgresql import insert - - stmt = insert(UserModel.__table__).values( - provider_id=provider_id, - created_at=datetime.now(timezone.utc), - selected_organization_id=None, # Will be set later - is_superuser=False, # Default value + # This should never happen, but handle it just in case + error_msg = ( + f"Failed to create or fetch user with provider_id {provider_id}" ) - # ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op - stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"]) - - result = await session.execute(stmt) - await session.commit() - - # Now fetch the user (either the one we just created or the one that existed) - result = await session.execute( - select(UserModel).where(UserModel.provider_id == provider_id) - ) - user = result.scalars().first() - - if user is None: - # This should never happen, but handle it just in case - error_msg = ( - f"Failed to create or fetch user with provider_id {provider_id}" - ) - raise ValueError(error_msg) - return user + raise ValueError(error_msg) + return user, was_created async def get_user_by_id(self, user_id: int) -> UserModel | None: """Fetch a user by their internal ID.""" diff --git a/api/enums.py b/api/enums.py index 95915ad..c07879a 100644 --- a/api/enums.py +++ b/api/enums.py @@ -155,3 +155,5 @@ class PostHogEvent(str, Enum): KNOWLEDGE_BASE_CREATED = "knowledge_base_created" TOOL_CREATED = "tool_created" AGENT_EMBEDDED = "agent_embedded" + SIGNED_UP = "signed_up" + SIGNED_IN = "signed_in" diff --git a/api/routes/auth.py b/api/routes/auth.py index 137ff7b..b6773a6 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -3,8 +3,10 @@ from loguru import logger from api.db import db_client from api.db.models import UserModel +from api.enums import PostHogEvent from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse from api.services.auth.depends import create_user_configuration_with_mps_key, get_user +from api.services.posthog_client import capture_event from api.utils.auth import create_jwt_token, hash_password, verify_password router = APIRouter( @@ -53,6 +55,15 @@ async def signup(request: SignupRequest): # Create JWT token token = create_jwt_token(user.id, request.email) + capture_event( + distinct_id=str(user.provider_id), + event=PostHogEvent.SIGNED_UP, + properties={ + "organization_id": organization.id, + "auth_provider": "local", + }, + ) + return AuthResponse( token=token, user=UserResponse( @@ -79,6 +90,15 @@ async def login(request: LoginRequest): # Create JWT token token = create_jwt_token(user.id, user.email) + capture_event( + distinct_id=str(user.provider_id), + event=PostHogEvent.SIGNED_IN, + properties={ + "organization_id": user.selected_organization_id, + "auth_provider": "local", + }, + ) + return AuthResponse( token=token, user=UserResponse( diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 54e0e05..35dc0b9 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -8,9 +8,11 @@ from pydantic import ValidationError from api.constants import AUTH_PROVIDER, DOGRAH_MPS_SECRET_KEY, MPS_API_URL from api.db import db_client from api.db.models import UserModel +from api.enums import PostHogEvent from api.schemas.user_configuration import UserConfiguration from api.services.auth.stack_auth import stackauth from api.services.configuration.registry import ServiceProviders +from api.services.posthog_client import capture_event from api.utils.auth import decode_jwt_token @@ -54,7 +56,7 @@ async def get_user( # ------------------------------------------------------------------ try: - user_model = await db_client.get_or_create_user_by_provider_id(stack_user["id"]) + user_model, user_was_created = await db_client.get_or_create_user_by_provider_id(stack_user["id"]) # Sync email from Stack Auth if available and not already set stack_email = stack_user.get("primary_email_verified") and stack_user.get( @@ -63,6 +65,15 @@ async def get_user( if stack_email and user_model.email != stack_email: await db_client.update_user_email(user_model.id, stack_email) user_model.email = stack_email + + if user_was_created: + capture_event( + distinct_id=str(stack_user["id"]), + event=PostHogEvent.SIGNED_UP, + properties={ + "auth_provider": "stack", + }, + ) except Exception as e: raise HTTPException( status_code=500, detail=f"Error while creating user from database {e}" diff --git a/api/services/posthog_client.py b/api/services/posthog_client.py index d9b262b..1b4d5e0 100644 --- a/api/services/posthog_client.py +++ b/api/services/posthog_client.py @@ -1,7 +1,7 @@ from loguru import logger from posthog import Posthog -from api.constants import POSTHOG_API_KEY, POSTHOG_HOST +from api.constants import ENABLE_TELEMETRY, POSTHOG_API_KEY, POSTHOG_HOST _posthog_client: Posthog | None = None @@ -9,7 +9,7 @@ _posthog_client: Posthog | None = None def get_posthog() -> Posthog | None: """Return the lazily-initialised PostHog client, or None if not configured.""" global _posthog_client - if _posthog_client is None and POSTHOG_API_KEY: + if _posthog_client is None and POSTHOG_API_KEY and ENABLE_TELEMETRY: _posthog_client = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) return _posthog_client diff --git a/docker-compose.yaml b/docker-compose.yaml index 262746e..466ccb1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -123,6 +123,11 @@ services: OSS_JWT_SECRET: "${OSS_JWT_SECRET:-ChangeMeInProduction}" + # Telemetry + ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" + POSTHOG_API_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr" + POSTHOG_HOST: "https://us.i.posthog.com" + ports: - "8000:8000" depends_on: diff --git a/ui/package-lock.json b/ui/package-lock.json index 904b266..982ae57 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.24.0", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.24.0", + "version": "1.26.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@nangohq/frontend": "^0.69.47", diff --git a/ui/src/components/PostHogIdentify.tsx b/ui/src/components/PostHogIdentify.tsx index 3a9c83b..ece5a44 100644 --- a/ui/src/components/PostHogIdentify.tsx +++ b/ui/src/components/PostHogIdentify.tsx @@ -3,6 +3,7 @@ import posthog from 'posthog-js'; import { useEffect } from 'react'; +import { PostHogEvent } from '@/constants/posthog-events'; import { useAuth } from '@/lib/auth'; /** @@ -44,6 +45,7 @@ export default function PostHogIdentify() { ...(email && { email }), ...(name && { name }), }); + posthog.capture(PostHogEvent.SIGNED_IN); } catch (err) { console.warn('Failed to identify user in PostHog', err); } diff --git a/ui/src/constants/posthog-events.ts b/ui/src/constants/posthog-events.ts index e09244f..66302e7 100644 --- a/ui/src/constants/posthog-events.ts +++ b/ui/src/constants/posthog-events.ts @@ -8,6 +8,7 @@ export const PostHogEvent = { RECORDING_PLAYED: "recording_played", TRANSCRIPT_VIEWED: "transcript_viewed", WEB_CALL_INITIATED: "web_call_initiated", + SIGNED_IN: "signed_in", GITHUB_STAR_CLICKED: "github_star_clicked", SLACK_COMMUNITY_CLICKED: "slack_community_clicked", } as const;