diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index e7d9b2c2..2f61b7d6 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -33,8 +33,7 @@ const { getPresignedUploadUrl } = require("@urbackend/common"); const { verifyUploadedFile } = require("@urbackend/common"); const { getPublicIp } = require("@urbackend/common"); const { clearCompiledModel } = require("@urbackend/common"); -const { createUniqueIndexes } = require("@urbackend/common"); - +const { createUniqueIndexes, ApiAnalytics } = require("@urbackend/common"); const MAX_FILE_SIZE = 10 * 1024 * 1024; const SAFETY_MAX_BYTES = 100 * 1024 * 1024; const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64; @@ -1959,25 +1958,27 @@ module.exports.deleteProject = async (req, res) => { } }; -module.exports.analytics = async (req, res) => { +// MODIFIED analytics function to include API performance metrics +module.exports.analytics = async (req, res, next) => { try { const { projectId } = req.params; - const project = await Project.findOne({ - _id: projectId, - owner: req.user._id, - }); - if (!project) - return res - .status(404) - .json({ error: "Project not found or access denied." }); + const { range = 'last24h' } = req.query; + + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (!project) { + return res.status(404).json({ + success: false, + data: {}, + message: "Project not found or access denied.", + }); + } + + // Existing analytics const totalRequests = await Log.countDocuments({ projectId }); - const logs = await Log.find({ projectId }) - .sort({ timestamp: -1 }) - .limit(50); + const logs = await Log.find({ projectId }).sort({ timestamp: -1 }).limit(50); const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const chartData = await Log.aggregate([ { $match: { @@ -1994,18 +1995,65 @@ module.exports.analytics = async (req, res) => { { $sort: { _id: 1 } }, ]); - res.json({ - storage: { used: project.storageUsed, limit: project.storageLimit }, - database: { used: project.databaseUsed, limit: project.databaseLimit }, - totalRequests, - logs, - chartData, - }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}; + // New performance metrics + // ✅ Whitelist allowed ranges (including explicit 'allTime') +const VALID_RANGES = new Set(['last1h', 'last24h', 'last7d', 'last30d', 'allTime']); +if (!VALID_RANGES.has(range)) { + return res.status(400).json({ + success: false, + data: {}, + message: `Invalid range. Allowed values: ${[...VALID_RANGES].join(', ')}.`, + }); +} + +let startDate = new Date(); +switch (range) { + case 'last1h': startDate.setHours(startDate.getHours() - 1); break; + case 'last24h': startDate.setDate(startDate.getDate() - 1); break; + case 'last7d': startDate.setDate(startDate.getDate() - 7); break; + case 'last30d': startDate.setDate(startDate.getDate() - 30); break; + case 'allTime': startDate = new Date(0); break; +} + const match = { + projectId: new mongoose.Types.ObjectId(projectId), + timestamp: { $gte: startDate }, + }; + + // Single aggregation for both latency and error metrics +const analyticsAgg = await ApiAnalytics.aggregate([ + { $match: match }, + { + $group: { + _id: null, + avg: { $avg: '$responseTimeMs' }, + total: { $sum: 1 }, + errors: { $sum: { $cond: [{ $gte: ['$statusCode', 400] }, 1, 0] } }, + }, + }, +]); +const avgResponseTimeMs = analyticsAgg[0]?.avg ?? null; +const errorRate = analyticsAgg[0] ? (analyticsAgg[0].errors / analyticsAgg[0].total) * 100 : 0; + + // ✅ Correct response format + return res.json({ + success: true, + data: { + storage: { used: project.storageUsed, limit: project.storageLimit }, + database: { used: project.databaseUsed, limit: project.databaseLimit }, + totalRequests, + logs, + chartData, + avgResponseTimeMs, + errorRate, + range, + }, + message: 'Analytics fetched successfully.', + }); + } catch (err) { + console.error('Analytics error:', err); + return next(new AppError(500, 'Failed to fetch analytics.')); +}}; // FUNCTION - TOGGLE AUTH module.exports.toggleAuth = async (req, res) => { try { diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index e34ea262..dcb68ca9 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -1,10 +1,9 @@ const rateLimit = require('express-rate-limit'); -const { Log, redis } = require('@urbackend/common'); +const { Log, redis, ApiAnalytics } = require('@urbackend/common'); const { getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('../utils/usageCounter'); // Rate Limiter - const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, @@ -15,17 +14,21 @@ const limiter = rateLimit({ validate: { trustProxy: false } }); -// Logger +// Logger with API analytics const logger = (req, res, next) => { - console.time("logger middleware") + console.time("logger middleware"); + + // Capture start time for response time measurement + const startHr = process.hrtime(); + // Check for Data, Storage, AND UserAuth routes if ( req.originalUrl.startsWith('/api/data') || req.originalUrl.startsWith('/api/storage') || req.originalUrl.startsWith('/api/userAuth') ) { - res.on('finish', async () => { + // --- Existing logging and usage counter --- if (req.project) { try { Log.create({ @@ -37,7 +40,6 @@ const logger = (req, res, next) => { }); // Usage counter (Redis): daily API requests per project - // Skip increment if usageGate already incremented atomically if (!req._dailyCountIncremented) { const day = getDayKey(); const reqCountKey = `project:usage:req:count:${req.project._id}:${day}`; @@ -49,9 +51,30 @@ const logger = (req, res, next) => { console.error("Logging failed:", e.message); } } + + // --- NEW: API performance analytics --- + if (req.project) { + const diff = process.hrtime(startHr); + const responseTimeMs = (diff[0] * 1e3 + diff[1] / 1e6).toFixed(2); + + // Asynchronously store analytics + setImmediate(async () => { + try { + await ApiAnalytics.create({ + projectId: req.project._id, + endpoint: req.route?.path || req.originalUrl, + method: req.method, + statusCode: res.statusCode, + responseTimeMs: parseFloat(responseTimeMs), + }); + } catch (err) { + console.error('Failed to save API analytics:', err); + } + }); + } }); } - console.timeEnd("logger middleware") + console.timeEnd("logger middleware"); next(); }; diff --git a/packages/common/src/models/ApiAnalytics.js b/packages/common/src/models/ApiAnalytics.js new file mode 100644 index 00000000..135c911a --- /dev/null +++ b/packages/common/src/models/ApiAnalytics.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +const apiAnalyticsSchema = new mongoose.Schema( + { + projectId: { type: mongoose.Schema.Types.ObjectId, ref: 'Project', required: true }, + endpoint: { type: String, required: true }, + method: { type: String, required: true }, + statusCode: { type: Number, required: true }, + responseTimeMs: { type: Number, required: true }, + timestamp: { type: Date, default: Date.now }, // TTL index will be applied below + }, + { timestamps: false } +); + +// TTL index – configurable via environment variable (default: 365 days) +const ttlDays = parseInt(process.env.ANALYTICS_TTL_DAYS || '365', 10); +if (!isNaN(ttlDays) && ttlDays > 0) { + apiAnalyticsSchema.index({ timestamp: 1 }, { expireAfterSeconds: ttlDays * 24 * 60 * 60 }); +} else { + console.warn('Invalid ANALYTICS_TTL_DAYS, defaulting to 365 days'); + apiAnalyticsSchema.index({ timestamp: 1 }, { expireAfterSeconds: 365 * 24 * 60 * 60 }); +} + +module.exports = mongoose.model('ApiAnalytics', apiAnalyticsSchema); \ No newline at end of file diff --git a/packages/common/src/models/index.js b/packages/common/src/models/index.js new file mode 100644 index 00000000..dbadcfbd --- /dev/null +++ b/packages/common/src/models/index.js @@ -0,0 +1 @@ +module.exports.ApiAnalytics = require('./ApiAnalytics'); \ No newline at end of file