Spaces:
Running
Running
| """Author RAG Chatbot SaaS — Analytics Tracker. | |
| Fire-and-forget event logging for each chat turn. | |
| Device/geo parsing and DB event persistence. | |
| RULE: Failures here MUST NOT affect the chat response. | |
| """ | |
| import structlog | |
| from datetime import datetime, timezone | |
| from redis.asyncio import Redis | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.models.analytics import AnalyticsEvent | |
| from app.models.base import generate_uuid | |
| from app.models.chat_session import ChatMessage | |
| from app.services.token_budget import record_token_usage | |
| logger = structlog.get_logger(__name__) | |
| async def track_link_click( | |
| db: AsyncSession, | |
| session_id: str, | |
| author_id: str, | |
| book_id: str | None = None, | |
| ) -> None: | |
| """Record a purchase/preview link click for funnel analytics.""" | |
| try: | |
| event = AnalyticsEvent( | |
| id=generate_uuid(), | |
| session_id=session_id, | |
| author_id=author_id, | |
| book_id=book_id, | |
| timestamp=datetime.now(timezone.utc), | |
| turn_number=0, | |
| link_clicked=True, | |
| visitor_fingerprint="", | |
| ) | |
| db.add(event) | |
| await db.flush() | |
| logger.debug("Link click tracked", session_id=session_id) | |
| except Exception as e: | |
| logger.error("Link click tracking failed (non-fatal)", error=str(e)) | |
| async def track_turn( | |
| db: AsyncSession, | |
| redis: Redis, | |
| session_id: str, | |
| author_id: str, | |
| book_id: str | None, | |
| user_message: str, | |
| result, | |
| turn_number: int = 0, # DESIGN-5 fix: actual completed turn count, passed by caller | |
| ) -> None: | |
| """Log a chat turn to the analytics_events table. | |
| Args: | |
| db: Database session. | |
| redis: Redis connection. | |
| session_id: UUID of the chat session. | |
| author_id: UUID of the author. | |
| book_id: UUID of the selected book. | |
| user_message: The user's raw message (not stored — only metadata). | |
| result: PipelineResult from the RAG pipeline. | |
| turn_number: Completed turn count for this turn (from session_ctx.turn_count). | |
| """ | |
| try: | |
| from datetime import datetime, timezone | |
| from sqlalchemy import select | |
| from app.models.chat_session import ChatSession | |
| # DESIGN-4 fix: visitor_fingerprint was hardcoded to "" which made | |
| # unique-visitor aggregation (COUNT DISTINCT visitor_fingerprint) always | |
| # return 1. Now fetched from the ChatSession row written at session init. | |
| fp_result = await db.execute( | |
| select(ChatSession.visitor_fingerprint).where(ChatSession.id == session_id) | |
| ) | |
| visitor_fingerprint = fp_result.scalar_one_or_none() or "" | |
| # Save messages to DB | |
| user_msg = ChatMessage( | |
| id=generate_uuid(), | |
| session_id=session_id, | |
| role="user", | |
| content=user_message[:2000], | |
| ) | |
| bot_msg = ChatMessage( | |
| id=generate_uuid(), | |
| session_id=session_id, | |
| role="assistant", | |
| content=result.response["text"][:2000], | |
| intent=result.intent, | |
| intent_confidence=result.intent_confidence, | |
| faithfulness_score=result.faithfulness_score, | |
| hallucination_detected=result.hallucination_detected, | |
| boundary_triggered=result.boundary_triggered, | |
| upsell_strategy=result.upsell_strategy, | |
| link_shown=result.link_shown, | |
| prompt_tokens=result.prompt_tokens, | |
| completion_tokens=result.completion_tokens, | |
| response_ms=result.response_ms, | |
| ) | |
| db.add(user_msg) | |
| db.add(bot_msg) | |
| # Save analytics event | |
| event = AnalyticsEvent( | |
| id=generate_uuid(), | |
| session_id=session_id, | |
| author_id=author_id, | |
| book_id=book_id or (result.top_book_ids[0] if result.top_book_ids else None), | |
| timestamp=datetime.now(timezone.utc), | |
| # DESIGN-5 fix: use the actual completed turn number passed by the caller. | |
| # Aggregator never updated this field (see audit DESIGN-5). | |
| turn_number=turn_number, | |
| intent=result.intent, | |
| intent_confidence=result.intent_confidence, | |
| faithfulness_score=result.faithfulness_score, | |
| hallucination_detected=result.hallucination_detected, | |
| boundary_triggered=result.boundary_triggered, | |
| prompt_tokens=result.prompt_tokens, | |
| completion_tokens=result.completion_tokens, | |
| response_ms=result.response_ms, | |
| upsell_strategy=result.upsell_strategy, | |
| link_shown=result.link_shown, | |
| visitor_fingerprint=visitor_fingerprint, | |
| ) | |
| db.add(event) | |
| # Flush immediately so rows are visible to the DB before the request | |
| # session commits. Without this, rows sit only in the ORM identity map | |
| # and can be lost if the session is shared or rolled back elsewhere. | |
| await db.flush() | |
| total_tokens = result.prompt_tokens + result.completion_tokens | |
| await record_token_usage(db, redis, author_id, total_tokens) | |
| logger.debug("Turn tracked", session_id=session_id, tokens=total_tokens) | |
| except Exception as e: | |
| logger.error("Analytics tracking failed (non-fatal)", error=str(e)) | |
| def parse_device_info(request) -> dict: | |
| """Parse browser and device info from User-Agent. | |
| Args: | |
| request: FastAPI request. | |
| Returns: | |
| Dict with device_type, browser, os keys. | |
| """ | |
| ua_string = request.headers.get("User-Agent", "") | |
| try: | |
| from user_agents import parse | |
| ua = parse(ua_string) | |
| if ua.is_mobile: | |
| device_type = "mobile" | |
| elif ua.is_tablet: | |
| device_type = "tablet" | |
| elif ua.is_pc: | |
| device_type = "desktop" | |
| else: | |
| device_type = "unknown" | |
| return { | |
| "device_type": device_type, | |
| "browser": ua.browser.family[:100], | |
| "os": ua.os.family[:100], | |
| } | |
| except Exception: | |
| return {"device_type": "unknown", "browser": None, "os": None} | |