Skip to content
100 changes: 74 additions & 26 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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 {
Expand Down
37 changes: 30 additions & 7 deletions apps/public-api/src/middlewares/api_usage.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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({
Expand All @@ -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}`;
Expand All @@ -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();
};

Expand Down
24 changes: 24 additions & 0 deletions packages/common/src/models/ApiAnalytics.js
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions packages/common/src/models/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports.ApiAnalytics = require('./ApiAnalytics');
Loading