Arag / app /services /analytics_core /tracker.py
AuthorBot
Fix analytics accuracy, tenant isolation, and event-loop blocking.
ff0847b
Raw
History Blame Contribute Delete
6.13 kB
"""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}