Skip to content

Commit 319c305

Browse files
committed
feat: implement Leo Analytics tracking service and routes for first-party analytics
1 parent 03bb992 commit 319c305

6 files changed

Lines changed: 777 additions & 6 deletions

File tree

‎backend/api/routes/tracking.py‎

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""
2+
Leo Analytics Tracking routes.
3+
4+
Public ingest endpoint (no auth, CORS open):
5+
POST /t/v1/e — receive a tracking event from the JS snippet
6+
7+
Project management endpoints (auth required, Pro+ tier):
8+
GET /projects/{id}/tracking/status — is tracking enabled? token? stats summary
9+
POST /projects/{id}/tracking/enable — generate token, enable tracking
10+
POST /projects/{id}/tracking/disable — disable tracking
11+
GET /projects/{id}/tracking/stats — aggregated pageviews, sessions, pages, sources
12+
GET /projects/{id}/tracking/insights — AI insights from tracking data
13+
GET /track.js — serve the Leo tracking script
14+
"""
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import logging
19+
20+
from fastapi import APIRouter, HTTPException, Query, Request
21+
from fastapi.responses import PlainTextResponse, Response
22+
from pydantic import BaseModel, Field
23+
24+
from backend.api.deps import get_project_as_editor, get_project_as_member, require_tier
25+
from backend.middleware.auth import CurrentUser
26+
from backend.services import tracking_service
27+
28+
logger = logging.getLogger(__name__)
29+
30+
# Project-scoped routes (auth required)
31+
router = APIRouter(prefix="/projects/{project_id}/tracking", tags=["tracking"])
32+
33+
# Public routes (no prefix, no auth)
34+
public_router = APIRouter(tags=["tracking-public"])
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# Tracking script — served as JavaScript
39+
# ---------------------------------------------------------------------------
40+
41+
_TRACK_JS = """/* Leo Analytics — v1.0 */
42+
(function(){
43+
var d=document,w=window,s=d.currentScript||d.querySelector('script[data-token]');
44+
if(!s)return;
45+
var token=s.getAttribute('data-token');
46+
if(!token)return;
47+
48+
var sid=(function(){
49+
var k='_lt_sid',v=sessionStorage.getItem(k);
50+
if(!v){v=Math.random().toString(36).slice(2)+Date.now().toString(36);sessionStorage.setItem(k,v);}
51+
return v;
52+
})();
53+
54+
function send(type,extra){
55+
if(navigator.doNotTrack==='1')return;
56+
var payload={
57+
token:token,type:type,
58+
url:location.href,referrer:d.referrer||'',
59+
title:d.title,session_id:sid,
60+
screen:w.screen.width+'x'+w.screen.height,
61+
ua:navigator.userAgent
62+
};
63+
Object.assign(payload,extra||{});
64+
var endpoint='/api/backend/t/v1/e';
65+
if(navigator.sendBeacon){navigator.sendBeacon(endpoint,JSON.stringify(payload));}
66+
else{fetch(endpoint,{method:'POST',body:JSON.stringify(payload),keepalive:true}).catch(function(){});}
67+
}
68+
69+
// Pageview on load
70+
if(d.readyState==='complete'||d.readyState==='interactive'){send('pageview');}
71+
else{d.addEventListener('DOMContentLoaded',function(){send('pageview');});}
72+
73+
// SPA route change support
74+
var lastUrl=location.href;
75+
var obs=new MutationObserver(function(){
76+
if(location.href!==lastUrl){lastUrl=location.href;setTimeout(function(){send('pageview');},300);}
77+
});
78+
obs.observe(d.body||d.documentElement,{childList:true,subtree:true});
79+
80+
// Expose manual event tracker
81+
w.leo=w.leo||{};
82+
w.leo.track=function(name,props){send('event',{name:name,props:JSON.stringify(props||{})});};
83+
})();
84+
"""
85+
86+
_TRACK_JS_HEADERS = {
87+
"Content-Type": "application/javascript",
88+
"Cache-Control": "public, max-age=3600",
89+
"Access-Control-Allow-Origin": "*",
90+
}
91+
92+
93+
@public_router.get("/track.js", include_in_schema=False)
94+
async def serve_tracking_script():
95+
"""Serve the Leo Analytics tracking script."""
96+
return Response(content=_TRACK_JS, headers=_TRACK_JS_HEADERS)
97+
98+
99+
# ---------------------------------------------------------------------------
100+
# Event ingest — no auth, open CORS, must be fast
101+
# ---------------------------------------------------------------------------
102+
103+
class TrackEventPayload(BaseModel):
104+
token: str = Field(..., min_length=1, max_length=80)
105+
type: str = Field(default="pageview", max_length=32)
106+
url: str = Field(default="", max_length=512)
107+
referrer: str = Field(default="", max_length=512)
108+
title: str = Field(default="", max_length=200)
109+
session_id: str = Field(default="", max_length=64)
110+
screen: str = Field(default="", max_length=32)
111+
ua: str = Field(default="", max_length=512)
112+
name: str = Field(default="", max_length=64)
113+
props: str = Field(default="{}", max_length=512)
114+
115+
116+
@public_router.post("/t/v1/e", include_in_schema=False)
117+
async def ingest_event(payload: TrackEventPayload, request: Request):
118+
"""
119+
High-throughput event ingest endpoint.
120+
- No authentication (open to all origins)
121+
- Rate-limited by SlowAPI at a generous limit
122+
- Returns 204 No Content always (even on error) to avoid client-side noise
123+
"""
124+
project_id = await asyncio.to_thread(
125+
tracking_service.resolve_token_to_project, payload.token
126+
)
127+
if project_id:
128+
event = payload.model_dump(exclude={"token"})
129+
await asyncio.to_thread(tracking_service.ingest_event, project_id, event)
130+
131+
return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
132+
133+
134+
@public_router.options("/t/v1/e", include_in_schema=False)
135+
async def ingest_event_preflight():
136+
"""CORS preflight for the ingest endpoint."""
137+
return Response(
138+
status_code=204,
139+
headers={
140+
"Access-Control-Allow-Origin": "*",
141+
"Access-Control-Allow-Methods": "POST, OPTIONS",
142+
"Access-Control-Allow-Headers": "Content-Type",
143+
"Access-Control-Max-Age": "86400",
144+
},
145+
)
146+
147+
148+
# ---------------------------------------------------------------------------
149+
# Project tracking management
150+
# ---------------------------------------------------------------------------
151+
152+
@router.get("/status")
153+
async def tracking_status(
154+
project_id: str,
155+
user: CurrentUser,
156+
_tier: None = require_tier("pro"),
157+
):
158+
"""Return tracking configuration and a 7-day pageview summary."""
159+
get_project_as_member(project_id, user["uid"])
160+
config = await asyncio.to_thread(tracking_service.get_tracking_config, project_id)
161+
if not config:
162+
return {"enabled": False, "token": None, "created_at": None, "pageviews_7d": 0}
163+
164+
pageviews_7d = 0
165+
if config.get("enabled"):
166+
stats = await tracking_service.get_stats(project_id, days=7)
167+
pageviews_7d = stats.get("pageviews", 0)
168+
169+
return {**config, "pageviews_7d": pageviews_7d}
170+
171+
172+
@router.post("/enable")
173+
async def enable_tracking(
174+
project_id: str,
175+
user: CurrentUser,
176+
_tier: None = require_tier("pro"),
177+
):
178+
"""Generate a tracking token and enable Leo Analytics for the project."""
179+
get_project_as_editor(project_id, user["uid"])
180+
181+
# Check if already enabled — re-use existing token
182+
config = await asyncio.to_thread(tracking_service.get_tracking_config, project_id)
183+
if config and config.get("enabled") and config.get("token"):
184+
return {"token": config["token"], "already_enabled": True}
185+
186+
token = tracking_service.generate_tracking_token()
187+
await asyncio.to_thread(tracking_service.save_tracking_config, project_id, token)
188+
return {"token": token, "already_enabled": False}
189+
190+
191+
@router.post("/disable")
192+
async def disable_tracking(
193+
project_id: str,
194+
user: CurrentUser,
195+
_tier: None = require_tier("pro"),
196+
):
197+
"""Disable Leo Analytics tracking for the project."""
198+
get_project_as_editor(project_id, user["uid"])
199+
await asyncio.to_thread(tracking_service.disable_tracking, project_id)
200+
return {"ok": True}
201+
202+
203+
@router.get("/stats")
204+
async def tracking_stats(
205+
project_id: str,
206+
user: CurrentUser,
207+
days: int = Query(30, ge=1, le=365),
208+
_tier: None = require_tier("pro"),
209+
):
210+
"""
211+
Return aggregated Leo tracking stats:
212+
pageviews, sessions, top pages, top sources, daily chart.
213+
"""
214+
get_project_as_member(project_id, user["uid"])
215+
216+
config = await asyncio.to_thread(tracking_service.get_tracking_config, project_id)
217+
if not config or not config.get("enabled"):
218+
raise HTTPException(
219+
status_code=400,
220+
detail="Leo Analytics not enabled for this project.",
221+
)
222+
223+
return await tracking_service.get_stats(project_id, days=days)
224+
225+
226+
@router.post("/insights")
227+
async def tracking_insights(
228+
project_id: str,
229+
user: CurrentUser,
230+
_tier: None = require_tier("pro"),
231+
):
232+
"""Generate AI insights from Leo tracking data."""
233+
get_project_as_member(project_id, user["uid"])
234+
235+
config = await asyncio.to_thread(tracking_service.get_tracking_config, project_id)
236+
if not config or not config.get("enabled"):
237+
raise HTTPException(status_code=400, detail="Leo Analytics not enabled.")
238+
239+
stats = await tracking_service.get_stats(project_id, days=30)
240+
insights = await tracking_service.generate_tracking_insights(project_id, stats)
241+
return {"insights": insights}

‎backend/main.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def _init_sentry() -> None:
4646

4747
from backend.config import settings
4848
from backend.services import firebase_service
49-
from backend.api.routes import projects, chats, stream, ingestion, brand_core, billing, assets, campaigns, generate, members, admin, announcements, intelligence, content_ops, content_studio, templates, planner, analytics, reports, monitoring, seo, posts, credits, deep_search, strategy, carousel, threads, competitors, personal_brand, knowledge, brand_audit, competitor_digest, blog, integrations, brand_chat
49+
from backend.api.routes import projects, chats, stream, ingestion, brand_core, billing, assets, campaigns, generate, members, admin, announcements, intelligence, content_ops, content_studio, templates, planner, analytics, reports, monitoring, seo, posts, credits, deep_search, strategy, carousel, threads, competitors, personal_brand, knowledge, brand_audit, competitor_digest, blog, integrations, brand_chat, tracking
5050
from backend.middleware.rate_limit import limiter
5151
from backend.middleware.request_id import RequestIdFilter, RequestIdMiddleware
5252

@@ -255,6 +255,8 @@ async def dispatch(self, request: Request, call_next):
255255
app.include_router(integrations.auth_router)
256256
app.include_router(brand_chat.router)
257257
app.include_router(templates.router_community)
258+
app.include_router(tracking.router)
259+
app.include_router(tracking.public_router)
258260

259261
# ---------------------------------------------------------------------------
260262
# Health endpoints

0 commit comments

Comments
 (0)