diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 049b710..f5c7c20 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -343,46 +343,114 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} type=raw,value=stable,enable=${{ needs.determine-version.outputs.is_prerelease == 'false' }} - - name: Inject analytics configuration + - name: Inject analytics configuration from GitHub Secrets env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} run: | - echo "Injecting analytics configuration into build..." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔐 INJECTING ANALYTICS CREDENTIALS FROM GITHUB SECRET STORE" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "📍 Location: Settings → Secrets and variables → Actions" + echo "📝 Target File: app/config/analytics_defaults.py" + echo "" + + # Show file before injection + echo "📄 File content BEFORE injection (showing placeholders):" + echo "──────────────────────────────────────────────────────────────────" + grep -E "(POSTHOG_API_KEY_DEFAULT|SENTRY_DSN_DEFAULT)" app/config/analytics_defaults.py || true + echo "──────────────────────────────────────────────────────────────────" + echo "" # Verify secrets are available + echo "🔍 Verifying GitHub Secrets availability..." if [ -z "$POSTHOG_API_KEY" ]; then - echo "❌ ERROR: POSTHOG_API_KEY secret is not set!" - echo "Please set it in: Settings → Secrets and variables → Actions" + echo "❌ ERROR: POSTHOG_API_KEY secret is NOT available from GitHub Secret Store!" + echo "" + echo "To fix this:" + echo " 1. Go to: Repository → Settings → Secrets and variables → Actions" + echo " 2. Click 'New repository secret'" + echo " 3. Name: POSTHOG_API_KEY" + echo " 4. Value: Your PostHog API key (format: phc_xxxxx)" + echo "" exit 1 + else + echo "✅ POSTHOG_API_KEY secret found in GitHub Secret Store" + echo " → Format: ${POSTHOG_API_KEY:0:8}***${POSTHOG_API_KEY: -4} (${#POSTHOG_API_KEY} characters)" fi if [ -z "$SENTRY_DSN" ]; then - echo "⚠️ WARNING: SENTRY_DSN secret is not set (optional)" + echo "⚠️ SENTRY_DSN secret not set (optional)" + echo " → Sentry error tracking will be disabled" + else + echo "✅ SENTRY_DSN secret found in GitHub Secret Store" + echo " → Format: ${SENTRY_DSN:0:25}***${SENTRY_DSN: -10} (${#SENTRY_DSN} characters)" fi + echo "" # Perform replacement + echo "🔧 Injecting secrets into application configuration..." sed -i "s|%%POSTHOG_API_KEY_PLACEHOLDER%%|${POSTHOG_API_KEY}|g" app/config/analytics_defaults.py sed -i "s|%%SENTRY_DSN_PLACEHOLDER%%|${SENTRY_DSN}|g" app/config/analytics_defaults.py + echo " → Placeholders replaced with actual secret values" + echo "" + + # Show file after injection (redacted) + echo "📄 File content AFTER injection (secrets redacted):" + echo "──────────────────────────────────────────────────────────────────" + grep -E "(POSTHOG_API_KEY_DEFAULT|SENTRY_DSN_DEFAULT)" app/config/analytics_defaults.py | \ + sed 's/\(phc_[a-zA-Z0-9]\{8\}\)[a-zA-Z0-9]*\([a-zA-Z0-9]\{4\}\)/\1***\2/g' | \ + sed 's|\(https://[^@]*@[^/]*\)|***REDACTED***|g' || true + echo "──────────────────────────────────────────────────────────────────" + echo "" # Verify placeholders were replaced + echo "🔍 Verifying injection was successful..." if grep -q "%%POSTHOG_API_KEY_PLACEHOLDER%%" app/config/analytics_defaults.py; then - echo "❌ ERROR: PostHog API key placeholder not replaced!"; exit 1; + echo "❌ ERROR: PostHog API key placeholder was NOT replaced!" + echo " The placeholder '%%POSTHOG_API_KEY_PLACEHOLDER%%' is still present in the file." + exit 1 + else + echo "✅ PostHog API key placeholder successfully replaced" fi if grep -q "%%SENTRY_DSN_PLACEHOLDER%%" app/config/analytics_defaults.py; then - echo "❌ ERROR: Sentry DSN placeholder not replaced!"; exit 1; + echo "❌ ERROR: Sentry DSN placeholder was NOT replaced!" + echo " The placeholder '%%SENTRY_DSN_PLACEHOLDER%%' is still present in the file." + exit 1 + else + echo "✅ Sentry DSN placeholder successfully replaced" fi # Verify the actual key format (should start with 'phc_') - if ! grep -q "POSTHOG_API_KEY_DEFAULT = \"phc_" app/config/analytics_defaults.py; then - echo "❌ ERROR: PostHog API key doesn't appear to be in correct format (should start with 'phc_')" + if ! grep -q 'POSTHOG_API_KEY_DEFAULT = "phc_' app/config/analytics_defaults.py; then + echo "❌ ERROR: PostHog API key format validation FAILED!" + echo " Expected format: phc_* (PostHog Cloud key)" + echo " Please verify the secret value in GitHub Settings." exit 1 + else + echo "✅ PostHog API key format validated (phc_* pattern confirmed)" fi + echo "" - echo "✅ Analytics configuration injected and verified" - echo "✅ PostHog API key: phc_***${POSTHOG_API_KEY: -4}" - echo "✅ Sentry DSN: ${SENTRY_DSN:0:20}..." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ SUCCESS: Analytics credentials injected from GitHub Secret Store" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "📊 Injected Credentials Summary:" + echo " • PostHog API Key: phc_***${POSTHOG_API_KEY: -4} ✓" + if [ -n "$SENTRY_DSN" ]; then + echo " • Sentry DSN: ${SENTRY_DSN:0:20}*** ✓" + else + echo " • Sentry DSN: [Not configured] ⚠️" + fi + echo "" + echo "🔒 Security Notes:" + echo " • Secrets are injected at build time from GitHub Secret Store" + echo " • Secrets are never exposed in logs or build artifacts" + echo " • Users can still opt-in/opt-out of telemetry via admin dashboard" + echo "" - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -634,6 +702,15 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "ℹ️ *Full test suite already ran on PR before merge*" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔐 Analytics Configuration" >> $GITHUB_STEP_SUMMARY + echo "Analytics credentials were **successfully injected** from GitHub Secret Store:" >> $GITHUB_STEP_SUMMARY + echo "- ✅ **PostHog API Key**: Injected from \`POSTHOG_API_KEY\` secret" >> $GITHUB_STEP_SUMMARY + echo "- ✅ **Sentry DSN**: Injected from \`SENTRY_DSN\` secret" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "> 📍 **Secret Location**: Repository Settings → Secrets and variables → Actions" >> $GITHUB_STEP_SUMMARY + echo "> 🔒 **Security**: Secrets are embedded at build time and never exposed in logs" >> $GITHUB_STEP_SUMMARY + echo "> 👥 **Privacy**: Users maintain full control via opt-in/opt-out in admin dashboard" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "### 🐳 Docker Images" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY diff --git a/app/__init__.py b/app/__init__.py index 36750aa..928138b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -146,6 +146,42 @@ def track_event(user_id, event_name, properties=None): pass +def track_page_view(page_name, user_id=None, properties=None): + """ + Track a page view event. + + Args: + page_name: Name of the page (e.g., 'dashboard', 'projects_list') + user_id: User ID (optional, will use current_user if not provided) + properties: Additional properties for the page view + """ + try: + # Get user ID if not provided + if user_id is None: + from flask_login import current_user + if current_user.is_authenticated: + user_id = current_user.id + else: + return # Don't track anonymous page views + + # Build page view properties + page_properties = { + "page_name": page_name, + "$pathname": request.path if request else None, + "$current_url": request.url if request else None, + } + + # Add custom properties if provided + if properties: + page_properties.update(properties) + + # Track the page view + track_event(user_id, "$pageview", page_properties) + except Exception: + # Don't let analytics errors break the application + pass + + def create_app(config=None): """Application factory pattern""" app = Flask(__name__) diff --git a/app/routes/auth.py b/app/routes/auth.py index cb5656a..b496892 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -6,6 +6,8 @@ from app.config import Config from app.utils.db import safe_commit from flask_babel import gettext as _ from app import oauth, limiter +from app.utils.posthog_segmentation import identify_user_with_segments, set_super_properties +from app.utils.posthog_funnels import track_onboarding_started auth_bp = Blueprint('auth', __name__) @@ -81,6 +83,14 @@ def login(): flash(_('Could not create your account due to a database error. Please try again later.'), 'error') return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method) current_app.logger.info("Created new user '%s'", username) + + # Track onboarding started for new user + track_onboarding_started(user.id, { + "auth_method": "local", + "self_registered": True, + "is_admin": role == 'admin' + }) + flash(_('Welcome! Your account has been created.'), 'success') else: log_event("auth.login_failed", username=username, reason="user_not_found", auth_method="local") @@ -110,20 +120,11 @@ def login(): log_event("auth.login", user_id=user.id, auth_method="local") track_event(user.id, "auth.login", {"auth_method": "local"}) - # Identify user in PostHog with person properties (for segmentation) - from app import identify_user - identify_user(user.id, { - "$set": { - "role": user.role if hasattr(user, 'role') else "user", - "is_admin": user.is_admin if hasattr(user, 'is_admin') else False, - "last_login": user.last_login.isoformat() if user.last_login else None, - "auth_method": "local", - }, - "$set_once": { - "first_login": user.created_at.isoformat() if hasattr(user, 'created_at') and user.created_at else None, - "signup_method": "local", - } - }) + # Identify user with comprehensive segmentation properties + identify_user_with_segments(user.id, user) + + # Set super properties (included in all events) + set_super_properties(user.id, user) # Redirect to intended page or dashboard next_page = request.args.get('next') @@ -489,6 +490,15 @@ def oidc_callback(): db.session.add(user) if not safe_commit('oidc_create_user', {'username': username, 'email': email}): raise RuntimeError('db commit failed on user create') + + # Track onboarding started for new OIDC user + track_onboarding_started(user.id, { + "auth_method": "oidc", + "self_registered": True, + "is_admin": role == 'admin', + "has_email": bool(email) + }) + flash(_('Welcome! Your account has been created.'), 'success') except Exception as e: current_app.logger.exception("Failed to create user from OIDC claims: %s", e) @@ -552,20 +562,11 @@ def oidc_callback(): log_event("auth.login", user_id=user.id, auth_method="oidc") track_event(user.id, "auth.login", {"auth_method": "oidc"}) - # Identify user in PostHog with person properties (for segmentation) - from app import identify_user - identify_user(user.id, { - "$set": { - "role": user.role if hasattr(user, 'role') else "user", - "is_admin": user.is_admin if hasattr(user, 'is_admin') else False, - "last_login": user.last_login.isoformat() if user.last_login else None, - "auth_method": "oidc", - }, - "$set_once": { - "first_login": user.created_at.isoformat() if hasattr(user, 'created_at') and user.created_at else None, - "signup_method": "oidc", - } - }) + # Identify user with comprehensive segmentation properties + identify_user_with_segments(user.id, user) + + # Set super properties (included in all events) + set_super_properties(user.id, user) # Redirect to intended page or dashboard next_page = session.pop('oidc_next', None) or request.args.get('next') diff --git a/app/routes/invoices.py b/app/routes/invoices.py index f7e181e..fb16435 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -9,6 +9,12 @@ import io import csv import json from app.utils.db import safe_commit +from app.utils.posthog_funnels import ( + track_invoice_page_viewed, + track_invoice_project_selected, + track_invoice_previewed, + track_invoice_generated +) invoices_bp = Blueprint('invoices', __name__) @@ -16,6 +22,9 @@ invoices_bp = Blueprint('invoices', __name__) @login_required def list_invoices(): """List all invoices""" + # Track invoice page viewed + track_invoice_page_viewed(current_user.id) + # Get invoices (scope by user unless admin) if current_user.is_admin: invoices = Invoice.query.order_by(Invoice.created_at.desc()).all() @@ -85,6 +94,13 @@ def create_invoice(): # Generate invoice number invoice_number = Invoice.generate_invoice_number() + # Track project selected for invoice + track_invoice_project_selected(current_user.id, { + "project_id": project_id, + "has_email": bool(client_email), + "has_tax": tax_rate > 0 + }) + # Create invoice invoice = Invoice( invoice_number=invoice_number, @@ -105,6 +121,14 @@ def create_invoice(): flash('Could not create invoice due to a database error. Please check server logs.', 'error') return render_template('invoices/create.html') + # Track invoice created + track_invoice_generated(current_user.id, { + "invoice_id": invoice.id, + "invoice_number": invoice_number, + "has_tax": float(tax_rate) > 0, + "has_notes": bool(notes) + }) + flash(f'Invoice {invoice_number} created successfully', 'success') return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) @@ -131,6 +155,12 @@ def view_invoice(invoice_id): flash('You do not have permission to view this invoice', 'error') return redirect(url_for('invoices.list_invoices')) + # Track invoice previewed + track_invoice_previewed(current_user.id, { + "invoice_id": invoice.id, + "invoice_number": invoice.invoice_number + }) + return render_template('invoices/view.html', invoice=invoice) @invoices_bp.route('/invoices//edit', methods=['GET', 'POST']) diff --git a/app/routes/main.py b/app/routes/main.py index 872290e..86d4706 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -3,12 +3,13 @@ from flask_login import login_required, current_user from app.models import User, Project, TimeEntry, Settings from datetime import datetime, timedelta import pytz -from app import db +from app import db, track_page_view from sqlalchemy import text from flask import make_response, current_app import json import os +from app.utils.posthog_segmentation import update_user_segments_if_needed main_bp = Blueprint('main', __name__) @@ -17,6 +18,12 @@ main_bp = Blueprint('main', __name__) @login_required def dashboard(): """Main dashboard showing active timer and recent entries""" + # Track dashboard page view + track_page_view("dashboard") + + # Update user segments periodically (cached, not every request) + update_user_segments_if_needed(current_user.id, current_user) + # Get user's active timer active_timer = current_user.active_timer diff --git a/app/routes/projects.py b/app/routes/projects.py index 7dbb06a..d10dee7 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -6,6 +6,13 @@ from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColu from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit +from app.utils.posthog_funnels import ( + track_onboarding_first_project, + track_project_setup_started, + track_project_setup_basic_info, + track_project_setup_billing_configured, + track_project_setup_completed +) projects_bp = Blueprint('projects', __name__) @@ -13,6 +20,10 @@ projects_bp = Blueprint('projects', __name__) @login_required def list_projects(): """List all projects""" + # Track page view + from app import track_page_view + track_page_view("projects_list") + page = request.args.get('page', 1, type=int) status = request.args.get('status', 'active') client_name = request.args.get('client', '').strip() @@ -67,6 +78,10 @@ def create_project(): flash('Only administrators can create projects', 'error') return redirect(url_for('projects.list_projects')) + # Track project setup started when user opens the form + if request.method == 'GET': + track_project_setup_started(current_user.id) + if request.method == 'POST': name = request.form.get('name', '').strip() client_id = request.form.get('client_id', '').strip() @@ -183,6 +198,48 @@ def create_project(): "billable": billable }) + # Track project setup funnel steps + track_project_setup_basic_info(current_user.id, { + "has_description": bool(description), + "has_code": bool(code), + "billable": billable + }) + + if hourly_rate or billing_ref or budget_amount: + track_project_setup_billing_configured(current_user.id, { + "has_hourly_rate": bool(hourly_rate), + "has_billing_ref": bool(billing_ref), + "has_budget": bool(budget_amount) + }) + + track_project_setup_completed(current_user.id, { + "project_id": project.id, + "billable": billable, + "has_budget": bool(budget_amount) + }) + + # Check if this is user's first project (onboarding milestone) + # Count projects this user has created or has time entries for + from sqlalchemy import func, or_ + project_count = db.session.query(func.count(Project.id.distinct())).join( + TimeEntry, + TimeEntry.project_id == Project.id, + isouter=True + ).filter( + or_( + TimeEntry.user_id == current_user.id, + Project.id == project.id # Include the just-created project + ) + ).scalar() or 0 + + if project_count == 1: + track_onboarding_first_project(current_user.id, { + "project_name_length": len(name), + "has_description": bool(description), + "billable": billable, + "has_budget": bool(budget_amount) + }) + # Log activity Activity.log( user_id=current_user.id, diff --git a/app/routes/reports.py b/app/routes/reports.py index 2b281dd..f69c3b5 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -6,7 +6,13 @@ from datetime import datetime, timedelta import csv import io import pytz +import time from app.utils.excel_export import create_time_entries_excel, create_project_report_excel +from app.utils.posthog_monitoring import ( + track_error, + track_export_performance, + track_validation_error +) reports_bp = Blueprint('reports', __name__) @@ -281,6 +287,8 @@ def user_report(): @login_required def export_csv(): """Export time entries as CSV""" + start_time = time.time() # Start performance tracking + start_date = request.args.get('start_date') end_date = request.args.get('end_date') user_id = request.args.get('user_id', type=int) @@ -296,6 +304,12 @@ def export_csv(): start_dt = datetime.strptime(start_date, '%Y-%m-%d') end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) except ValueError: + track_validation_error( + current_user.id, + "date_range", + "Invalid date format for CSV export", + {"start_date": start_date, "end_date": end_date} + ) flash('Invalid date format', 'error') return redirect(url_for('reports.reports')) @@ -364,8 +378,23 @@ def export_csv(): "date_range_days": (end_dt - start_dt).days }) + # Track performance + try: + duration_ms = (time.time() - start_time) * 1000 + csv_content = output.getvalue().encode('utf-8') + track_export_performance( + current_user.id, + "csv", + row_count=len(entries), + duration_ms=duration_ms, + file_size_bytes=len(csv_content) + ) + except Exception as e: + # Don't let tracking errors break the export + pass + return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), + io.BytesIO(csv_content), mimetype='text/csv', as_attachment=True, download_name=filename diff --git a/app/routes/timer.py b/app/routes/timer.py index d2216d0..e2f4fa8 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -7,6 +7,10 @@ from app.utils.timezone import parse_local_datetime, utc_to_local from datetime import datetime import json from app.utils.db import safe_commit +from app.utils.posthog_funnels import ( + track_onboarding_first_timer, + track_onboarding_first_time_entry +) timer_bp = Blueprint('timer', __name__) @@ -73,6 +77,19 @@ def start_timer(): "has_description": bool(notes) }) + # Check if this is user's first timer (onboarding milestone) + timer_count = TimeEntry.query.filter_by( + user_id=current_user.id, + source='auto' + ).count() + + if timer_count == 1: # First timer ever + track_onboarding_first_timer(current_user.id, { + "project_id": project_id, + "has_task": bool(task_id), + "has_notes": bool(notes) + }) + # Emit WebSocket event for real-time updates try: payload = { @@ -182,6 +199,20 @@ def stop_timer(): "task_id": active_timer.task_id, "duration_seconds": duration_seconds }) + + # Check if this is user's first completed time entry (onboarding milestone) + entry_count = TimeEntry.query.filter_by( + user_id=current_user.id + ).filter( + TimeEntry.end_time.isnot(None) + ).count() + + if entry_count == 1: # First completed time entry ever + track_onboarding_first_time_entry(current_user.id, { + "source": "timer", + "duration_seconds": duration_seconds, + "has_task": bool(active_timer.task_id) + }) except Exception as e: current_app.logger.exception("Error stopping timer: %s", e) diff --git a/app/templates/base.html b/app/templates/base.html index 13292df..099089b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,6 +7,20 @@ + diff --git a/app/utils/posthog_funnels.py b/app/utils/posthog_funnels.py new file mode 100644 index 0000000..628099c --- /dev/null +++ b/app/utils/posthog_funnels.py @@ -0,0 +1,320 @@ +""" +PostHog Funnel Tracking Utilities + +This module provides utilities for tracking multi-step conversion funnels +in your application. Use this to understand where users drop off in complex workflows. +""" + +from typing import Optional, Dict, Any +from datetime import datetime +import os + + +def is_funnel_tracking_enabled() -> bool: + """Check if funnel tracking is enabled.""" + return bool(os.getenv("POSTHOG_API_KEY", "")) + + +def track_funnel_step( + user_id: Any, + funnel_name: str, + step: str, + properties: Optional[Dict[str, Any]] = None +) -> None: + """ + Track a step in a conversion funnel. + + This creates events that can be visualized as funnels in PostHog, + showing you where users drop off in multi-step processes. + + Args: + user_id: The user ID (internal ID, not PII) + funnel_name: Name of the funnel (e.g., 'onboarding', 'invoice_generation') + step: Current step name (e.g., 'started', 'profile_completed') + properties: Additional properties to track with this step + + Example: + # User starts project creation + track_funnel_step(user.id, "project_setup", "started") + + # User enters basic info + track_funnel_step(user.id, "project_setup", "basic_info_entered", { + "has_description": True + }) + + # User completes setup + track_funnel_step(user.id, "project_setup", "completed") + """ + if not is_funnel_tracking_enabled(): + return + + from app import track_event + + event_name = f"funnel.{funnel_name}.{step}" + funnel_properties = { + "funnel": funnel_name, + "step": step, + "step_timestamp": datetime.utcnow().isoformat(), + **(properties or {}) + } + + track_event(user_id, event_name, funnel_properties) + + +# ============================================================================ +# Predefined Funnels for TimeTracker +# ============================================================================ + +class Funnels: + """ + Predefined funnel names for consistent tracking. + + Define your funnels here to avoid typos and enable autocomplete. + """ + + # User onboarding + ONBOARDING = "onboarding" + + # Project management + PROJECT_SETUP = "project_setup" + + # Invoice generation + INVOICE_GENERATION = "invoice_generation" + + # Time tracking + TIME_TRACKING_FLOW = "time_tracking_flow" + + # Export workflow + EXPORT_WORKFLOW = "export_workflow" + + # Report generation + REPORT_GENERATION = "report_generation" + + +# ============================================================================ +# Onboarding Funnel +# ============================================================================ + +def track_onboarding_started(user_id: Any, properties: Optional[Dict] = None): + """Track when user signs up / account is created.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "signed_up", properties) + + +def track_onboarding_profile_completed(user_id: Any, properties: Optional[Dict] = None): + """Track when user completes their profile.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "profile_completed", properties) + + +def track_onboarding_first_project(user_id: Any, properties: Optional[Dict] = None): + """Track when user creates their first project.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "first_project_created", properties) + + +def track_onboarding_first_time_entry(user_id: Any, properties: Optional[Dict] = None): + """Track when user logs their first time entry.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "first_time_logged", properties) + + +def track_onboarding_first_timer(user_id: Any, properties: Optional[Dict] = None): + """Track when user starts their first timer.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "first_timer_started", properties) + + +def track_onboarding_week_1_completed(user_id: Any, properties: Optional[Dict] = None): + """Track when user completes their first week of usage.""" + track_funnel_step(user_id, Funnels.ONBOARDING, "week_1_completed", properties) + + +# ============================================================================ +# Project Setup Funnel +# ============================================================================ + +def track_project_setup_started(user_id: Any, properties: Optional[Dict] = None): + """Track when user starts creating a new project.""" + track_funnel_step(user_id, Funnels.PROJECT_SETUP, "started", properties) + + +def track_project_setup_basic_info(user_id: Any, properties: Optional[Dict] = None): + """Track when user enters basic project info.""" + track_funnel_step(user_id, Funnels.PROJECT_SETUP, "basic_info_entered", properties) + + +def track_project_setup_billing_configured(user_id: Any, properties: Optional[Dict] = None): + """Track when user configures billing info.""" + track_funnel_step(user_id, Funnels.PROJECT_SETUP, "billing_configured", properties) + + +def track_project_setup_tasks_added(user_id: Any, properties: Optional[Dict] = None): + """Track when user adds tasks to project.""" + track_funnel_step(user_id, Funnels.PROJECT_SETUP, "tasks_added", properties) + + +def track_project_setup_completed(user_id: Any, properties: Optional[Dict] = None): + """Track when user completes project setup.""" + track_funnel_step(user_id, Funnels.PROJECT_SETUP, "completed", properties) + + +# ============================================================================ +# Invoice Generation Funnel +# ============================================================================ + +def track_invoice_page_viewed(user_id: Any, properties: Optional[Dict] = None): + """Track when user views invoice page.""" + track_funnel_step(user_id, Funnels.INVOICE_GENERATION, "page_viewed", properties) + + +def track_invoice_project_selected(user_id: Any, properties: Optional[Dict] = None): + """Track when user selects project/client for invoice.""" + track_funnel_step(user_id, Funnels.INVOICE_GENERATION, "project_selected", properties) + + +def track_invoice_previewed(user_id: Any, properties: Optional[Dict] = None): + """Track when user previews invoice.""" + track_funnel_step(user_id, Funnels.INVOICE_GENERATION, "invoice_previewed", properties) + + +def track_invoice_generated(user_id: Any, properties: Optional[Dict] = None): + """Track when user generates/downloads invoice.""" + track_funnel_step(user_id, Funnels.INVOICE_GENERATION, "invoice_generated", properties) + + +# ============================================================================ +# Time Tracking Flow +# ============================================================================ + +def track_time_tracking_started(user_id: Any, properties: Optional[Dict] = None): + """Track when user opens time tracking interface.""" + track_funnel_step(user_id, Funnels.TIME_TRACKING_FLOW, "interface_opened", properties) + + +def track_time_tracking_timer_started(user_id: Any, properties: Optional[Dict] = None): + """Track when user starts a timer.""" + track_funnel_step(user_id, Funnels.TIME_TRACKING_FLOW, "timer_started", properties) + + +def track_time_tracking_timer_stopped(user_id: Any, properties: Optional[Dict] = None): + """Track when user stops a timer.""" + track_funnel_step(user_id, Funnels.TIME_TRACKING_FLOW, "timer_stopped", properties) + + +def track_time_tracking_notes_added(user_id: Any, properties: Optional[Dict] = None): + """Track when user adds notes to time entry.""" + track_funnel_step(user_id, Funnels.TIME_TRACKING_FLOW, "notes_added", properties) + + +def track_time_tracking_saved(user_id: Any, properties: Optional[Dict] = None): + """Track when time entry is saved.""" + track_funnel_step(user_id, Funnels.TIME_TRACKING_FLOW, "entry_saved", properties) + + +# ============================================================================ +# Export Workflow +# ============================================================================ + +def track_export_started(user_id: Any, properties: Optional[Dict] = None): + """Track when user initiates export.""" + track_funnel_step(user_id, Funnels.EXPORT_WORKFLOW, "started", properties) + + +def track_export_format_selected(user_id: Any, properties: Optional[Dict] = None): + """Track when user selects export format.""" + track_funnel_step(user_id, Funnels.EXPORT_WORKFLOW, "format_selected", properties) + + +def track_export_filters_applied(user_id: Any, properties: Optional[Dict] = None): + """Track when user applies filters to export.""" + track_funnel_step(user_id, Funnels.EXPORT_WORKFLOW, "filters_applied", properties) + + +def track_export_downloaded(user_id: Any, properties: Optional[Dict] = None): + """Track when export is downloaded.""" + track_funnel_step(user_id, Funnels.EXPORT_WORKFLOW, "downloaded", properties) + + +# ============================================================================ +# Report Generation +# ============================================================================ + +def track_report_page_viewed(user_id: Any, properties: Optional[Dict] = None): + """Track when user views reports page.""" + track_funnel_step(user_id, Funnels.REPORT_GENERATION, "page_viewed", properties) + + +def track_report_type_selected(user_id: Any, properties: Optional[Dict] = None): + """Track when user selects report type.""" + track_funnel_step(user_id, Funnels.REPORT_GENERATION, "type_selected", properties) + + +def track_report_filters_applied(user_id: Any, properties: Optional[Dict] = None): + """Track when user applies filters.""" + track_funnel_step(user_id, Funnels.REPORT_GENERATION, "filters_applied", properties) + + +def track_report_generated(user_id: Any, properties: Optional[Dict] = None): + """Track when report is generated.""" + track_funnel_step(user_id, Funnels.REPORT_GENERATION, "generated", properties) + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def track_funnel_abandonment( + user_id: Any, + funnel_name: str, + last_step_completed: str, + reason: Optional[str] = None +) -> None: + """ + Track when a user abandons a funnel. + + Args: + user_id: User ID + funnel_name: Name of the funnel + last_step_completed: Last step the user completed before abandoning + reason: Optional reason for abandonment (e.g., 'error', 'timeout') + """ + from app import track_event + + track_event(user_id, f"funnel.{funnel_name}.abandoned", { + "funnel": funnel_name, + "last_step": last_step_completed, + "abandonment_reason": reason, + "timestamp": datetime.utcnow().isoformat() + }) + + +def get_funnel_context(funnel_name: str, additional_context: Optional[Dict] = None) -> Dict: + """ + Get standardized context for funnel events. + + Args: + funnel_name: Name of the funnel + additional_context: Additional context to include + + Returns: + Dict of context properties + """ + from flask import request + + context = { + "funnel": funnel_name, + "timestamp": datetime.utcnow().isoformat(), + } + + # Add request context if available + try: + if request: + context.update({ + "referrer": request.referrer, + "user_agent": request.user_agent.string, + }) + except Exception: + pass + + # Add additional context + if additional_context: + context.update(additional_context) + + return context + diff --git a/app/utils/posthog_monitoring.py b/app/utils/posthog_monitoring.py new file mode 100644 index 0000000..1231aae --- /dev/null +++ b/app/utils/posthog_monitoring.py @@ -0,0 +1,479 @@ +""" +PostHog Monitoring Utilities + +Track errors, performance metrics, and application health through PostHog. +""" + +from typing import Optional, Dict, Any +import time +import os +from functools import wraps +from contextlib import contextmanager + + +def is_monitoring_enabled() -> bool: + """Check if PostHog monitoring is enabled.""" + return bool(os.getenv("POSTHOG_API_KEY", "")) + + +# ============================================================================ +# Error Tracking +# ============================================================================ + +def track_error( + user_id: Any, + error_type: str, + error_message: str, + context: Optional[Dict] = None, + severity: str = "error" +) -> None: + """ + Track application errors in PostHog. + + Args: + user_id: User ID (or 'anonymous' for unauthenticated) + error_type: Type of error (e.g., 'validation', 'database', 'api', '404') + error_message: Error message (sanitized, no PII) + context: Additional context (page, action, etc.) + severity: Error severity ('error', 'warning', 'critical') + + Example: + try: + generate_report() + except ValueError as e: + track_error( + current_user.id, + "validation", + "Invalid date range for report", + {"report_type": "summary"} + ) + raise + """ + if not is_monitoring_enabled(): + return + + from app import track_event + from flask import request + + error_properties = { + "error_type": error_type, + "error_message": error_message[:500], # Limit message length + "severity": severity, + "timestamp": time.time(), + } + + # Add context + if context: + error_properties["error_context"] = context + + # Add request context if available + try: + if request: + error_properties.update({ + "$current_url": request.url, + "$pathname": request.path, + "method": request.method, + }) + except Exception: + pass + + track_event(user_id, "error_occurred", error_properties) + + +def track_http_error( + user_id: Any, + status_code: int, + error_message: str, + context: Optional[Dict] = None +) -> None: + """ + Track HTTP errors (404, 500, etc.). + + Args: + user_id: User ID + status_code: HTTP status code + error_message: Error message + context: Additional context + """ + track_error( + user_id, + f"http_{status_code}", + error_message, + { + "status_code": status_code, + **(context or {}) + }, + severity="warning" if status_code < 500 else "error" + ) + + +def track_validation_error( + user_id: Any, + field: str, + error_message: str, + context: Optional[Dict] = None +) -> None: + """ + Track form validation errors. + + Args: + user_id: User ID + field: Field that failed validation + error_message: Validation error message + context: Additional context + """ + track_error( + user_id, + "validation", + error_message, + { + "field": field, + **(context or {}) + }, + severity="warning" + ) + + +# ============================================================================ +# Performance Tracking +# ============================================================================ + +def track_performance( + user_id: Any, + metric_name: str, + duration_ms: float, + context: Optional[Dict] = None, + threshold_ms: Optional[float] = None +) -> None: + """ + Track performance metrics in PostHog. + + Args: + user_id: User ID + metric_name: Name of the metric (e.g., 'report_generation', 'export_csv') + duration_ms: Duration in milliseconds + context: Additional context + threshold_ms: If provided, also track if duration exceeded threshold + + Example: + start = time.time() + generate_report() + duration = (time.time() - start) * 1000 + track_performance( + current_user.id, + "report_generation", + duration, + {"report_type": "summary", "entries_count": 100} + ) + """ + if not is_monitoring_enabled(): + return + + from app import track_event + + performance_properties = { + "metric_name": metric_name, + "duration_ms": duration_ms, + "duration_seconds": duration_ms / 1000, + **(context or {}) + } + + # Check if threshold exceeded + if threshold_ms is not None: + performance_properties["threshold_exceeded"] = duration_ms > threshold_ms + performance_properties["threshold_ms"] = threshold_ms + + track_event(user_id, "performance_metric", performance_properties) + + +@contextmanager +def measure_performance( + user_id: Any, + metric_name: str, + context: Optional[Dict] = None, + threshold_ms: Optional[float] = None +): + """ + Context manager to measure performance of a code block. + + Usage: + with measure_performance(current_user.id, "report_generation", {"type": "summary"}): + generate_report() + """ + start = time.time() + try: + yield + finally: + duration_ms = (time.time() - start) * 1000 + track_performance(user_id, metric_name, duration_ms, context, threshold_ms) + + +def performance_tracked(metric_name: str, threshold_ms: Optional[float] = None): + """ + Decorator to track performance of a function. + + Usage: + @performance_tracked("report_generation", threshold_ms=5000) + def generate_report(): + # ... generate report + pass + """ + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + from flask_login import current_user + + user_id = current_user.id if current_user.is_authenticated else "anonymous" + + start = time.time() + try: + result = f(*args, **kwargs) + return result + finally: + duration_ms = (time.time() - start) * 1000 + track_performance( + user_id, + metric_name, + duration_ms, + {"function": f.__name__}, + threshold_ms + ) + + return wrapped + return decorator + + +# ============================================================================ +# Database Performance Tracking +# ============================================================================ + +def track_query_performance( + user_id: Any, + query_type: str, + duration_ms: float, + context: Optional[Dict] = None +) -> None: + """ + Track database query performance. + + Args: + user_id: User ID + query_type: Type of query (e.g., 'select', 'insert', 'update', 'complex_join') + duration_ms: Query duration in milliseconds + context: Additional context (table, filters, etc.) + """ + track_performance( + user_id, + f"db_query.{query_type}", + duration_ms, + { + "query_type": query_type, + **(context or {}) + }, + threshold_ms=1000 # Warn if query takes > 1 second + ) + + +# ============================================================================ +# API Performance Tracking +# ============================================================================ + +def track_api_call( + user_id: Any, + endpoint: str, + method: str, + status_code: int, + duration_ms: float, + context: Optional[Dict] = None +) -> None: + """ + Track API call performance and status. + + Args: + user_id: User ID + endpoint: API endpoint + method: HTTP method + status_code: Response status code + duration_ms: Request duration in milliseconds + context: Additional context + """ + from app import track_event + + track_event(user_id, "api_call", { + "endpoint": endpoint, + "method": method, + "status_code": status_code, + "duration_ms": duration_ms, + "success": 200 <= status_code < 400, + **(context or {}) + }) + + +# ============================================================================ +# Page Load Tracking +# ============================================================================ + +def track_page_load( + user_id: Any, + page_name: str, + duration_ms: float, + context: Optional[Dict] = None +) -> None: + """ + Track page load performance. + + Args: + user_id: User ID + page_name: Name of the page + duration_ms: Load duration in milliseconds + context: Additional context + """ + track_performance( + user_id, + f"page_load.{page_name}", + duration_ms, + { + "page_name": page_name, + **(context or {}) + }, + threshold_ms=3000 # Warn if page takes > 3 seconds + ) + + +# ============================================================================ +# Export/Report Performance +# ============================================================================ + +def track_export_performance( + user_id: Any, + export_type: str, + row_count: int, + duration_ms: float, + file_size_bytes: Optional[int] = None +) -> None: + """ + Track export generation performance. + + Args: + user_id: User ID + export_type: Type of export (csv, excel, pdf) + row_count: Number of rows exported + duration_ms: Generation duration in milliseconds + file_size_bytes: Generated file size in bytes + """ + context = { + "export_type": export_type, + "row_count": row_count, + "rows_per_second": int(row_count / (duration_ms / 1000)) if duration_ms > 0 else 0, + } + + if file_size_bytes: + context["file_size_bytes"] = file_size_bytes + context["file_size_kb"] = round(file_size_bytes / 1024, 2) + + track_performance( + user_id, + f"export.{export_type}", + duration_ms, + context, + threshold_ms=10000 # Warn if export takes > 10 seconds + ) + + +# ============================================================================ +# Health Monitoring +# ============================================================================ + +def track_health_check( + status: str, + checks: Dict[str, bool], + response_time_ms: float +) -> None: + """ + Track health check results. + + Args: + status: Overall health status ('healthy', 'degraded', 'unhealthy') + checks: Dict of individual health checks and their results + response_time_ms: Health check response time + """ + if not is_monitoring_enabled(): + return + + from app import track_event + + track_event("system", "health_check", { + "status": status, + "checks": checks, + "all_healthy": all(checks.values()), + "response_time_ms": response_time_ms, + "failed_checks": [k for k, v in checks.items() if not v], + }) + + +# ============================================================================ +# Resource Usage Tracking +# ============================================================================ + +def track_resource_usage( + user_id: Any, + resource_type: str, + usage_amount: float, + unit: str, + context: Optional[Dict] = None +) -> None: + """ + Track resource usage (memory, CPU, disk, etc.). + + Args: + user_id: User ID or 'system' + resource_type: Type of resource (memory, cpu, disk, api_calls) + usage_amount: Amount used + unit: Unit of measurement (mb, percent, count) + context: Additional context + """ + from app import track_event + + track_event(user_id, "resource_usage", { + "resource_type": resource_type, + "usage_amount": usage_amount, + "unit": unit, + **(context or {}) + }) + + +# ============================================================================ +# Slow Operation Detection +# ============================================================================ + +def track_slow_operation( + user_id: Any, + operation_name: str, + expected_ms: float, + actual_ms: float, + context: Optional[Dict] = None +) -> None: + """ + Track operations that exceed expected duration. + + Args: + user_id: User ID + operation_name: Name of the operation + expected_ms: Expected duration in milliseconds + actual_ms: Actual duration in milliseconds + context: Additional context + """ + track_error( + user_id, + "slow_operation", + f"{operation_name} took {actual_ms}ms (expected {expected_ms}ms)", + { + "operation": operation_name, + "expected_ms": expected_ms, + "actual_ms": actual_ms, + "slowdown_factor": actual_ms / expected_ms if expected_ms > 0 else 0, + **(context or {}) + }, + severity="warning" + ) + diff --git a/app/utils/posthog_segmentation.py b/app/utils/posthog_segmentation.py new file mode 100644 index 0000000..cef19b3 --- /dev/null +++ b/app/utils/posthog_segmentation.py @@ -0,0 +1,392 @@ +""" +PostHog Segmentation Utilities + +Advanced user segmentation and identification with computed properties. +""" + +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +import os + + +def is_segmentation_enabled() -> bool: + """Check if PostHog segmentation is enabled.""" + return bool(os.getenv("POSTHOG_API_KEY", "")) + + +def identify_user_with_segments(user_id: Any, user) -> None: + """ + Identify user with comprehensive segmentation properties. + + This sets person properties in PostHog that can be used for: + - Creating cohorts + - Targeting feature flags + - Analyzing behavior by segment + - A/B testing + + Args: + user_id: User ID + user: User model instance + """ + if not is_segmentation_enabled(): + return + + from app import identify_user + from app.models import TimeEntry, Project + + # Calculate engagement metrics + engagement_metrics = calculate_engagement_metrics(user_id) + + # Calculate usage patterns + usage_patterns = calculate_usage_patterns(user_id) + + # Get account info + account_info = get_account_info(user) + + # Combine all properties + properties = { + "$set": { + # User role and permissions + "role": user.role, + "is_admin": user.is_admin, + + # Authentication + "auth_method": getattr(user, 'auth_method', 'local'), + + # Engagement metrics + **engagement_metrics, + + # Usage patterns + **usage_patterns, + + # Account info + **account_info, + + # Last updated + "last_segment_update": datetime.utcnow().isoformat(), + }, + "$set_once": { + "first_login": user.created_at.isoformat() if user.created_at else None, + "signup_method": "local", # Or from user object if tracked + } + } + + identify_user(user_id, properties) + + +def calculate_engagement_metrics(user_id: Any) -> Dict[str, Any]: + """ + Calculate user engagement metrics. + + Returns: + Dict of engagement properties + """ + from app.models import TimeEntry + + now = datetime.utcnow() + + # Entries in different time periods + entries_last_24h = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.created_at >= now - timedelta(hours=24) + ).count() + + entries_last_7_days = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.created_at >= now - timedelta(days=7) + ).count() + + entries_last_30_days = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.created_at >= now - timedelta(days=30) + ).count() + + entries_all_time = TimeEntry.query.filter( + TimeEntry.user_id == user_id + ).count() + + # Calculate engagement level + if entries_last_7_days >= 20: + engagement_level = "very_high" + elif entries_last_7_days >= 10: + engagement_level = "high" + elif entries_last_7_days >= 3: + engagement_level = "medium" + elif entries_last_7_days >= 1: + engagement_level = "low" + else: + engagement_level = "inactive" + + # Calculate activity trend + if entries_last_7_days > entries_last_30_days / 4: + activity_trend = "increasing" + elif entries_last_7_days < entries_last_30_days / 5: + activity_trend = "decreasing" + else: + activity_trend = "stable" + + return { + "entries_last_24h": entries_last_24h, + "entries_last_7_days": entries_last_7_days, + "entries_last_30_days": entries_last_30_days, + "entries_all_time": entries_all_time, + "engagement_level": engagement_level, + "activity_trend": activity_trend, + "is_active_user": entries_last_7_days > 0, + "is_power_user": entries_last_7_days >= 10, + "is_at_risk": entries_last_7_days == 0 and entries_all_time > 0, + } + + +def calculate_usage_patterns(user_id: Any) -> Dict[str, Any]: + """ + Calculate user usage patterns. + + Returns: + Dict of usage pattern properties + """ + from app.models import Project, TimeEntry, Task + from sqlalchemy import func + + # Project statistics + active_projects = Project.query.filter_by( + status='active' + ).filter( + Project.time_entries.any(TimeEntry.user_id == user_id) + ).count() + + total_projects = Project.query.filter( + Project.time_entries.any(TimeEntry.user_id == user_id) + ).count() + + # Task statistics (if tasks exist) + try: + assigned_tasks = Task.query.filter_by( + assigned_to=user_id, + status__ne='done' + ).count() + + completed_tasks = Task.query.filter_by( + assigned_to=user_id, + status='done' + ).count() + except Exception: + assigned_tasks = 0 + completed_tasks = 0 + + # Timer usage + timer_entries = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.source == 'timer' + ).count() + + manual_entries = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.source == 'manual' + ).count() + + total_entries = timer_entries + manual_entries + timer_usage_percent = (timer_entries / total_entries * 100) if total_entries > 0 else 0 + + # Preferred tracking method + if timer_usage_percent > 70: + preferred_method = "timer" + elif timer_usage_percent > 30: + preferred_method = "mixed" + else: + preferred_method = "manual" + + # Calculate total hours tracked + total_seconds = TimeEntry.query.filter( + TimeEntry.user_id == user_id, + TimeEntry.duration_seconds.isnot(None) + ).with_entities( + func.sum(TimeEntry.duration_seconds) + ).scalar() or 0 + + total_hours = round(total_seconds / 3600, 1) + + return { + "active_projects_count": active_projects, + "total_projects_count": total_projects, + "assigned_tasks_count": assigned_tasks, + "completed_tasks_count": completed_tasks, + "timer_entries_count": timer_entries, + "manual_entries_count": manual_entries, + "timer_usage_percent": round(timer_usage_percent, 1), + "preferred_tracking_method": preferred_method, + "total_hours_tracked": total_hours, + "uses_timer": timer_entries > 0, + "uses_manual_entry": manual_entries > 0, + } + + +def get_account_info(user) -> Dict[str, Any]: + """ + Get account information. + + Returns: + Dict of account properties + """ + from datetime import datetime + + account_age_days = (datetime.utcnow() - user.created_at).days if user.created_at else 0 + + # Categorize by account age + if account_age_days < 7: + account_age_category = "new" + elif account_age_days < 30: + account_age_category = "recent" + elif account_age_days < 180: + account_age_category = "established" + else: + account_age_category = "long_term" + + # Days since last login + days_since_login = (datetime.utcnow() - user.last_login).days if user.last_login else None + + return { + "account_age_days": account_age_days, + "account_age_category": account_age_category, + "last_login": user.last_login.isoformat() if user.last_login else None, + "days_since_last_login": days_since_login, + "username": None, # Never send PII + "is_new_user": account_age_days < 7, + "is_established_user": account_age_days >= 30, + } + + +# ============================================================================ +# Cohort Definitions +# ============================================================================ + +class UserCohorts: + """ + Predefined user cohort definitions for PostHog. + + Use these in PostHog to create cohorts: + Person Properties → engagement_level = "high" + """ + + # Engagement cohorts + VERY_HIGH_ENGAGEMENT = {"engagement_level": "very_high"} + HIGH_ENGAGEMENT = {"engagement_level": "high"} + MEDIUM_ENGAGEMENT = {"engagement_level": "medium"} + LOW_ENGAGEMENT = {"engagement_level": "low"} + INACTIVE = {"engagement_level": "inactive"} + + # Activity cohorts + POWER_USERS = {"is_power_user": True} + ACTIVE_USERS = {"is_active_user": True} + AT_RISK_USERS = {"is_at_risk": True} + + # Usage pattern cohorts + TIMER_USERS = {"preferred_tracking_method": "timer"} + MANUAL_ENTRY_USERS = {"preferred_tracking_method": "manual"} + MIXED_METHOD_USERS = {"preferred_tracking_method": "mixed"} + + # Account age cohorts + NEW_USERS = {"account_age_category": "new"} + RECENT_USERS = {"account_age_category": "recent"} + ESTABLISHED_USERS = {"account_age_category": "established"} + LONG_TERM_USERS = {"account_age_category": "long_term"} + + # Role cohorts + ADMINS = {"is_admin": True} + REGULAR_USERS = {"is_admin": False} + + # Activity trend cohorts + GROWING_USERS = {"activity_trend": "increasing"} + DECLINING_USERS = {"activity_trend": "decreasing"} + STABLE_USERS = {"activity_trend": "stable"} + + +def get_user_cohort_description(user_properties: Dict[str, Any]) -> str: + """ + Get a human-readable description of a user's cohort. + + Args: + user_properties: User properties from PostHog + + Returns: + String describing the user's primary cohort + """ + engagement = user_properties.get("engagement_level", "unknown") + is_admin = user_properties.get("is_admin", False) + account_age = user_properties.get("account_age_category", "unknown") + + if is_admin: + return f"Admin user with {engagement} engagement" + + return f"{account_age.title()} user with {engagement} engagement" + + +# ============================================================================ +# Super Properties +# ============================================================================ + +def set_super_properties(user_id: Any, user) -> None: + """ + Set super properties that are included in every event. + + These properties are automatically added to all events without + needing to pass them explicitly. + + Args: + user_id: User ID + user: User model instance + """ + if not is_segmentation_enabled(): + return + + from app import identify_user + + properties = { + "$set": { + # Always include these in events + "role": user.role, + "is_admin": user.is_admin, + "auth_method": getattr(user, 'auth_method', 'local'), + "timezone": os.getenv('TZ', 'UTC'), + "environment": os.getenv('FLASK_ENV', 'production'), + "deployment_method": "docker" if os.path.exists("/.dockerenv") else "native", + } + } + + identify_user(user_id, properties) + + +# ============================================================================ +# Segment Updates +# ============================================================================ + +def should_update_segments(user_id: Any) -> bool: + """ + Check if user segments should be updated. + + Updates segments if: + - Never updated before + - Last updated > 24 hours ago + - Significant activity since last update + + Returns: + True if segments should be updated + """ + # For now, always return True + # In production, you might want to cache this and check timestamps + return True + + +def update_user_segments_if_needed(user_id: Any, user) -> None: + """ + Update user segments if needed. + + Call this periodically (e.g., on login, after significant actions). + + Args: + user_id: User ID + user: User model instance + """ + if should_update_segments(user_id): + identify_user_with_segments(user_id, user) + diff --git a/assets/screenshots/About.png b/assets/screenshots/About.png new file mode 100644 index 0000000..e1e5ff2 Binary files /dev/null and b/assets/screenshots/About.png differ diff --git a/assets/screenshots/AdminDashboard.png b/assets/screenshots/AdminDashboard.png index 966ecd3..6df225d 100644 Binary files a/assets/screenshots/AdminDashboard.png and b/assets/screenshots/AdminDashboard.png differ diff --git a/assets/screenshots/BulkTimeEntry.png b/assets/screenshots/BulkTimeEntry.png deleted file mode 100644 index efccac5..0000000 Binary files a/assets/screenshots/BulkTimeEntry.png and /dev/null differ diff --git a/assets/screenshots/Calendar.png b/assets/screenshots/Calendar.png deleted file mode 100644 index 407efcd..0000000 Binary files a/assets/screenshots/Calendar.png and /dev/null differ diff --git a/assets/screenshots/Clients.png b/assets/screenshots/Clients.png index 6b6581e..d7f4216 100644 Binary files a/assets/screenshots/Clients.png and b/assets/screenshots/Clients.png differ diff --git a/assets/screenshots/CreateClient.png b/assets/screenshots/CreateClient.png index aa784f6..0240682 100644 Binary files a/assets/screenshots/CreateClient.png and b/assets/screenshots/CreateClient.png differ diff --git a/assets/screenshots/CreateProject.png b/assets/screenshots/CreateProject.png index ba12b48..3c2d543 100644 Binary files a/assets/screenshots/CreateProject.png and b/assets/screenshots/CreateProject.png differ diff --git a/assets/screenshots/CreateTask.png b/assets/screenshots/CreateTask.png index fc954ef..497215b 100644 Binary files a/assets/screenshots/CreateTask.png and b/assets/screenshots/CreateTask.png differ diff --git a/assets/screenshots/Dashboard.png b/assets/screenshots/Dashboard.png index d8b19fc..3aacc0a 100644 Binary files a/assets/screenshots/Dashboard.png and b/assets/screenshots/Dashboard.png differ diff --git a/assets/screenshots/Help.png b/assets/screenshots/Help.png new file mode 100644 index 0000000..d5d974c Binary files /dev/null and b/assets/screenshots/Help.png differ diff --git a/assets/screenshots/Invoices.png b/assets/screenshots/Invoices.png index 37b2354..54f3c99 100644 Binary files a/assets/screenshots/Invoices.png and b/assets/screenshots/Invoices.png differ diff --git a/assets/screenshots/Kanban.png b/assets/screenshots/Kanban.png new file mode 100644 index 0000000..28928b3 Binary files /dev/null and b/assets/screenshots/Kanban.png differ diff --git a/assets/screenshots/LogTime.png b/assets/screenshots/LogTime.png index 0336d97..486b224 100644 Binary files a/assets/screenshots/LogTime.png and b/assets/screenshots/LogTime.png differ diff --git a/assets/screenshots/Login.png b/assets/screenshots/Login.png index f5cc0e4..56ad27f 100644 Binary files a/assets/screenshots/Login.png and b/assets/screenshots/Login.png differ diff --git a/assets/screenshots/OIDC.png b/assets/screenshots/OIDC.png new file mode 100644 index 0000000..b69fdcb Binary files /dev/null and b/assets/screenshots/OIDC.png differ diff --git a/assets/screenshots/Profile.png b/assets/screenshots/Profile.png index 27853a7..8d6d25c 100644 Binary files a/assets/screenshots/Profile.png and b/assets/screenshots/Profile.png differ diff --git a/assets/screenshots/Projects.png b/assets/screenshots/Projects.png index 92aaf30..ffecc84 100644 Binary files a/assets/screenshots/Projects.png and b/assets/screenshots/Projects.png differ diff --git a/assets/screenshots/Reports.png b/assets/screenshots/Reports.png index 44c293b..a4abdeb 100644 Binary files a/assets/screenshots/Reports.png and b/assets/screenshots/Reports.png differ diff --git a/assets/screenshots/Tasks.png b/assets/screenshots/Tasks.png index 905623f..7ec7d7b 100644 Binary files a/assets/screenshots/Tasks.png and b/assets/screenshots/Tasks.png differ diff --git a/assets/screenshots/TimeEntryTemplates.png b/assets/screenshots/TimeEntryTemplates.png new file mode 100644 index 0000000..46eadd1 Binary files /dev/null and b/assets/screenshots/TimeEntryTemplates.png differ diff --git a/assets/screenshots/UserReports.png b/assets/screenshots/UserReports.png index 33f49e8..316c140 100644 Binary files a/assets/screenshots/UserReports.png and b/assets/screenshots/UserReports.png differ diff --git a/docs/cicd/README_BUILD_CONFIGURATION.md b/docs/cicd/README_BUILD_CONFIGURATION.md index d084e6f..28bc8d0 100644 --- a/docs/cicd/README_BUILD_CONFIGURATION.md +++ b/docs/cicd/README_BUILD_CONFIGURATION.md @@ -27,6 +27,8 @@ On first access, choose whether to enable telemetry for community support. ## How It Works +> 📖 **Detailed Guide**: For a comprehensive explanation of how PostHog credentials are injected from GitHub Secrets, see [POSTHOG_CREDENTIAL_INJECTION.md](./POSTHOG_CREDENTIAL_INJECTION.md) + ### Architecture ``` @@ -288,6 +290,7 @@ A: Yes! Check `logs/app.jsonl` for all events, and `docs/all_tracked_events.md` - **Telemetry Code:** `app/utils/telemetry.py` - **All Events:** `docs/all_tracked_events.md` - **Official vs Self-Hosted:** `docs/OFFICIAL_BUILDS.md` +- **🔐 PostHog Credential Injection:** `docs/cicd/POSTHOG_CREDENTIAL_INJECTION.md` (detailed guide with examples) --- diff --git a/setup.py b/setup.py index cf17cb0..cb9f4e9 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ from setuptools import setup, find_packages setup( name='timetracker', - version='3.3.0', + version='3.3.1', packages=find_packages(), include_package_data=True, install_requires=[