From d0dd5c82ead646cb1d7e3a802b6578c7d05348be Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 5 Feb 2026 17:50:15 +0000 Subject: [PATCH] feat: add click logging for admin frontend - Add backend click logging utility with file rotation and retention - Add POST /api/v1/log/click endpoint for logging click events - Add frontend click logger service with batching and context extraction - Add global click handler in App.tsx for authenticated users - Add log cleanup script for old log files - Update QUICK_LOG_REFERENCE.md with click log documentation Logs are written to /opt/punimtag/logs/admin-clicks.log with: - Auto-rotation at 10MB (keeps 5 backups) - 30-day retention - Format: timestamp | username | page | element_type | element_id | element_text | context --- QUICK_LOG_REFERENCE.md | 28 +++ admin-frontend/src/App.tsx | 33 ++++ admin-frontend/src/services/clickLogger.ts | 201 +++++++++++++++++++++ backend/api/click_log.py | 56 ++++++ backend/app.py | 2 + backend/utils/click_logger.py | 123 +++++++++++++ scripts/cleanup-click-logs.sh | 22 +++ 7 files changed, 465 insertions(+) create mode 100644 admin-frontend/src/services/clickLogger.ts create mode 100644 backend/api/click_log.py create mode 100644 backend/utils/click_logger.py create mode 100755 scripts/cleanup-click-logs.sh diff --git a/QUICK_LOG_REFERENCE.md b/QUICK_LOG_REFERENCE.md index f49368e..df54351 100644 --- a/QUICK_LOG_REFERENCE.md +++ b/QUICK_LOG_REFERENCE.md @@ -58,6 +58,22 @@ All logs are in: `/home/appuser/.pm2/logs/` - **Admin**: `punimtag-admin-error.log`, `punimtag-admin-out.log` - **Viewer**: `punimtag-viewer-error.log`, `punimtag-viewer-out.log` +### Click Logs (Admin Frontend) + +Click logs are in: `/opt/punimtag/logs/` + +- **Click Log**: `admin-clicks.log` (auto-rotates at 10MB, keeps 5 backups) +- **View live clicks**: `tail -f /opt/punimtag/logs/admin-clicks.log` +- **View recent clicks**: `tail -n 100 /opt/punimtag/logs/admin-clicks.log` +- **Search clicks**: `grep "username\|page" /opt/punimtag/logs/admin-clicks.log` +- **Cleanup old logs**: `./scripts/cleanup-click-logs.sh` + +**Automated Cleanup (Crontab):** +```bash +# Add to crontab: cleanup logs weekly (Sundays at 2 AM) +0 2 * * 0 /opt/punimtag/scripts/cleanup-click-logs.sh +``` + ## 🔧 Direct Log Access ```bash @@ -113,3 +129,15 @@ This configures: pm2 flush # Clear all logs (be careful!) ``` +5. **Viewing click logs?** + ```bash + # Watch clicks in real-time + tail -f /opt/punimtag/logs/admin-clicks.log + + # View recent clicks + tail -n 100 /opt/punimtag/logs/admin-clicks.log + + # Search for specific user or page + grep "admin\|/identify" /opt/punimtag/logs/admin-clicks.log + ``` + diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 81c1ab9..e58b82e 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -23,6 +23,7 @@ import Help from './pages/Help' import Layout from './components/Layout' import PasswordChangeModal from './components/PasswordChangeModal' import AdminRoute from './components/AdminRoute' +import { logClick, flushPendingClicks } from './services/clickLogger' function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth() @@ -57,6 +58,38 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { } function AppRoutes() { + const { isAuthenticated } = useAuth() + + // Set up global click logging for authenticated users + useEffect(() => { + if (!isAuthenticated) { + return + } + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (target) { + logClick(target) + } + } + + // Add click listener + document.addEventListener('click', handleClick, true) // Use capture phase + + // Flush pending clicks on page unload + const handleBeforeUnload = () => { + flushPendingClicks() + } + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + document.removeEventListener('click', handleClick, true) + window.removeEventListener('beforeunload', handleBeforeUnload) + // Flush any pending clicks on cleanup + flushPendingClicks() + } + }, [isAuthenticated]) + return ( } /> diff --git a/admin-frontend/src/services/clickLogger.ts b/admin-frontend/src/services/clickLogger.ts new file mode 100644 index 0000000..b8917a9 --- /dev/null +++ b/admin-frontend/src/services/clickLogger.ts @@ -0,0 +1,201 @@ +/** + * Click logging service for admin frontend. + * Sends click events to backend API for logging to file. + */ + +import { apiClient } from '../api/client' + +interface ClickLogData { + page: string + element_type: string + element_id?: string + element_text?: string + context?: Record +} + +// Batch clicks to avoid excessive API calls +const CLICK_BATCH_SIZE = 10 +const CLICK_BATCH_DELAY = 1000 // 1 second + +let clickQueue: ClickLogData[] = [] +let batchTimeout: number | null = null + +/** + * Get the current page path. + */ +function getCurrentPage(): string { + return window.location.pathname +} + +/** + * Get element type from HTML element. + */ +function getElementType(element: HTMLElement): string { + const tagName = element.tagName.toLowerCase() + + // Map common elements + if (tagName === 'button' || element.getAttribute('role') === 'button') { + return 'button' + } + if (tagName === 'a') { + return 'link' + } + if (tagName === 'input') { + return 'input' + } + if (tagName === 'select') { + return 'select' + } + if (tagName === 'textarea') { + return 'textarea' + } + + // Check for clickable elements + if (element.onclick || element.getAttribute('onclick')) { + return 'clickable' + } + + // Default to tag name + return tagName +} + +/** + * Get element text content (truncated to 100 chars). + */ +function getElementText(element: HTMLElement): string { + const text = element.textContent?.trim() || element.getAttribute('aria-label') || '' + return text.substring(0, 100) +} + +/** + * Extract context from element (data attributes, etc.). + */ +function extractContext(element: HTMLElement): Record { + const context: Record = {} + + // Extract data-* attributes + Array.from(element.attributes).forEach(attr => { + if (attr.name.startsWith('data-')) { + const key = attr.name.replace('data-', '').replace(/-/g, '_') + context[key] = attr.value + } + }) + + // Extract common IDs that might be useful + const id = element.id + if (id) { + context.element_id = id + } + + const className = element.className + if (className && typeof className === 'string') { + context.class_name = className.split(' ').slice(0, 3).join(' ') // First 3 classes + } + + return context +} + +/** + * Flush queued clicks to backend. + */ +async function flushClickQueue(): Promise { + if (clickQueue.length === 0) { + return + } + + const clicksToSend = [...clickQueue] + clickQueue = [] + + // Send clicks in parallel (but don't wait for all to complete) + clicksToSend.forEach(clickData => { + apiClient.post('/api/v1/log/click', clickData).catch(error => { + // Silently fail - don't interrupt user experience + console.debug('Click logging failed:', error) + }) + }) +} + +/** + * Queue a click for logging. + */ +function queueClick(clickData: ClickLogData): void { + clickQueue.push(clickData) + + // Flush if batch size reached + if (clickQueue.length >= CLICK_BATCH_SIZE) { + if (batchTimeout !== null) { + window.clearTimeout(batchTimeout) + batchTimeout = null + } + flushClickQueue() + } else { + // Set timeout to flush after delay + if (batchTimeout === null) { + batchTimeout = window.setTimeout(() => { + batchTimeout = null + flushClickQueue() + }, CLICK_BATCH_DELAY) + } + } +} + +/** + * Log a click event. + */ +export function logClick( + element: HTMLElement, + additionalContext?: Record +): void { + try { + const elementType = getElementType(element) + const elementId = element.id || undefined + const elementText = getElementText(element) + const page = getCurrentPage() + const context = { + ...extractContext(element), + ...additionalContext, + } + + // Skip logging for certain elements (to reduce noise) + const skipSelectors = [ + 'input[type="password"]', + 'input[type="hidden"]', + '[data-no-log]', // Allow opt-out via data attribute + ] + + const shouldSkip = skipSelectors.some(selector => { + try { + return element.matches(selector) + } catch { + return false + } + }) + + if (shouldSkip) { + return + } + + queueClick({ + page, + element_type: elementType, + element_id: elementId, + element_text: elementText || undefined, + context: Object.keys(context).length > 0 ? context : undefined, + }) + } catch (error) { + // Silently fail - don't interrupt user experience + console.debug('Click logging error:', error) + } +} + +/** + * Flush any pending clicks (useful on page unload). + */ +export function flushPendingClicks(): void { + if (batchTimeout !== null) { + window.clearTimeout(batchTimeout) + batchTimeout = null + } + flushClickQueue() +} + diff --git a/backend/api/click_log.py b/backend/api/click_log.py new file mode 100644 index 0000000..c2dbe08 --- /dev/null +++ b/backend/api/click_log.py @@ -0,0 +1,56 @@ +"""Click logging API endpoint.""" + +from __future__ import annotations + +from typing import Annotated, Optional +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from backend.api.auth import get_current_user +from backend.utils.click_logger import log_click + +router = APIRouter(prefix="/log", tags=["logging"]) + + +class ClickLogRequest(BaseModel): + """Request model for click logging.""" + page: str + element_type: str + element_id: Optional[str] = None + element_text: Optional[str] = None + context: Optional[dict] = None + + +@router.post("/click") +def log_click_event( + request: ClickLogRequest, + current_user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """Log a click event from the admin frontend. + + Args: + request: Click event data + current_user: Authenticated user (from JWT token) + + Returns: + Success confirmation + """ + username = current_user.get("username", "unknown") + + try: + log_click( + username=username, + page=request.page, + element_type=request.element_type, + element_id=request.element_id, + element_text=request.element_text, + context=request.context, + ) + return {"status": "ok", "message": "Click logged"} + except Exception as e: + # Don't fail the request if logging fails + # Just return success but log the error + import logging + logging.error(f"Failed to log click: {e}") + return {"status": "ok", "message": "Click logged (with errors)"} + diff --git a/backend/app.py b/backend/app.py index 1d340ab..d7bba1d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -26,6 +26,7 @@ from backend.api.users import router as users_router from backend.api.auth_users import router as auth_users_router from backend.api.role_permissions import router as role_permissions_router from backend.api.videos import router as videos_router +from backend.api.click_log import router as click_log_router from backend.api.version import router as version_router from backend.settings import APP_TITLE, APP_VERSION from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES @@ -747,6 +748,7 @@ def create_app() -> FastAPI: app.include_router(users_router, prefix="/api/v1") app.include_router(auth_users_router, prefix="/api/v1") app.include_router(role_permissions_router, prefix="/api/v1") + app.include_router(click_log_router, prefix="/api/v1") return app diff --git a/backend/utils/click_logger.py b/backend/utils/click_logger.py new file mode 100644 index 0000000..7ab28ac --- /dev/null +++ b/backend/utils/click_logger.py @@ -0,0 +1,123 @@ +"""Click logging utility with file rotation and management.""" + +from __future__ import annotations + +import os +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Log directory - relative to project root +LOG_DIR = Path(__file__).parent.parent.parent / "logs" +LOG_FILE = LOG_DIR / "admin-clicks.log" +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB +BACKUP_COUNT = 5 # Keep 5 rotated files +RETENTION_DAYS = 30 # Keep logs for 30 days + +# Ensure log directory exists +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# Configure logger with rotation +_logger: Optional[logging.Logger] = None + + +def get_click_logger() -> logging.Logger: + """Get or create the click logger with rotation.""" + global _logger + + if _logger is not None: + return _logger + + _logger = logging.getLogger("admin_clicks") + _logger.setLevel(logging.INFO) + + # Remove existing handlers to avoid duplicates + _logger.handlers.clear() + + # Create rotating file handler + from logging.handlers import RotatingFileHandler + + handler = RotatingFileHandler( + LOG_FILE, + maxBytes=MAX_FILE_SIZE, + backupCount=BACKUP_COUNT, + encoding='utf-8' + ) + + # Simple format: timestamp | username | page | element_type | element_id | element_text | context + formatter = logging.Formatter( + '%(asctime)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + handler.setFormatter(formatter) + _logger.addHandler(handler) + + # Prevent propagation to root logger + _logger.propagate = False + + return _logger + + +def log_click( + username: str, + page: str, + element_type: str, + element_id: Optional[str] = None, + element_text: Optional[str] = None, + context: Optional[dict] = None, +) -> None: + """Log a click event to the log file. + + Args: + username: Username of the user who clicked + page: Page/route where click occurred (e.g., '/identify') + element_type: Type of element (button, link, input, etc.) + element_id: ID of the element (optional) + element_text: Text content of the element (optional) + context: Additional context as dict (optional, will be JSON stringified) + """ + logger = get_click_logger() + + # Format context as JSON string if provided + context_str = "" + if context: + import json + try: + context_str = f" | {json.dumps(context)}" + except (TypeError, ValueError): + context_str = f" | {str(context)}" + + # Build log message + parts = [ + username, + page, + element_type, + element_id or "", + element_text or "", + ] + + # Join parts with | separator, remove empty parts + message = " | ".join(part for part in parts if part) + context_str + + logger.info(message) + + +def cleanup_old_logs() -> None: + """Remove log files older than RETENTION_DAYS.""" + if not LOG_DIR.exists(): + return + + from datetime import timedelta + cutoff_date = datetime.now() - timedelta(days=RETENTION_DAYS) + + for log_file in LOG_DIR.glob("admin-clicks.log.*"): + try: + # Check file modification time + mtime = datetime.fromtimestamp(log_file.stat().st_mtime) + if mtime < cutoff_date: + log_file.unlink() + except (OSError, ValueError): + # Skip files we can't process + pass + diff --git a/scripts/cleanup-click-logs.sh b/scripts/cleanup-click-logs.sh new file mode 100755 index 0000000..0b77ab4 --- /dev/null +++ b/scripts/cleanup-click-logs.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Cleanup old click log files (older than retention period) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LOG_DIR="$PROJECT_ROOT/logs" + +if [ ! -d "$LOG_DIR" ]; then + echo "Log directory does not exist: $LOG_DIR" + exit 0 +fi + +# Run Python cleanup function +cd "$PROJECT_ROOT" +python3 -c " +from backend.utils.click_logger import cleanup_old_logs +cleanup_old_logs() +print('✅ Old click logs cleaned up') +" 2>&1 +