diff --git a/.env.example b/.env.example
index c3965bb..5f5bec4 100644
--- a/.env.example
+++ b/.env.example
@@ -19,3 +19,7 @@ VITE_API_URL=http://127.0.0.1:8001/api
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=pyqdb
+
+# PostHog
+POSTHOG_API_KEY=
+POSTHOG_HOST=https://us.i.posthog.com
diff --git a/backend/app/analytics.py b/backend/app/analytics.py
new file mode 100644
index 0000000..5f231ee
--- /dev/null
+++ b/backend/app/analytics.py
@@ -0,0 +1,17 @@
+from posthog import Posthog
+from .config import settings
+
+# Initialize PostHog client
+ph_client = Posthog(
+ settings.POSTHOG_API_KEY,
+ host=settings.POSTHOG_HOST,
+ # debug=True # Helpful for debugging delivery issues
+)
+
+# Gracefully disable if no API key is provided
+if not settings.POSTHOG_API_KEY:
+ print("⚠️ [POSTHOG] Disabled: No API Key found in settings")
+ ph_client.disabled = True
+else:
+ # print(f"✅ [POSTHOG] Initialized with Key: {settings.POSTHOG_API_KEY[:8]}***")
+ print(f"✅ [POSTHOG] Initialized")
diff --git a/backend/app/config.py b/backend/app/config.py
index a3ac0a7..199b9bc 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -32,6 +32,11 @@ class Settings(BaseSettings):
QUESTIONS_LIMIT: int = 30
PAPERS_LIMIT: int = 3
RESOURCES_LIMIT: int = 3
+
+ # PostHog Settings
+ POSTHOG_API_KEY: str = ""
+ POSTHOG_HOST: str = "https://us.i.posthog.com"
+
class Config:
env_file = ".env"
diff --git a/backend/app/main.py b/backend/app/main.py
index 4512a20..63f63fb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -2,6 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware
from .routers import auth, resources, solver, papers
from .config import settings
+from .analytics import ph_client
app = FastAPI(title="PYQ Solver API")
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 81b090c..86e6cd3 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -8,6 +8,7 @@
from ..schemas.user import UserCreate, UserLogin, UserOut, Token, TokenData, GoogleLogin
from ..services.security import get_password_hash, verify_password, create_access_token
from ..config import settings
+from ..analytics import ph_client
import httpx
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -57,6 +58,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
db.add(new_user)
await db.commit()
await db.refresh(new_user)
+
+ ph_client.capture("user_registered", distinct_id=str(new_user.id), properties={"email": new_user.email, "method": "password"})
+
return new_user
@router.post("/login", response_model=Token)
@@ -72,6 +76,7 @@ async def login(user_in: UserLogin, db: AsyncSession = Depends(get_db)):
)
access_token = create_access_token(subject=user.id)
+ ph_client.capture("user_logged_in", distinct_id=str(user.id), properties={"method": "password"})
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/google", response_model=Token)
@@ -110,6 +115,7 @@ async def google_login(data: GoogleLogin, db: AsyncSession = Depends(get_db)):
db.add(user)
await db.commit()
await db.refresh(user)
+ ph_client.capture("user_registered", distinct_id=str(user.id), properties={"email": email, "method": "google"})
elif not user.google_id:
# Link existing email account to Google ID
user.google_id = google_id
@@ -117,6 +123,7 @@ async def google_login(data: GoogleLogin, db: AsyncSession = Depends(get_db)):
await db.refresh(user)
access_token = create_access_token(subject=user.id)
+ ph_client.capture("user_logged_in", distinct_id=str(user.id), properties={"method": "google"})
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserOut)
diff --git a/backend/app/routers/papers.py b/backend/app/routers/papers.py
index 629784c..e69332d 100644
--- a/backend/app/routers/papers.py
+++ b/backend/app/routers/papers.py
@@ -24,6 +24,7 @@
from arq.connections import RedisSettings
from ..services.pdf import generate_paper_pdf
from ..services.storage import storage_service
+from ..analytics import ph_client
logger = logging.getLogger(__name__)
@@ -68,6 +69,12 @@ async def detect_format(
clean_json = clean_json[:-3]
format_config = json.loads(clean_json)
+
+ ph_client.capture("paper_format_detected", distinct_id=str(current_user.id), properties={
+ "resource_id": str(data.resource_id),
+ "format": format_config.get("format_name", "unknown")
+ })
+
return format_config
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to detect format: {str(e)}")
@@ -138,6 +145,12 @@ async def create_paper(
await db.commit()
await db.refresh(new_paper)
+ ph_client.capture("paper_generation_started", distinct_id=str(current_user.id), properties={
+ "paper_id": str(new_paper.id),
+ "num_resources": len(data.resources),
+ "delivery_mode": data.delivery_mode
+ })
+
from ..models.job import Job
new_job = Job(
user_id=current_user.id,
@@ -264,6 +277,8 @@ async def delete_paper(
await db.delete(paper)
await db.commit()
+ ph_client.capture("paper_deleted", distinct_id=str(current_user.id), properties={"paper_id": str(paper_id)})
+
return {"message": "Paper deleted successfully"}
@router.get("/{paper_id}/output", response_model=PaperOutputOut)
@@ -383,4 +398,9 @@ async def get_paper_pdf_url(
await db.commit()
+ ph_client.capture("paper_pdf_downloaded", distinct_id=str(current_user.id), properties={
+ "paper_id": str(paper_id),
+ "mode": mode
+ })
+
return {"url": pdf_url}
diff --git a/backend/app/routers/resources.py b/backend/app/routers/resources.py
index 11b4505..7375580 100644
--- a/backend/app/routers/resources.py
+++ b/backend/app/routers/resources.py
@@ -10,6 +10,7 @@
from ..schemas.resource import ResourceOut, ResourceUpdate
from ..routers.auth import get_current_user
from ..services.storage import storage_service
+from ..analytics import ph_client
from arq import create_pool
from ..config import settings
from arq.connections import RedisSettings
@@ -94,6 +95,13 @@ async def upload_resource(
detail="Failed to upload file to storage"
)
+ ph_client.capture("resource_upload_started", distinct_id=str(current_user.id), properties={
+ "filename": file.filename,
+ "content_type": file.content_type,
+ "size_mb": total_size / (1024 * 1024),
+ "type": type
+ })
+
try:
# Create DB record (not committed yet)
new_resource = Resource(
@@ -187,6 +195,8 @@ async def delete_resource(
await db.delete(resource)
await db.commit()
+ ph_client.capture("resource_deleted", distinct_id=str(current_user.id), properties={"resource_id": str(resource_id)})
+
return {"message": "Resource deleted successfully"}
@router.patch("/{resource_id}", response_model=ResourceOut)
@@ -246,6 +256,8 @@ async def retry_extraction(
# Only commit if enqueue was successful
await db.commit()
await db.refresh(resource)
+
+ ph_client.capture("resource_retry_extraction", distinct_id=str(current_user.id), properties={"resource_id": str(resource_id)})
except Exception as e:
await db.rollback()
raise HTTPException(
@@ -278,5 +290,6 @@ async def stop_processing(
resource.status = "failed"
await db.commit()
await db.refresh(resource)
+ ph_client.capture("resource_stop_processing", distinct_id=str(current_user.id), properties={"resource_id": str(resource_id)})
return resource
diff --git a/backend/app/routers/solver.py b/backend/app/routers/solver.py
index 92b82cd..dee15ac 100644
--- a/backend/app/routers/solver.py
+++ b/backend/app/routers/solver.py
@@ -17,6 +17,7 @@
from ..llm.client import open_router_client
from ..llm.prompts import SOLVER_SYSTEM, SOLVER_USER_TEMPLATE
from ..config import settings
+from ..analytics import ph_client
router = APIRouter(prefix="/solver", tags=["solver"])
@@ -32,6 +33,7 @@ async def create_session(
db.add(new_session)
await db.commit()
await db.refresh(new_session)
+ ph_client.capture("chat_session_created", distinct_id=str(current_user.id), properties={"session_id": str(new_session.id)})
return new_session
@router.get("/sessions", response_model=List[ChatSessionOut])
@@ -209,6 +211,13 @@ async def ask_question(
current_user.questions_used += 1
await db.commit()
+ ph_client.capture("ai_question_asked", distinct_id=str(current_user.id), properties={
+ "session_id": str(session_id),
+ "num_resources": len(data.resource_ids),
+ "delivery_mode": data.delivery_mode,
+ "question_length": len(data.content)
+ })
+
# 5. Prepare LLM Call
messages = [
{"role": "system", "content": SOLVER_SYSTEM},
diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py
index d86897c..4f42609 100644
--- a/backend/app/workers/tasks.py
+++ b/backend/app/workers/tasks.py
@@ -8,6 +8,7 @@
from ..models.resource import Resource
from ..config import settings
from ..services.storage import storage_service
+from ..analytics import ph_client
import httpx
import pypdfium2 as pdfium
from PIL import Image
@@ -165,6 +166,14 @@ async def extraction_task(ctx, resource_id: str, job_id: str = None):
print(f"💾 [STEP 3/3] Saving extracted text to DB...")
await db.commit()
+
+ ph_client.capture("resource_processed", distinct_id=str(resource.user_id), properties={
+ "resource_id": str(resource_id),
+ "filename": resource.filename,
+ "num_pages": num_pages,
+ "type": resource.type
+ })
+
print(f"🏁 [TASK COMPLETE] Resource {resource_id} is now READY.\n")
elif resource.filename.lower().endswith('.txt'):
@@ -180,6 +189,13 @@ async def extraction_task(ctx, resource_id: str, job_id: str = None):
job.completed_at = datetime.utcnow()
await db.commit()
+
+ ph_client.capture("resource_processed", distinct_id=str(resource.user_id), properties={
+ "resource_id": str(resource_id),
+ "filename": resource.filename,
+ "type": resource.type
+ })
+
print("🏁 [TASK COMPLETE] Text file is READY.\n")
else:
@@ -217,6 +233,12 @@ async def extraction_task(ctx, resource_id: str, job_id: str = None):
j.completed_at = datetime.utcnow()
await err_db.commit()
+
+ ph_client.capture("resource_processing_failed", distinct_id=str(res_to_fail.user_id), properties={
+ "resource_id": str(resource_id),
+ "error": str(e)
+ })
+
print(f"📉 [DB] Resource {resource_id} marked as FAILED.")
except Exception as final_err:
print(f"💀 [FATAL] Could not even mark as failed: {final_err}")
@@ -359,6 +381,12 @@ async def generate_paper_task(ctx, paper_id: str, job_id: str = None):
job.completed_at = datetime.utcnow()
await db.commit()
+
+ ph_client.capture("paper_generated", distinct_id=str(paper.user_id), properties={
+ "paper_id": str(paper_id),
+ "num_questions": len(questions)
+ })
+
print(f"🏁 [TASK COMPLETE] Paper {paper_id} is READY.\n")
except Exception as e:
@@ -382,6 +410,12 @@ async def generate_paper_task(ctx, paper_id: str, job_id: str = None):
j.completed_at = datetime.utcnow()
await err_db.commit()
+
+ ph_client.capture("paper_generation_failed", distinct_id=str(paper_to_fail.user_id), properties={
+ "paper_id": str(paper_id),
+ "error": str(e)
+ })
+
print(f"📉 [DB] Paper {paper_id} marked as FAILED.")
except:
pass
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 5043a18..29aa648 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -19,3 +19,4 @@ python-dotenv
pypdfium2
pillow
email-validator
+posthog
\ No newline at end of file
diff --git a/context/progress-tracker.md b/context/progress-tracker.md
index 026838d..c803d34 100644
--- a/context/progress-tracker.md
+++ b/context/progress-tracker.md
@@ -10,6 +10,12 @@
## Completed
+- **Sprint 7: Phase 4 (Analytics & Growth)**:
+ - **PostHog Integration**: Full-stack analytics pipeline implemented across React and FastAPI.
+ - **Granular Event Tracking**: Instrumented high-value actions including resource uploads, AI questions, paper generations, and PDF exports.
+ - **Conversion Funnel instrumentation**: Added behavioral tracking to Landing Page sections, FAQ interactions, and Pricing buttons.
+ - **Revenue Insight**: Instrumented `UpgradeModal` to track intent vs. hesitation on paywall hits.
+ - **Robust Backend Implementation**: Resolved circular imports and implemented a dedicated `analytics.py` module with debug mode and string-ID formatting.
- **Sprint 7: Phase 3 (Marketing & Identity)**:
- **PrepAI Landing Page**: Fully implemented as a high-converting single-page layout at `/`.
- Replicated professional Hero section and "Meet the two superpowers" from HTML template.
@@ -23,6 +29,8 @@
- **LLM Resilience**: Implemented exponential backoff retries (2s, 4s, 8s) in `OpenRouterClient` for all API calls.
- **Production Deployment**: VPS updated and live with unified Docker setup behind system Nginx reverse proxy.
- **Sprint 7: Phase 1 (UI/UX & Onboarding)**:
+ - **Solver & Generator Mobile UX**: Added Rename and Delete buttons to sidebar items and chat/paper headers on mobile for easier session management.
+ - **Inline Header Renaming**: Implemented a seamless renaming flow directly in both Solver and Generator headers.
- Implemented `PublicRoute` to redirect authenticated users away from login/register.
- Added dismissible `WelcomeBanner` on the Dashboard for new user guidance.
- Created `UpgradeModal` and integrated it into Solver, Generator, and Resources for quota limit handling.
@@ -53,6 +61,6 @@
## Session Notes
-- Sprint 7: Marketing & Identity phase complete. Landing page is live and fully polished.
+- Sprint 7: Analytics & Growth phase complete. Full-stack PostHog integration is live with granular behavioral tracking.
- App architecture is now clearly separated between public marketing and private dashboard.
-- Future focus shifted towards core AI performance (RAG) and monetization.
+- Future focus shifted towards core AI performance (RAG) and monetization (Razorpay).
diff --git a/frontend/.env.example b/frontend/.env.example
index 194386d..ac4f1f4 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1 +1,3 @@
VITE_API_URL=http://127.0.0.1:8001/api
+VITE_POSTHOG_KEY=
+VITE_POSTHOG_HOST=https://us.i.posthog.com
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 168a340..7277aa8 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
+ "posthog-js": "^1.218.1",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -1247,6 +1248,252 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
+ "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
+ "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/exporter-logs-otlp-http": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
+ "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/otlp-exporter-base": "0.208.0",
+ "@opentelemetry/otlp-transformer": "0.208.0",
+ "@opentelemetry/sdk-logs": "0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-exporter-base": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
+ "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/otlp-transformer": "0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-transformer": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
+ "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0",
+ "@opentelemetry/sdk-logs": "0.208.0",
+ "@opentelemetry/sdk-metrics": "2.2.0",
+ "@opentelemetry/sdk-trace-base": "2.2.0",
+ "protobufjs": "^7.3.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
+ "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.7.1",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
+ "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-logs": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
+ "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.4.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-metrics": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
+ "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.9.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
+ "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.41.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
+ "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
@@ -1257,6 +1504,84 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@posthog/core": {
+ "version": "1.29.7",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.7.tgz",
+ "integrity": "sha512-WcBD9/YQVGI9r/5+/IGeaPgsmTIg0YfyzaTei5TNlhmAeFOccnhs269rhtQJcAXngZFpvWSj+RTxX2ONdgxBDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "1.374.4"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.374.4",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.374.4.tgz",
+ "integrity": "sha512-OHBo+gReFwPJtt/yLY6xxa1EYMp7Ti07O1C1KE9ZXXyyuLNqekRaHZxJ/SKUfEvt1LhFV/9sioz8O0xfsSffsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -3438,6 +3763,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3447,7 +3773,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3468,6 +3794,13 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4469,6 +4802,17 @@
"node": ">=6.6.0"
}
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -4718,6 +5062,15 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
+ "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -5392,6 +5745,12 @@
"node": "^12.20 || >= 14.13"
}
},
+ "node_modules/fflate": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
+ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
+ "license": "MIT"
+ },
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -6724,6 +7083,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -8005,6 +8370,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/posthog-js": {
+ "version": "1.374.4",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.374.4.tgz",
+ "integrity": "sha512-6RtCHzeUKsfkd21QFXnOUbBhorVqemZ57xuJwxEpb0fTj4wrX+tlItm7seY0LsX5LXLRHPxA02uwL/DaGfWXKg==",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.208.0",
+ "@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
+ "@opentelemetry/resources": "^2.2.0",
+ "@opentelemetry/sdk-logs": "^0.208.0",
+ "@posthog/core": "1.29.7",
+ "@posthog/types": "1.374.4",
+ "core-js": "^3.38.1",
+ "dompurify": "^3.3.2",
+ "fflate": "^0.4.8",
+ "preact": "^10.28.2",
+ "query-selector-shadow-dom": "^1.0.1",
+ "web-vitals": "^5.1.0"
+ }
+ },
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -8017,6 +8403,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/preact": {
+ "version": "10.29.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
+ "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8074,6 +8470,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/protobufjs": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz",
+ "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -8121,6 +8541,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/query-selector-shadow-dom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
+ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9357,7 +9783,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -9754,6 +10180,12 @@
"node": ">= 8"
}
},
+ "node_modules/web-vitals": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
+ "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
+ "license": "Apache-2.0"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d55ef8f..ad5aa00 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
+ "posthog-js": "^1.218.1",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index eb47ecd..d8abab2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -17,6 +17,7 @@ import api from './api/auth';
import { resourcesApi } from './api/resources';
import { papersApi } from './api/papers';
import { useEffect } from 'react';
+import posthog from 'posthog-js';
import { TooltipProvider } from "@/components/ui/tooltip"
import {
@@ -42,6 +43,7 @@ import {
} from "lucide-react"
import { cn } from './lib/utils';
import { WelcomeBanner } from './components/WelcomeBanner';
+import { trackEvent } from './lib/analytics';
const queryClient = new QueryClient();
@@ -76,6 +78,11 @@ function Dashboard() {
useEffect(() => {
if (user && token) {
setAuth(user, token);
+ posthog.identify(user.id, {
+ email: user.email,
+ plan: user.plan,
+ });
+ trackEvent('dashboard_viewed');
}
}, [user, token, setAuth]);
diff --git a/frontend/src/components/UpgradeModal.tsx b/frontend/src/components/UpgradeModal.tsx
index 961b64e..56e4182 100644
--- a/frontend/src/components/UpgradeModal.tsx
+++ b/frontend/src/components/UpgradeModal.tsx
@@ -8,6 +8,7 @@ import {
} from "./ui/dialog";
import { Button } from "./ui/button";
import { CheckCircle2, Zap, FileText, FileEdit, Crown } from "lucide-react";
+import { trackEvent } from "../lib/analytics";
interface UpgradeModalProps {
isOpen: boolean;
@@ -67,6 +68,10 @@ export function UpgradeModal({ isOpen, onClose, message }: UpgradeModalProps) {
-