- PageViewSchema had 'index: true' on sessionId field (line 16)
- AND compound index PageViewSchema.index({ sessionId: 1, timestamp: -1 })
- Compound index already covers sessionId queries (leftmost prefix)
- Removed redundant single-field index to eliminate Mongoose warning
252 lines
6.1 KiB
JavaScript
252 lines
6.1 KiB
JavaScript
/**
|
|
* Analytics Model - Sovereign Analytics Tracking
|
|
* Privacy-respecting, self-hosted analytics (no cookies, no external services)
|
|
*/
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const PageViewSchema = new mongoose.Schema({
|
|
// Page Information
|
|
path: { type: String, required: true, index: true },
|
|
title: { type: String },
|
|
referrer: { type: String },
|
|
|
|
// Visitor Information (privacy-respecting)
|
|
visitorId: { type: String, required: true, index: true }, // Hashed IP + User Agent
|
|
sessionId: { type: String, required: true }, // Session identifier (indexed via compound index below)
|
|
|
|
// Request Details
|
|
userAgent: { type: String },
|
|
language: { type: String },
|
|
country: { type: String }, // Optional: derived from IP if available
|
|
|
|
// Timing
|
|
timestamp: { type: Date, default: Date.now, index: true },
|
|
duration: { type: Number }, // Time spent on page (if available)
|
|
|
|
// Metadata
|
|
isUnique: { type: Boolean, default: false }, // First visit of the day
|
|
isBounce: { type: Boolean, default: false }, // Only page visited in session
|
|
|
|
// UTM Parameters (marketing attribution)
|
|
utmSource: { type: String },
|
|
utmMedium: { type: String },
|
|
utmCampaign: { type: String },
|
|
utmContent: { type: String },
|
|
utmTerm: { type: String }
|
|
}, {
|
|
timestamps: true
|
|
});
|
|
|
|
// Indexes for performance
|
|
PageViewSchema.index({ timestamp: -1 });
|
|
PageViewSchema.index({ path: 1, timestamp: -1 });
|
|
PageViewSchema.index({ visitorId: 1, timestamp: -1 });
|
|
PageViewSchema.index({ sessionId: 1, timestamp: -1 });
|
|
|
|
// Session Schema
|
|
const SessionSchema = new mongoose.Schema({
|
|
sessionId: { type: String, required: true, unique: true }, // unique already creates index
|
|
visitorId: { type: String, required: true, index: true },
|
|
|
|
startTime: { type: Date, default: Date.now },
|
|
endTime: { type: Date },
|
|
duration: { type: Number }, // Total session duration in seconds
|
|
|
|
pageViews: { type: Number, default: 1 },
|
|
pages: [{ type: String }], // Paths visited
|
|
|
|
entryPage: { type: String },
|
|
exitPage: { type: String },
|
|
|
|
referrer: { type: String },
|
|
utmSource: { type: String },
|
|
utmMedium: { type: String },
|
|
utmCampaign: { type: String },
|
|
|
|
country: { type: String },
|
|
language: { type: String },
|
|
|
|
isActive: { type: Boolean, default: true } // Session still active
|
|
}, {
|
|
timestamps: true
|
|
});
|
|
|
|
SessionSchema.index({ sessionId: 1 });
|
|
SessionSchema.index({ visitorId: 1, startTime: -1 });
|
|
SessionSchema.index({ startTime: -1 });
|
|
|
|
// Statics for analytics queries
|
|
|
|
/**
|
|
* Get real-time visitor count (last 5 minutes)
|
|
*/
|
|
SessionSchema.statics.getRealTimeVisitors = async function() {
|
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
return await this.countDocuments({
|
|
isActive: true,
|
|
endTime: { $gte: fiveMinutesAgo }
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get total page views for a date range
|
|
*/
|
|
PageViewSchema.statics.getPageViews = async function(startDate, endDate) {
|
|
return await this.countDocuments({
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get unique visitors for a date range
|
|
*/
|
|
PageViewSchema.statics.getUniqueVisitors = async function(startDate, endDate) {
|
|
const result = await this.distinct('visitorId', {
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
});
|
|
return result.length;
|
|
};
|
|
|
|
/**
|
|
* Get top pages by views
|
|
*/
|
|
PageViewSchema.statics.getTopPages = async function(startDate, endDate, limit = 10) {
|
|
return await this.aggregate([
|
|
{
|
|
$match: {
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: '$path',
|
|
views: { $sum: 1 },
|
|
uniqueVisitors: { $addToSet: '$visitorId' }
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
path: '$_id',
|
|
views: 1,
|
|
uniqueVisitors: { $size: '$uniqueVisitors' }
|
|
}
|
|
},
|
|
{
|
|
$sort: { views: -1 }
|
|
},
|
|
{
|
|
$limit: limit
|
|
}
|
|
]);
|
|
};
|
|
|
|
/**
|
|
* Get top referrers
|
|
*/
|
|
PageViewSchema.statics.getTopReferrers = async function(startDate, endDate, limit = 10) {
|
|
return await this.aggregate([
|
|
{
|
|
$match: {
|
|
timestamp: { $gte: startDate, $lte: endDate },
|
|
referrer: { $exists: true, $ne: '' }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: '$referrer',
|
|
visits: { $sum: 1 }
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
referrer: '$_id',
|
|
visits: 1
|
|
}
|
|
},
|
|
{
|
|
$sort: { visits: -1 }
|
|
},
|
|
{
|
|
$limit: limit
|
|
}
|
|
]);
|
|
};
|
|
|
|
/**
|
|
* Get hourly page views (for trend charts)
|
|
*/
|
|
PageViewSchema.statics.getHourlyViews = async function(startDate, endDate) {
|
|
return await this.aggregate([
|
|
{
|
|
$match: {
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: {
|
|
year: { $year: '$timestamp' },
|
|
month: { $month: '$timestamp' },
|
|
day: { $dayOfMonth: '$timestamp' },
|
|
hour: { $hour: '$timestamp' }
|
|
},
|
|
views: { $sum: 1 },
|
|
uniqueVisitors: { $addToSet: '$visitorId' }
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
hour: '$_id',
|
|
views: 1,
|
|
uniqueVisitors: { $size: '$uniqueVisitors' }
|
|
}
|
|
},
|
|
{
|
|
$sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1, '_id.hour': 1 }
|
|
}
|
|
]);
|
|
};
|
|
|
|
/**
|
|
* Get bounce rate
|
|
*/
|
|
SessionSchema.statics.getBounceRate = async function(startDate, endDate) {
|
|
const totalSessions = await this.countDocuments({
|
|
startTime: { $gte: startDate, $lte: endDate }
|
|
});
|
|
|
|
const bouncedSessions = await this.countDocuments({
|
|
startTime: { $gte: startDate, $lte: endDate },
|
|
pageViews: 1
|
|
});
|
|
|
|
return totalSessions > 0 ? (bouncedSessions / totalSessions) * 100 : 0;
|
|
};
|
|
|
|
/**
|
|
* Get average session duration
|
|
*/
|
|
SessionSchema.statics.getAverageDuration = async function(startDate, endDate) {
|
|
const result = await this.aggregate([
|
|
{
|
|
$match: {
|
|
startTime: { $gte: startDate, $lte: endDate },
|
|
duration: { $exists: true, $gt: 0 }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
avgDuration: { $avg: '$duration' }
|
|
}
|
|
}
|
|
]);
|
|
|
|
return result.length > 0 ? Math.round(result[0].avgDuration) : 0;
|
|
};
|
|
|
|
const PageView = mongoose.model('PageView', PageViewSchema);
|
|
const Session = mongoose.model('Session', SessionSchema);
|
|
|
|
module.exports = { PageView, Session };
|