/** * Analytics Tracking Middleware * Privacy-respecting visitor tracking (no cookies, hashed identifiers) */ const crypto = require('crypto'); const { PageView, Session } = require('../models/Analytics.model'); /** * Generate visitor ID from IP + User Agent (hashed for privacy) */ function generateVisitorId(ip, userAgent) { const hash = crypto.createHash('sha256'); hash.update(`${ip}${userAgent}${process.env.ANALYTICS_SALT || 'tractatus-analytics'}`); return hash.digest('hex').substring(0, 32); } /** * Generate session ID (unique per session) */ function generateSessionId() { return crypto.randomBytes(16).toString('hex'); } /** * Parse UTM parameters from query string */ function parseUTM(query) { return { utmSource: query.utm_source || null, utmMedium: query.utm_medium || null, utmCampaign: query.utm_campaign || null, utmContent: query.utm_content || null, utmTerm: query.utm_term || null }; } /** * Analytics tracking middleware */ async function trackPageView(req, res, next) { // Skip tracking for: // - Admin pages // - API routes // - Static assets // - Health checks const skipPaths = ['/admin/', '/api/', '/css/', '/js/', '/images/', '/fonts/', '/health']; if (skipPaths.some(path => req.path.startsWith(path))) { return next(); } // Skip if this is not a page view (only track HTML pages) const acceptHeader = req.headers.accept || ''; if (!acceptHeader.includes('text/html')) { return next(); } try { // Get visitor information const ip = req.ip || req.connection.remoteAddress; const userAgent = req.headers['user-agent'] || 'Unknown'; const visitorId = generateVisitorId(ip, userAgent); // Get or create session let sessionId = req.cookies?.tractatus_session; if (!sessionId) { sessionId = generateSessionId(); // Set session cookie (30 minutes expiry) res.cookie('tractatus_session', sessionId, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 30 * 60 * 1000, // 30 minutes sameSite: 'strict' }); } // Parse UTM parameters const utm = parseUTM(req.query); // Get referrer (external only) let referrer = req.headers.referer || req.headers.referrer || ''; if (referrer) { const referrerHost = new URL(referrer).hostname; const currentHost = req.hostname; if (referrerHost === currentHost) { referrer = ''; // Internal referrer, don't track } } // Get language const language = req.headers['accept-language']?.split(',')[0] || 'en'; // Check if this is a unique visitor today const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const existingToday = await PageView.findOne({ visitorId, timestamp: { $gte: startOfDay } }); const isUnique = !existingToday; // Create page view record const pageView = await PageView.create({ path: req.path, title: null, // Will be updated by client-side script referrer, visitorId, sessionId, userAgent, language, timestamp: new Date(), isUnique, ...utm }); // Update or create session let session = await Session.findOne({ sessionId }); if (!session) { // New session session = await Session.create({ sessionId, visitorId, startTime: new Date(), pageViews: 1, pages: [req.path], entryPage: req.path, referrer, language, isActive: true, ...utm }); } else { // Update existing session session.pageViews += 1; session.pages.push(req.path); session.exitPage = req.path; session.endTime = new Date(); session.duration = Math.round((session.endTime - session.startTime) / 1000); await session.save(); } // Attach analytics data to request (for potential use in response) req.analytics = { visitorId, sessionId, pageViewId: pageView._id }; } catch (error) { // Analytics failure should not break the site console.error('[Analytics] Tracking error:', error.message); } next(); } /** * Track page duration (called when user leaves page) */ async function trackPageDuration(req, res, next) { try { const { pageViewId, duration } = req.body; if (pageViewId && duration) { await PageView.findByIdAndUpdate(pageViewId, { duration: parseInt(duration, 10) }); } res.json({ success: true }); } catch (error) { console.error('[Analytics] Duration tracking error:', error.message); res.status(500).json({ error: 'Failed to track duration' }); } } /** * Track page title (called from client-side) */ async function trackPageTitle(req, res, next) { try { const { pageViewId, title } = req.body; if (pageViewId && title) { await PageView.findByIdAndUpdate(pageViewId, { title: title.substring(0, 200) // Limit title length }); } res.json({ success: true }); } catch (error) { console.error('[Analytics] Title tracking error:', error.message); res.status(500).json({ error: 'Failed to track title' }); } } /** * Cleanup old sessions (mark as inactive) */ async function cleanupSessions() { try { const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); await Session.updateMany( { isActive: true, endTime: { $lt: thirtyMinutesAgo } }, { isActive: false } ); } catch (error) { console.error('[Analytics] Session cleanup error:', error.message); } } // Run cleanup every 5 minutes setInterval(cleanupSessions, 5 * 60 * 1000); module.exports = { trackPageView, trackPageDuration, trackPageTitle, cleanupSessions };