From 543c20209ffb00dbbb7424e97e1082c29a079754 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Tue, 12 May 2026 01:37:27 +0530 Subject: [PATCH 1/4] feat(analytics): implement platform telemetry, developer activation funnel, and admin metrics dashboard This commit introduces the Startup Metrics Stack to monitor platform health and track developer activation. Key Changes: - Core Data: Added PlatformEvent and DeveloperActivity models. - BullMQ Jobs: Implemented daily activityRollupQueue and 5-min reliabilityAlertQueue to track error rate spikes. - Activation Funnel: Instrumented auth, project creation, and API usage to emit pipeline events. - Admin Dashboard: Added 7 new protected metrics APIs and built the AdminMetrics UI for operators. - Developer UI: Added DeveloperMetrics component to show personal activation status and 30-day activity. --- .../src/__tests__/auth.controller.test.js | 5 + apps/dashboard-api/src/app.js | 4 + .../controllers/admin.metrics.controller.js | 364 ++++++++++++++++++ .../src/controllers/analytics.controller.js | 209 +++++++++- .../src/controllers/auth.controller.js | 10 + .../src/controllers/events.controller.js | 51 +++ .../src/controllers/project.controller.js | 12 + .../dashboard-api/src/routes/admin.metrics.js | 24 ++ apps/dashboard-api/src/routes/analytics.js | 6 + apps/dashboard-api/src/routes/events.js | 15 + apps/dashboard-api/src/utils/emitEvent.js | 31 ++ apps/public-api/src/app.js | 11 + apps/public-api/src/middlewares/api_usage.js | 34 +- apps/public-api/src/utils/emitEvent.js | 25 ++ apps/web-dashboard/src/App.jsx | 9 + .../components/Dashboard/DeveloperMetrics.jsx | 92 +++++ apps/web-dashboard/src/index.css | 251 ++++++++++++ apps/web-dashboard/src/pages/AdminMetrics.jsx | 282 ++++++++++++++ apps/web-dashboard/src/pages/Dashboard.jsx | 4 + packages/common/src/index.js | 20 + .../common/src/models/DeveloperActivity.js | 39 ++ packages/common/src/models/PlatformEvent.js | 60 +++ packages/common/src/models/index.js | 7 +- .../common/src/queues/activityRollupQueue.js | 161 ++++++++ .../src/queues/reliabilityAlertQueue.js | 139 +++++++ 25 files changed, 1857 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard-api/src/controllers/admin.metrics.controller.js create mode 100644 apps/dashboard-api/src/controllers/events.controller.js create mode 100644 apps/dashboard-api/src/routes/admin.metrics.js create mode 100644 apps/dashboard-api/src/routes/events.js create mode 100644 apps/dashboard-api/src/utils/emitEvent.js create mode 100644 apps/public-api/src/utils/emitEvent.js create mode 100644 apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx create mode 100644 apps/web-dashboard/src/pages/AdminMetrics.jsx create mode 100644 packages/common/src/models/DeveloperActivity.js create mode 100644 packages/common/src/models/PlatformEvent.js create mode 100644 packages/common/src/queues/activityRollupQueue.js create mode 100644 packages/common/src/queues/reliabilityAlertQueue.js diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 3f1827d3f..40c70c5b7 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -55,10 +55,15 @@ jest.mock('@urbackend/common', () => { const Project = jest.fn(); Project.deleteMany = jest.fn().mockResolvedValue(undefined); + const PlatformEvent = { + create: jest.fn().mockResolvedValue(undefined), + }; + return { Developer, Otp, Project, + PlatformEvent, sendOtp: jest.fn().mockResolvedValue(undefined), // Use real zod shapes so validation logic is exercised. loginSchema: z.object({ diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index 2911ccbf7..64db34784 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -103,6 +103,8 @@ const releaseRoute = require('./routes/releases'); const webhookRoute = require('./routes/webhooks'); const analyticsRoute = require('./routes/analytics'); const billingRoute = require('./routes/billing'); +const eventsRoute = require('./routes/events'); +const adminMetricsRoute = require('./routes/admin.metrics'); app.use('/api/auth', authRoute); app.use('/api/projects', dashboardLimiter, projectRoute); @@ -110,6 +112,8 @@ app.use('/api/projects', dashboardLimiter, webhookRoute); app.use('/api/releases', releaseRoute); app.use('/api/analytics', dashboardLimiter, analyticsRoute); app.use('/api/billing', billingRoute); +app.use('/api/events', dashboardLimiter, eventsRoute); +app.use('/api/admin/metrics', dashboardLimiter, adminMetricsRoute); diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js new file mode 100644 index 000000000..b740f6aa3 --- /dev/null +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -0,0 +1,364 @@ +const mongoose = require('mongoose'); +const { Developer, Project, Log, PlatformEvent, DeveloperActivity } = require('@urbackend/common'); + +/** + * Guard: only callable by the platform admin. + * Checked upstream via the isAdmin flag on the JWT payload, + * but we double-check here for defence in depth. + */ +function requireAdmin(req, res) { + if (!req.user?.isAdmin) { + res.status(403).json({ success: false, data: {}, message: 'Admin access required.' }); + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/overview +// Platform-wide snapshot: signups, verified devs, active projects, total calls. +// --------------------------------------------------------------------------- +module.exports.getOverview = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + + const [ + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + northStarProjects, + ] = await Promise.all([ + Developer.countDocuments(), + Developer.countDocuments({ isVerified: true }), + Project.countDocuments(), + Log.countDocuments(), + Log.distinct('projectId', { + status: { $gte: 200, $lt: 300 }, + timestamp: { $gte: sevenDaysAgo }, + }), + ]); + + return res.json({ + success: true, + data: { + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + activeProjectsLast7d: northStarProjects.length, + }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/activation-funnel +// Platform-wide funnel: count of devs who completed each activation step. +// --------------------------------------------------------------------------- +module.exports.getActivationFunnel = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const FUNNEL_STEPS = [ + 'signup_completed', + 'email_verified', + 'project_created', + 'collection_created', + 'first_api_success', + ]; + + const counts = await PlatformEvent.aggregate([ + { $match: { event: { $in: FUNNEL_STEPS } } }, + { + $group: { + _id: '$event', + uniqueDevs: { $addToSet: '$developerId' }, + }, + }, + { + $project: { + event: '$_id', + count: { $size: '$uniqueDevs' }, + _id: 0, + }, + }, + ]); + + const countMap = {}; + for (const row of counts) { + countMap[row.event] = row.count; + } + + const steps = FUNNEL_STEPS.map((step, i) => ({ + step, + order: i + 1, + uniqueDevs: countMap[step] || 0, + })); + + return res.json({ success: true, data: { steps }, message: '' }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/cohorts?month=2026-05 +// D1/D7/D30 retention for developers who signed up in a given month. +// --------------------------------------------------------------------------- +module.exports.getCohorts = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const { month } = req.query; // e.g. "2026-05" + if (!month || !/^\d{4}-\d{2}$/.test(month)) { + return res.status(400).json({ + success: false, + data: {}, + message: 'Provide month as YYYY-MM (e.g. 2026-05)', + }); + } + + const [year, mo] = month.split('-').map(Number); + const cohortStart = new Date(Date.UTC(year, mo - 1, 1)); + const cohortEnd = new Date(Date.UTC(year, mo, 1)); + + // Developers who signed up in this cohort month + const signups = await PlatformEvent.find({ + event: 'signup_completed', + timestamp: { $gte: cohortStart, $lt: cohortEnd }, + }) + .select('developerId timestamp') + .lean(); + + const cohortSize = signups.length; + if (cohortSize === 0) { + return res.json({ + success: true, + data: { month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }, + message: '', + }); + } + + // For each developer, check if active on D+1, D+7, D+30 + const checkDayRetention = async (daysAfter) => { + let retained = 0; + await Promise.all( + signups.map(async (s) => { + const base = new Date(s.timestamp); + base.setUTCHours(0, 0, 0, 0); + const target = new Date(base); + target.setUTCDate(target.getUTCDate() + daysAfter); + const next = new Date(target); + next.setUTCDate(next.getUTCDate() + 1); + + const exists = await DeveloperActivity.findOne({ + developerId: s.developerId, + date: { $gte: target, $lt: next }, + }).lean(); + if (exists) retained++; + }), + ); + return retained; + }; + + const [d1, d7, d30] = await Promise.all([ + checkDayRetention(1), + checkDayRetention(7), + checkDayRetention(30), + ]); + + return res.json({ + success: true, + data: { + month, + cohortSize, + d1, + d7, + d30, + d1Pct: Math.round((d1 / cohortSize) * 100), + d7Pct: Math.round((d7 / cohortSize) * 100), + d30Pct: Math.round((d30 / cohortSize) * 100), + }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/feature-usage +// Platform-wide feature breakdown from DeveloperActivity (last 30 days). +// --------------------------------------------------------------------------- +module.exports.getFeatureUsage = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + + const agg = await DeveloperActivity.aggregate([ + { $match: { date: { $gte: thirtyDaysAgo } } }, + { + $group: { + _id: null, + totalApiCalls: { $sum: '$apiCallCount' }, + totalMailSent: { $sum: '$mailSentCount' }, + totalStorageUploads: { $sum: '$storageUploadsCount' }, + totalWebhooksFired: { $sum: '$webhookTriggeredCount' }, + activeDevelopers: { $addToSet: '$developerId' }, + }, + }, + ]); + + const result = agg[0] || { + totalApiCalls: 0, + totalMailSent: 0, + totalStorageUploads: 0, + totalWebhooksFired: 0, + activeDevelopers: [], + }; + + return res.json({ + success: true, + data: { + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDevelopers: result.activeDevelopers.length, + }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/reliability +// Global error rate and latency across all projects (last 24h from ApiAnalytics). +// --------------------------------------------------------------------------- +module.exports.getReliability = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const { ApiAnalytics } = require('@urbackend/common'); + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const agg = await ApiAnalytics.aggregate([ + { $match: { timestamp: { $gte: since } } }, + { + $group: { + _id: null, + total: { $sum: 1 }, + errors: { $sum: { $cond: [{ $gte: ['$statusCode', 500] }, 1, 0] } }, + p50: { $percentile: { input: '$responseTimeMs', p: [0.5], method: 'approximate' } }, + p95: { $percentile: { input: '$responseTimeMs', p: [0.95], method: 'approximate' } }, + p99: { $percentile: { input: '$responseTimeMs', p: [0.99], method: 'approximate' } }, + }, + }, + ]); + + const r = agg[0] || { total: 0, errors: 0, p50: [0], p95: [0], p99: [0] }; + + return res.json({ + success: true, + data: { + window: '24h', + totalRequests: r.total, + errorCount: r.errors, + errorRate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(2) : '0.00', + p50Ms: r.p50?.[0]?.toFixed(1) ?? null, + p95Ms: r.p95?.[0]?.toFixed(1) ?? null, + p99Ms: r.p99?.[0]?.toFixed(1) ?? null, + }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/top-projects +// Most active projects by API calls in the last 7 days. +// --------------------------------------------------------------------------- +module.exports.getTopProjects = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + + const agg = await Log.aggregate([ + { $match: { timestamp: { $gte: sevenDaysAgo } } }, + { $group: { _id: '$projectId', callCount: { $sum: 1 } } }, + { $sort: { callCount: -1 } }, + { $limit: 20 }, + { + $lookup: { + from: 'projects', + localField: '_id', + foreignField: '_id', + as: 'project', + }, + }, + { + $project: { + _id: 0, + projectId: '$_id', + callCount: 1, + projectName: { $arrayElemAt: ['$project.name', 0] }, + }, + }, + ]); + + return res.json({ success: true, data: { projects: agg }, message: '' }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/churn-signals +// Projects with zero API calls in the last 14 days that had prior activity. +// --------------------------------------------------------------------------- +module.exports.getChurnSignals = async (req, res) => { + if (!requireAdmin(req, res)) return; + try { + const fourteenDaysAgo = new Date(); + fourteenDaysAgo.setUTCDate(fourteenDaysAgo.getUTCDate() - 14); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + + // Projects active in 30–14d window + const prevActive = await Log.distinct('projectId', { + timestamp: { $gte: thirtyDaysAgo, $lt: fourteenDaysAgo }, + }); + + // Projects that were active but have ZERO calls in last 14d + const recentlyActive = await Log.distinct('projectId', { + timestamp: { $gte: fourteenDaysAgo }, + }); + const recentSet = new Set(recentlyActive.map(String)); + + const churnedIds = prevActive.filter((id) => !recentSet.has(String(id))); + + const projects = await Project.find({ _id: { $in: churnedIds } }) + .select('name owner createdAt') + .populate('owner', 'email') + .limit(50) + .lean(); + + return res.json({ + success: true, + data: { churnSignals: churnedIds.length, projects }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 3a7ca1dd1..8484499c5 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -1,4 +1,4 @@ -const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common"); +const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity } = require("@urbackend/common"); const mongoose = require("mongoose"); /** @@ -42,13 +42,9 @@ module.exports.getGlobalStats = async (req, res) => { const projects = await Project.find({ owner: user_id }).select("_id").lean(); const projectIds = projects.map(p => p._id); - // Calculate total requests const totalRequests = await Log.countDocuments({ projectId: { $in: projectIds } }); - - // Calculate total webhooks const totalWebhooks = await Webhook.countDocuments({ projectId: { $in: projectIds } }); - // Calculate total users across all project databases let totalUsers = 0; for (const project of projects) { try { @@ -121,3 +117,206 @@ module.exports.getRecentActivity = async (req, res) => { res.status(500).json({ error: err.message }); } }; + +// --------------------------------------------------------------------------- +// ACTIVATION FUNNEL +// Returns step-by-step conversion rates for the current developer. +// --------------------------------------------------------------------------- +module.exports.getActivationFunnel = async (req, res) => { + try { + const developerId = req.user._id; + + const FUNNEL_STEPS = [ + 'signup_completed', + 'email_verified', + 'project_created', + 'collection_created', + 'first_api_success', + ]; + + // Fetch one event per step (we only need existence, not count) + const events = await PlatformEvent.find({ + developerId, + event: { $in: FUNNEL_STEPS }, + }) + .sort({ timestamp: 1 }) + .select('event timestamp') + .lean(); + + const completed = {}; + for (const e of events) { + if (!completed[e.event]) completed[e.event] = e.timestamp; + } + + const steps = FUNNEL_STEPS.map((step, i) => ({ + step, + order: i + 1, + completed: !!completed[step], + completedAt: completed[step] || null, + })); + + return res.json({ success: true, data: { steps }, message: '' }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// RETENTION (D1 / D7 / D30) +// Checks whether the developer was active on Day+1, Day+7, Day+30 after signup. +// --------------------------------------------------------------------------- +module.exports.getRetention = async (req, res) => { + try { + const developerId = req.user._id; + + // Find signup event to anchor the cohort start date + const signupEvent = await PlatformEvent.findOne({ + developerId, + event: 'signup_completed', + }).sort({ timestamp: 1 }).lean(); + + if (!signupEvent) { + return res.json({ + success: true, + data: { d1: false, d7: false, d30: false, signupDate: null }, + message: '', + }); + } + + const signupDate = new Date(signupEvent.timestamp); + signupDate.setUTCHours(0, 0, 0, 0); + + const checkDay = async (daysAfter) => { + const targetDate = new Date(signupDate); + targetDate.setUTCDate(targetDate.getUTCDate() + daysAfter); + const nextDate = new Date(targetDate); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + + const activity = await DeveloperActivity.findOne({ + developerId, + date: { $gte: targetDate, $lt: nextDate }, + }).lean(); + return !!activity; + }; + + const [d1, d7, d30] = await Promise.all([ + checkDay(1), + checkDay(7), + checkDay(30), + ]); + + return res.json({ + success: true, + data: { d1, d7, d30, signupDate }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// FEATURE ENGAGEMENT (trailing 30 days) +// Returns per-feature usage totals across all projects for the developer. +// --------------------------------------------------------------------------- +module.exports.getEngagement = async (req, res) => { + try { + const developerId = req.user._id; + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + + const agg = await DeveloperActivity.aggregate([ + { + $match: { + developerId: new mongoose.Types.ObjectId(developerId), + date: { $gte: thirtyDaysAgo }, + }, + }, + { + $group: { + _id: null, + totalApiCalls: { $sum: '$apiCallCount' }, + totalMailSent: { $sum: '$mailSentCount' }, + totalStorageUploads: { $sum: '$storageUploadsCount' }, + totalWebhooksFired: { $sum: '$webhookTriggeredCount' }, + activeDays: { $sum: 1 }, + allProjectIds: { $push: '$activeProjectIds' }, + }, + }, + ]); + + const result = agg[0] || { + totalApiCalls: 0, + totalMailSent: 0, + totalStorageUploads: 0, + totalWebhooksFired: 0, + activeDays: 0, + }; + + // Unique active projects in the 30-day window + const flatProjectIds = (result.allProjectIds || []).flat(); + const uniqueActiveProjects = new Set(flatProjectIds.map(String)).size; + + return res.json({ + success: true, + data: { + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDays: result.activeDays, + uniqueActiveProjects, + }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + +// --------------------------------------------------------------------------- +// NORTH STAR METRIC +// "Projects making successful API calls in the last 7 days" +// --------------------------------------------------------------------------- +module.exports.getNorthStar = async (req, res) => { + try { + const developerId = req.user._id; + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + + // Projects owned by this developer + const allProjects = await Project.find({ owner: developerId }).select('_id name').lean(); + const projectIds = allProjects.map((p) => p._id); + const totalProjects = projectIds.length; + + if (totalProjects === 0) { + return res.json({ + success: true, + data: { activeProjects: 0, totalProjects: 0, percentage: 0 }, + message: '', + }); + } + + // Projects with at least one 2xx log in the last 7 days + const activeProjectIds = await Log.distinct('projectId', { + projectId: { $in: projectIds }, + status: { $gte: 200, $lt: 300 }, + timestamp: { $gte: sevenDaysAgo }, + }); + + const activeProjects = activeProjectIds.length; + const percentage = totalProjects > 0 ? Math.round((activeProjects / totalProjects) * 100) : 0; + + return res.json({ + success: true, + data: { activeProjects, totalProjects, percentage }, + message: '', + }); + } catch (err) { + res.status(500).json({ success: false, data: {}, message: err.message }); + } +}; + diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 4ea89ca63..798114692 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -14,6 +14,7 @@ const { resetPasswordSchema, verifyOtpSchema } = require("@urbackend/common"); +const { emitEvent } = require('../utils/emitEvent'); const ACCESS_TOKEN_EXPIRES_IN = '15m'; const REFRESH_TOKEN_EXPIRES_IN = '7d'; @@ -181,6 +182,9 @@ const findOrCreateGithubDeveloper = async (profile) => { }); await developer.save(); + // Activation funnel — GitHub signup counts as both signup + verified + emitEvent(developer._id, 'signup_completed', { method: 'github' }); + emitEvent(developer._id, 'email_verified', { method: 'github' }); return developer; }; @@ -281,6 +285,9 @@ module.exports.register = async (req, res) => { const newDev = new Developer({ email: email.toLowerCase().trim(), password: hashedPassword }); await newDev.save(); + // Activation funnel — signup completed + emitEvent(newDev._id, 'signup_completed', { method: 'email' }); + res.status(201).json({ message: "Registered successfully" }); } catch (err) { if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); @@ -483,6 +490,9 @@ module.exports.verifyOtp = async (req, res) => { existingUser.isVerified = true; await existingUser.save(); + // Activation funnel — email verified + emitEvent(existingUser._id, 'email_verified', { method: 'otp' }); + await sendTokenResponse(existingUser, 200, res); } catch (err) { if (err.status) return res.status(err.status).json({ error: err.message }); diff --git a/apps/dashboard-api/src/controllers/events.controller.js b/apps/dashboard-api/src/controllers/events.controller.js new file mode 100644 index 000000000..a09753734 --- /dev/null +++ b/apps/dashboard-api/src/controllers/events.controller.js @@ -0,0 +1,51 @@ +const { emitEvent } = require('../utils/emitEvent'); + +// Allowed frontend-emitted event names (whitelist prevents garbage in DB) +const ALLOWED_FRONTEND_EVENTS = new Set([ + 'onboarding_step_viewed', + 'api_key_copied', + 'api_key_viewed', + 'sdk_code_copied', + 'docs_opened', + 'ai_schema_accepted', + 'ai_schema_rejected', + 'ai_generation_started', +]); + +/** + * POST /api/events/track + * + * Receives frontend-emitted tracking events and writes them as PlatformEvents. + * Only whitelisted event names are accepted to prevent junk data. + */ +module.exports.track = async (req, res) => { + try { + const { event, properties = {}, projectId } = req.body; + + if (!event || typeof event !== 'string') { + return res.status(400).json({ success: false, message: 'event name is required' }); + } + + const normalizedEvent = event.trim().toLowerCase().replace(/\s+/g, '_'); + + if (!ALLOWED_FRONTEND_EVENTS.has(normalizedEvent)) { + return res.status(400).json({ + success: false, + message: `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`, + }); + } + + // emitEvent is fire-and-forget — responds 200 immediately + emitEvent( + req.user._id, + normalizedEvent, + { ...properties, _source: 'frontend' }, + projectId || null, + ); + + return res.json({ success: true, data: {}, message: 'Event queued' }); + } catch (err) { + console.error('[events.controller] track error:', err); + return res.status(500).json({ success: false, message: 'Internal server error' }); + } +}; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index ec8eb8a29..7f180f8be 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -34,6 +34,7 @@ const { verifyUploadedFile } = require("@urbackend/common"); const { getPublicIp } = require("@urbackend/common"); const { clearCompiledModel } = require("@urbackend/common"); const { createUniqueIndexes, ApiAnalytics } = require("@urbackend/common"); +const { emitEvent } = require('../utils/emitEvent'); const MAX_FILE_SIZE = 10 * 1024 * 1024; const SAFETY_MAX_BYTES = 100 * 1024 * 1024; const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64; @@ -297,6 +298,9 @@ module.exports.createProject = async (req, res) => { delete projectObj.jwtSecret; projectObj.authProviders = sanitizeAuthProviders(projectObj.authProviders); + // Activation funnel — project created + emitEvent(req.user._id, 'project_created', { projectName: name }, newProject._id); + res.status(201).json(projectObj); } catch (err) { await session.abortTransaction(); @@ -724,6 +728,14 @@ module.exports.createCollection = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; + // Activation funnel — collection created + emitEvent( + req.user._id, + 'collection_created', + { collectionName, isUsersCollection: collectionName === 'users' }, + projectId, + ); + return res.status(201).json(projectObj); } catch (err) { await session.abortTransaction(); diff --git a/apps/dashboard-api/src/routes/admin.metrics.js b/apps/dashboard-api/src/routes/admin.metrics.js new file mode 100644 index 000000000..29ffeb28b --- /dev/null +++ b/apps/dashboard-api/src/routes/admin.metrics.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const authMiddleware = require('../middlewares/authMiddleware'); +const { + getOverview, + getActivationFunnel, + getCohorts, + getFeatureUsage, + getReliability, + getTopProjects, + getChurnSignals, +} = require('../controllers/admin.metrics.controller'); + +// All admin routes require a valid dashboard session. +// The controller's requireAdmin() guard enforces isAdmin=true. +router.get('/overview', authMiddleware, getOverview); +router.get('/activation-funnel', authMiddleware, getActivationFunnel); +router.get('/cohorts', authMiddleware, getCohorts); +router.get('/feature-usage', authMiddleware, getFeatureUsage); +router.get('/reliability', authMiddleware, getReliability); +router.get('/top-projects', authMiddleware, getTopProjects); +router.get('/churn-signals', authMiddleware, getChurnSignals); + +module.exports = router; diff --git a/apps/dashboard-api/src/routes/analytics.js b/apps/dashboard-api/src/routes/analytics.js index 544177ad5..6ad929e7f 100644 --- a/apps/dashboard-api/src/routes/analytics.js +++ b/apps/dashboard-api/src/routes/analytics.js @@ -6,4 +6,10 @@ const authMiddleware = require("../middlewares/authMiddleware"); router.get("/stats", authMiddleware, analyticsController.getGlobalStats); router.get("/activity", authMiddleware, analyticsController.getRecentActivity); +// --- Metrics Stack --- +router.get("/funnel", authMiddleware, analyticsController.getActivationFunnel); +router.get("/retention", authMiddleware, analyticsController.getRetention); +router.get("/engagement", authMiddleware, analyticsController.getEngagement); +router.get("/north-star", authMiddleware, analyticsController.getNorthStar); + module.exports = router; diff --git a/apps/dashboard-api/src/routes/events.js b/apps/dashboard-api/src/routes/events.js new file mode 100644 index 000000000..5d25b02d4 --- /dev/null +++ b/apps/dashboard-api/src/routes/events.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { track } = require('../controllers/events.controller'); + +// Middleware: dashboard JWT auth +const { verifyEmail } = require('@urbackend/common'); + +/** + * POST /api/events/track + * Receives frontend analytics events (onboarding steps, key copy, etc.) + * Requires a valid dashboard session. + */ +router.post('/track', verifyEmail, track); + +module.exports = router; diff --git a/apps/dashboard-api/src/utils/emitEvent.js b/apps/dashboard-api/src/utils/emitEvent.js new file mode 100644 index 000000000..16acd04cc --- /dev/null +++ b/apps/dashboard-api/src/utils/emitEvent.js @@ -0,0 +1,31 @@ +const { PlatformEvent } = require('@urbackend/common'); + +/** + * emitEvent — fire-and-forget PlatformEvent writer. + * + * Never throws. Any failure is logged but never surfaces to the caller. + * Use setImmediate so the current request completes before the DB write. + * + * @param {string|ObjectId} developerId — required + * @param {string} event — e.g. 'project_created' + * @param {object} [properties] — optional context + * @param {string|ObjectId} [projectId] — optional + */ +function emitEvent(developerId, event, properties = {}, projectId = null) { + setImmediate(async () => { + try { + await PlatformEvent.create({ + developerId, + event, + properties, + projectId: projectId || null, + timestamp: new Date(), + }); + } catch (err) { + // Never block the caller — just log + console.error(`[emitEvent] Failed to write "${event}":`, err.message); + } + }); +} + +module.exports = { emitEvent }; diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 54b96b894..34821d897 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -23,14 +23,25 @@ const {emailQueue} = require('@urbackend/common'); const {authEmailQueue} = require('@urbackend/common'); const {initWebhookWorker} = require('@urbackend/common'); const {initAuthEmailWorker, initPublicEmailWorker} = require('@urbackend/common'); +const {initActivityRollupWorker, scheduleActivityRollup} = require('@urbackend/common'); +const {initReliabilityAlertWorker, scheduleReliabilityAlert} = require('@urbackend/common'); // Initialize webhook worker if (process.env.NODE_ENV !== 'test') { initWebhookWorker(); initAuthEmailWorker(); initPublicEmailWorker(); + initActivityRollupWorker(); + scheduleActivityRollup().catch((err) => + console.error('[ActivityRollup] Failed to schedule cron:', err.message) + ); + initReliabilityAlertWorker(); + scheduleReliabilityAlert().catch((err) => + console.error('[ReliabilityAlert] Failed to schedule cron:', err.message) + ); } + app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(standardizeApiResponse); diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 657a1e636..763ba2b08 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -51,12 +51,11 @@ const logger = (req, res, next) => { } } - // --- NEW: API performance analytics --- + // --- 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({ @@ -71,6 +70,37 @@ const logger = (req, res, next) => { } }); } + + // --- Activation funnel: first_api_success --- + // Fires only once per project lifetime, on the very first 2xx response. + // Uses a permanent Redis NX flag so we don't hit MongoDB on every request. + if (req.project && res.statusCode >= 200 && res.statusCode < 300) { + setImmediate(async () => { + try { + const flagKey = `project:activation:first_api_success:${req.project._id}`; + const isFirst = await redis.set(flagKey, '1', 'NX'); + if (isFirst) { + const { Project, PlatformEvent } = require('@urbackend/common'); + const proj = await Project.findById(req.project._id).select('owner').lean(); + if (proj?.owner) { + await PlatformEvent.create({ + developerId: proj.owner, + projectId: req.project._id, + event: 'first_api_success', + properties: { + method: req.method, + path: req.originalUrl, + statusCode: res.statusCode, + }, + timestamp: new Date(), + }); + } + } + } catch (err) { + console.error('[activation] first_api_success check failed:', err.message); + } + }); + } }); } diff --git a/apps/public-api/src/utils/emitEvent.js b/apps/public-api/src/utils/emitEvent.js new file mode 100644 index 000000000..2ebdd0efb --- /dev/null +++ b/apps/public-api/src/utils/emitEvent.js @@ -0,0 +1,25 @@ +const { PlatformEvent, Project } = require('@urbackend/common'); + +/** + * emitEvent (public-api variant) — fire-and-forget PlatformEvent writer. + * + * Identical contract to the dashboard-api version. + * Never throws, never blocks the active request. + */ +function emitEvent(developerId, event, properties = {}, projectId = null) { + setImmediate(async () => { + try { + await PlatformEvent.create({ + developerId, + event, + properties, + projectId: projectId || null, + timestamp: new Date(), + }); + } catch (err) { + console.error(`[emitEvent] Failed to write "${event}":`, err.message); + } + }); +} + +module.exports = { emitEvent }; diff --git a/apps/web-dashboard/src/App.jsx b/apps/web-dashboard/src/App.jsx index b91adb1fa..9b11946f2 100644 --- a/apps/web-dashboard/src/App.jsx +++ b/apps/web-dashboard/src/App.jsx @@ -29,6 +29,7 @@ import Webhooks from './pages/Webhooks'; import RequestPro from './pages/RequestPro'; import AdminProRequests from './pages/AdminProRequests'; import Onboarding from './pages/Onboarding'; +import AdminMetrics from './pages/AdminMetrics'; import { LayoutProvider } from './context/LayoutContext'; import { PlanProvider } from './context/PlanContext'; @@ -153,6 +154,14 @@ function AppContent() { } /> + + + + + + } /> + diff --git a/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx b/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx new file mode 100644 index 000000000..45e58c3b9 --- /dev/null +++ b/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx @@ -0,0 +1,92 @@ +import { useState, useEffect } from 'react'; +import api from '../../utils/api'; +import { Activity, BarChart2 } from 'lucide-react'; + +export default function DeveloperMetrics() { + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMetrics = async () => { + try { + const [funnelRes, engRes] = await Promise.all([ + api.get('/api/analytics/funnel'), + api.get('/api/analytics/engagement') + ]); + + setMetrics({ + funnel: funnelRes.data?.data, + engagement: engRes.data?.data + }); + } catch (err) { + console.error('Failed to load personal metrics', err); + } finally { + setLoading(false); + } + }; + + fetchMetrics(); + }, []); + + if (loading || !metrics) return null; + + const { funnel, engagement } = metrics; + + // Calculate funnel progress + const totalSteps = funnel?.steps?.length || 0; + const completedSteps = funnel?.steps?.filter(s => s.completed).length || 0; + const pct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + + return ( +
+
+ +

My Performance

+
+ + {/* Activation Progress */} +
+
+ Activation Status + {pct}% +
+
+
+
+
+ + {/* 30 Day Engagement */} + {engagement && ( +
+
+ 30-Day Activity +
+
+
+
API Calls
+
{engagement.apiCalls?.toLocaleString() || 0}
+
+
+
Mails Sent
+
{engagement.mailSent?.toLocaleString() || 0}
+
+
+
Storage
+
{engagement.storageUploads?.toLocaleString() || 0}
+
+
+
Webhooks
+
{engagement.webhooksFired?.toLocaleString() || 0}
+
+
+
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/index.css b/apps/web-dashboard/src/index.css index c039ef897..ceb76b945 100644 --- a/apps/web-dashboard/src/index.css +++ b/apps/web-dashboard/src/index.css @@ -1069,5 +1069,256 @@ select option { } } +/* ───────────────────────────────────────────── + ADMIN METRICS PAGE +───────────────────────────────────────────── */ +.admin-metrics-page { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.3s ease; +} + +.admin-metrics-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + gap: 1rem; +} + +.admin-metrics-title { + font-size: 1.4rem; + font-weight: 700; + color: var(--color-text-main); + display: flex; + align-items: center; + gap: 0.75rem; +} +.admin-badge { + font-size: 0.65rem; + font-weight: 700; + background: rgba(167, 139, 250, 0.15); + color: #a78bfa; + border: 1px solid rgba(167, 139, 250, 0.3); + border-radius: 4px; + padding: 3px 8px; + letter-spacing: 0.08em; +} +.admin-refresh-btn { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-bg-card); + color: var(--color-text-muted); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; +} +.admin-refresh-btn:hover:not(:disabled) { + color: var(--color-text-main); + border-color: var(--color-border-hover); +} +.admin-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.admin-error-banner { + padding: 0.75rem 1rem; + border-radius: 6px; + background: rgba(234, 84, 85, 0.1); + border: 1px solid rgba(234, 84, 85, 0.2); + color: #f87171; + margin-bottom: 1.5rem; + font-size: 0.875rem; +} + +.admin-section { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.admin-section-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text-main); + margin-bottom: 1.25rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.admin-section-desc { + font-size: 0.8rem; + color: var(--color-text-muted); + margin-bottom: 1rem; +} + +/* Stat Cards Grid */ +.admin-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; +} + +.admin-stat-card { + background: var(--color-bg-input); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.admin-stat-label { + font-size: 0.7rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.admin-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-main); + line-height: 1; +} + +.admin-stat-sub { + font-size: 0.7rem; + color: var(--color-text-muted); +} + +/* Funnel Bars */ +.admin-funnel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.admin-funnel-row { + display: grid; + grid-template-columns: 160px 1fr 140px; + align-items: center; + gap: 1rem; +} + +.admin-funnel-label { + font-size: 0.8rem; + color: var(--color-text-muted); + font-weight: 500; +} + +.admin-funnel-track { + height: 8px; + background: var(--color-bg-input); + border-radius: 4px; + overflow: hidden; +} + +.admin-funnel-fill { + height: 100%; + background: linear-gradient(90deg, #3ecf8e, #818cf8); + border-radius: 4px; + transition: width 0.6s ease; + min-width: 2px; +} + +.admin-funnel-count { + font-size: 0.8rem; + color: var(--color-text-main); + font-weight: 600; + text-align: right; +} + +.admin-funnel-pct { + color: var(--color-text-muted); + font-weight: 400; +} + +/* Cohort Controls */ +.admin-cohort-controls { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.admin-cohort-controls label { + font-size: 0.8rem; + color: var(--color-text-muted); + font-weight: 500; +} + +.admin-month-input { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-bg-input); + color: var(--color-text-main); + font-size: 0.8rem; + outline: none; + transition: border-color 0.2s; +} +.admin-month-input:focus { border-color: var(--color-primary); } + +/* Tables */ +.admin-table-wrapper { + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--color-border); +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.admin-table th { + padding: 0.6rem 1rem; + text-align: left; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--color-bg-input); + border-bottom: 1px solid var(--color-border); +} + +.admin-table td { + padding: 0.75rem 1rem; + color: var(--color-text-main); + border-bottom: 1px solid var(--color-border); +} + +.admin-table tr:last-child td { border-bottom: none; } +.admin-table tr:hover td { background: var(--color-surface-hover); } + +.admin-table-rank { + color: var(--color-text-muted); + font-weight: 700; +} + +/* Churn badge */ +.admin-churn-badge { + font-size: 0.65rem; + padding: 2px 8px; + border-radius: 20px; + background: rgba(248, 113, 113, 0.1); + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.2); + font-weight: 600; +} + +.admin-empty { + font-size: 0.8rem; + color: var(--color-text-muted); + padding: 0.5rem 0; +} diff --git a/apps/web-dashboard/src/pages/AdminMetrics.jsx b/apps/web-dashboard/src/pages/AdminMetrics.jsx new file mode 100644 index 000000000..443718fef --- /dev/null +++ b/apps/web-dashboard/src/pages/AdminMetrics.jsx @@ -0,0 +1,282 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { API_URL } from '../config'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const fetchAdmin = async (path) => { + const res = await fetch(`${API_URL}/api/admin/metrics/${path}`, { + credentials: 'include', + }); + const json = await res.json(); + if (!res.ok || !json.success) throw new Error(json.message || 'Failed to load'); + return json.data; +}; + +// ─── Sub-components ───────────────────────────────────────────────────────── + +function StatCard({ label, value, sub, accent }) { + return ( +
+ {label} + + {value ?? '—'} + + {sub && {sub}} +
+ ); +} + +function FunnelBar({ step, count, max }) { + const pct = max > 0 ? Math.round((count / max) * 100) : 0; + const labels = { + signup_completed: 'Signed Up', + email_verified: 'Email Verified', + project_created: 'Project Created', + collection_created: 'Collection Created', + first_api_success: 'First API Success', + }; + return ( +
+ {labels[step] ?? step} +
+
+
+ + {count.toLocaleString()} ({pct}%) + +
+ ); +} + +// ─── Main Page ─────────────────────────────────────────────────────────────── + +export default function AdminMetrics() { + const navigate = useNavigate(); + const [overview, setOverview] = useState(null); + const [funnel, setFunnel] = useState(null); + const [featureUsage, setFeatureUsage] = useState(null); + const [reliability, setReliability] = useState(null); + const [topProjects, setTopProjects] = useState(null); + const [churn, setChurn] = useState(null); + const [cohorts, setCohorts] = useState(null); + const [cohortMonth, setCohortMonth] = useState(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [ov, fn, fu, rl, tp, ch] = await Promise.all([ + fetchAdmin('overview'), + fetchAdmin('activation-funnel'), + fetchAdmin('feature-usage'), + fetchAdmin('reliability'), + fetchAdmin('top-projects'), + fetchAdmin('churn-signals'), + ]); + setOverview(ov); + setFunnel(fn); + setFeatureUsage(fu); + setReliability(rl); + setTopProjects(tp); + setChurn(ch); + } catch (e) { + if (e.message?.includes('Admin')) navigate('/dashboard'); + else setError(e.message); + } finally { + setLoading(false); + } + }, [navigate]); + + const loadCohort = useCallback(async () => { + try { + const data = await fetchAdmin(`cohorts?month=${cohortMonth}`); + setCohorts(data); + } catch { + setCohorts(null); + } + }, [cohortMonth]); + + useEffect(() => { queueMicrotask(() => load()); }, [load]); + useEffect(() => { queueMicrotask(() => loadCohort()); }, [loadCohort]); + + const funnelMax = funnel?.steps?.[0]?.uniqueDevs || 1; + + return ( +
+
+

+ ADMIN + Platform Metrics +

+ +
+ + {error &&
{error}
} + + {/* ── Overview ── */} + {overview && ( +
+

Overview

+
+ + + + + +
+
+ )} + + {/* ── Activation Funnel ── */} + {funnel && ( +
+

Activation Funnel (All-time)

+
+ {funnel.steps.map((s) => ( + + ))} +
+
+ )} + + {/* ── Retention Cohorts ── */} +
+

Retention Cohorts

+
+ + setCohortMonth(e.target.value)} + className="admin-month-input" + /> +
+ {cohorts ? ( +
+ + + + +
+ ) : ( +

No cohort data for {cohortMonth}

+ )} +
+ + {/* ── Feature Usage ── */} + {featureUsage && ( +
+

Feature Usage (Last 30 Days)

+
+ + + + + +
+
+ )} + + {/* ── Reliability ── */} + {reliability && ( +
+

Reliability (Last 24 Hours)

+
+ + 5 ? '#f87171' : '#4ade80'} + /> + + + +
+
+ )} + + {/* ── Top Projects ── */} + {topProjects && topProjects.projects.length > 0 && ( +
+

Top Projects (7 Days)

+
+ + + + + + + + + + {topProjects.projects.map((p, i) => ( + + + + + + ))} + +
#ProjectAPI Calls
{i + 1}{p.projectName || p.projectId}{p.callCount?.toLocaleString()}
+
+
+ )} + + {/* ── Churn Signals ── */} + {churn && ( +
+

+ Churn Signals + {churn.churnSignals} projects +

+

+ Projects active 14–30 days ago that have made zero API calls in the last 14 days. +

+ {churn.projects.length > 0 ? ( +
+ + + + + + + + + + {churn.projects.map((p) => ( + + + + + + ))} + +
ProjectOwner EmailCreated
{p.name}{p.owner?.email || 'Unknown'}{new Date(p.createdAt).toLocaleDateString()}
+
+ ) : ( +

No churn signals detected 🎉

+ )} +
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/pages/Dashboard.jsx b/apps/web-dashboard/src/pages/Dashboard.jsx index 0c3020526..e1f708914 100644 --- a/apps/web-dashboard/src/pages/Dashboard.jsx +++ b/apps/web-dashboard/src/pages/Dashboard.jsx @@ -17,6 +17,7 @@ import SkeletonLoader from '../components/Dashboard/SkeletonLoader'; import RecentActivityItem from '../components/Dashboard/RecentActivityItem'; import UsageQuota from '../components/Dashboard/UsageQuota'; import OnboardingChecklist from '../components/Onboarding/OnboardingChecklist'; +import DeveloperMetrics from '../components/Dashboard/DeveloperMetrics'; import DocLinks from '../components/Dashboard/DocLinks'; export default function Dashboard() { @@ -195,6 +196,9 @@ export default function Dashboard() { + {/* 1.5 My Performance (Per-Dev Analytics) */} + + {/* 2. Onboarding (Helpful Context) */} diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 8292d58c0..3043699fd 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -24,6 +24,8 @@ const Webhook = require("./models/Webhook"); const WebhookDelivery = require("./models/WebhookDelivery"); const ProRequest = require("./models/ProRequest"); const ApiAnalytics = require("./models/ApiAnalytics"); +const PlatformEvent = require("./models/PlatformEvent"); +const DeveloperActivity = require("./models/DeveloperActivity"); // Queues const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue"); @@ -35,6 +37,16 @@ const { initWebhookWorker, generateSignature, } = require("./queues/webhookQueue"); +const { + activityRollupQueue, + scheduleActivityRollup, + initActivityRollupWorker, +} = require("./queues/activityRollupQueue"); +const { + reliabilityAlertQueue, + scheduleReliabilityAlert, + initReliabilityAlertWorker, +} = require("./queues/reliabilityAlertQueue"); // Middleware const checkAuthEnabled = require('./middleware/checkAuthEnabled') @@ -179,4 +191,12 @@ module.exports = { getPresignedUploadUrl, verifyUploadedFile, ApiAnalytics, + PlatformEvent, + DeveloperActivity, + activityRollupQueue, + scheduleActivityRollup, + initActivityRollupWorker, + reliabilityAlertQueue, + scheduleReliabilityAlert, + initReliabilityAlertWorker, }; diff --git a/packages/common/src/models/DeveloperActivity.js b/packages/common/src/models/DeveloperActivity.js new file mode 100644 index 000000000..f13882989 --- /dev/null +++ b/packages/common/src/models/DeveloperActivity.js @@ -0,0 +1,39 @@ +const mongoose = require('mongoose'); + +/** + * DeveloperActivity — daily activity rollup per developer. + * + * Written by the `rollupActivity` BullMQ cron job (Phase 2), + * not per-request. Used for D1/D7/D30 retention and engagement + * feature-usage queries. + */ +const developerActivitySchema = new mongoose.Schema( + { + developerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Developer', + required: true, + }, + // Midnight UTC for the day this record represents + date: { + type: Date, + required: true, + }, + // Projects that fired at least 1 API call that day + activeProjectIds: { + type: [mongoose.Schema.Types.ObjectId], + default: [], + }, + apiCallCount: { type: Number, default: 0 }, + mailSentCount: { type: Number, default: 0 }, + storageUploadsCount: { type: Number, default: 0 }, + webhookTriggeredCount: { type: Number, default: 0 }, + }, + { timestamps: false }, +); + +// One record per developer per day +developerActivitySchema.index({ developerId: 1, date: -1 }, { unique: true }); +developerActivitySchema.index({ date: -1 }); + +module.exports = mongoose.model('DeveloperActivity', developerActivitySchema); diff --git a/packages/common/src/models/PlatformEvent.js b/packages/common/src/models/PlatformEvent.js new file mode 100644 index 000000000..322f7d2a3 --- /dev/null +++ b/packages/common/src/models/PlatformEvent.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); + +/** + * PlatformEvent — single-collection store for all activation/funnel/AI events. + * + * Events emitted: + * signup_completed — after Developer is saved on /api/auth/register + * email_verified — after OTP verified and isVerified set to true + * project_created — after Project.save() in createProject + * collection_created — after project.save() in createCollection + * first_api_success — after first 2xx response logged for a project + * frontend_event — any event emitted from the dashboard UI (onboarding steps, key copy, etc.) + * ai_generation_started — Phase 4 + * ai_generation_completed — Phase 4 + * ai_schema_accepted — Phase 4 + * ai_schema_rejected — Phase 4 + */ +const platformEventSchema = new mongoose.Schema( + { + developerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Developer', + required: true, + index: true, + }, + projectId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Project', + default: null, + }, + // e.g. 'signup_completed', 'first_api_success', 'ai_schema_accepted' + event: { + type: String, + required: true, + index: true, + }, + // Free-form context — keep lean. No PII beyond developerId. + properties: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + timestamp: { + type: Date, + default: Date.now, + }, + }, + { timestamps: false }, +); + +// Compound indexes for fast funnel queries +platformEventSchema.index({ developerId: 1, event: 1, timestamp: -1 }); +platformEventSchema.index({ event: 1, timestamp: -1 }); + +// TTL: 2-year retention (730 days) +platformEventSchema.index( + { timestamp: 1 }, + { expireAfterSeconds: 730 * 24 * 60 * 60 }, +); + +module.exports = mongoose.model('PlatformEvent', platformEventSchema); diff --git a/packages/common/src/models/index.js b/packages/common/src/models/index.js index dbadcfbd5..8d767e688 100644 --- a/packages/common/src/models/index.js +++ b/packages/common/src/models/index.js @@ -1 +1,6 @@ -module.exports.ApiAnalytics = require('./ApiAnalytics'); \ No newline at end of file +module.exports.ApiAnalytics = require('./ApiAnalytics'); +module.exports.Developer = require('./Developer'); +module.exports.Project = require('./Project'); +module.exports.Log = require('./Log'); +module.exports.PlatformEvent = require('./PlatformEvent'); +module.exports.DeveloperActivity = require('./DeveloperActivity'); \ No newline at end of file diff --git a/packages/common/src/queues/activityRollupQueue.js b/packages/common/src/queues/activityRollupQueue.js new file mode 100644 index 000000000..eea5dc65e --- /dev/null +++ b/packages/common/src/queues/activityRollupQueue.js @@ -0,0 +1,161 @@ +const { Queue, Worker } = require('bullmq'); +const connection = require('../config/redis'); +const mongoose = require('mongoose'); + +const QUEUE_NAME = 'activity-rollup-queue'; + +const activityRollupQueue = new Queue(QUEUE_NAME, { connection }); + +/** + * Schedule the daily rollup cron if not already scheduled. + * Runs at 00:05 UTC every day. + * + * Call once during app startup (after DB connect). + */ +async function scheduleActivityRollup() { + // Remove any stale repeatable job first to avoid duplicate schedules + const existing = await activityRollupQueue.getRepeatableJobs(); + for (const job of existing) { + if (job.name === 'daily-rollup') { + await activityRollupQueue.removeRepeatableByKey(job.key); + } + } + + await activityRollupQueue.add( + 'daily-rollup', + {}, + { + repeat: { cron: '5 0 * * *' }, // 00:05 UTC daily + removeOnComplete: true, + removeOnFail: { count: 10 }, + }, + ); + console.log('[ActivityRollup] Daily cron scheduled (00:05 UTC)'); +} + +/** + * Compute midnight UTC for "yesterday" + */ +function getYesterdayMidnightUtc() { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - 1); + return d; +} + +/** + * Run the rollup for a given day (defaults to yesterday UTC). + * + * Algorithm: + * 1. Derive the Log collection (all projects) + * 2. Group logs by projectId → count API calls, mail, storage, webhooks + * 3. Resolve project owner for each projectId + * 4. Upsert one DeveloperActivity per developer per day + */ +async function runRollup(targetDate) { + const { Log, Project, DeveloperActivity } = require('../models'); + + const dayStart = targetDate || getYesterdayMidnightUtc(); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + console.log(`[ActivityRollup] Running for ${dayStart.toISOString()}`); + + // 1. Aggregate logs by project for the day + const logAgg = await Log.aggregate([ + { $match: { timestamp: { $gte: dayStart, $lt: dayEnd } } }, + { + $group: { + _id: '$projectId', + apiCallCount: { $sum: 1 }, + mailCount: { + $sum: { $cond: [{ $regexMatch: { input: '$path', regex: /\/api\/mail/ } }, 1, 0] }, + }, + storageCount: { + $sum: { $cond: [{ $regexMatch: { input: '$path', regex: /\/api\/storage/ } }, 1, 0] }, + }, + }, + }, + ]); + + if (logAgg.length === 0) { + console.log('[ActivityRollup] No activity for this day.'); + return; + } + + // 2. Batch-resolve project owners + const projectIds = logAgg.map((r) => r._id).filter(Boolean); + const projects = await Project.find({ _id: { $in: projectIds } }) + .select('owner') + .lean(); + + const ownerMap = {}; + for (const p of projects) { + ownerMap[p._id.toString()] = p.owner; + } + + // 3. Group by developer + const devMap = {}; + for (const row of logAgg) { + if (!row._id) continue; + const ownerId = ownerMap[row._id.toString()]; + if (!ownerId) continue; + const key = ownerId.toString(); + if (!devMap[key]) { + devMap[key] = { + developerId: ownerId, + activeProjectIds: [], + apiCallCount: 0, + mailSentCount: 0, + storageUploadsCount: 0, + webhookTriggeredCount: 0, + }; + } + devMap[key].activeProjectIds.push(row._id); + devMap[key].apiCallCount += row.apiCallCount; + devMap[key].mailSentCount += row.mailCount; + devMap[key].storageUploadsCount += row.storageCount; + } + + // 4. Upsert one record per developer + const ops = Object.values(devMap).map((d) => ({ + updateOne: { + filter: { developerId: d.developerId, date: dayStart }, + update: { $set: d }, + upsert: true, + }, + })); + + if (ops.length > 0) { + await DeveloperActivity.bulkWrite(ops); + console.log(`[ActivityRollup] Upserted ${ops.length} developer activity records.`); + } +} + +/** + * Initialize the BullMQ worker that processes rollup jobs. + * Call once during app startup. + */ +function initActivityRollupWorker() { + const worker = new Worker( + QUEUE_NAME, + async () => { + await runRollup(); + }, + { connection, concurrency: 1 }, + ); + + worker.on('completed', () => console.log('[ActivityRollup] Rollup job completed')); + worker.on('failed', (job, err) => + console.error('[ActivityRollup] Rollup job failed:', err.message), + ); + + console.log('[ActivityRollup] Worker initialized'); + return worker; +} + +module.exports = { + activityRollupQueue, + scheduleActivityRollup, + initActivityRollupWorker, + runRollup, // exported for manual / test runs +}; diff --git a/packages/common/src/queues/reliabilityAlertQueue.js b/packages/common/src/queues/reliabilityAlertQueue.js new file mode 100644 index 000000000..79d895622 --- /dev/null +++ b/packages/common/src/queues/reliabilityAlertQueue.js @@ -0,0 +1,139 @@ +const { Queue, Worker } = require('bullmq'); +const connection = require('../config/redis'); + +const QUEUE_NAME = 'reliability-alert-queue'; + +const reliabilityAlertQueue = new Queue(QUEUE_NAME, { connection }); + +/** + * Schedule the reliability alert cron. + * Runs every 5 minutes. + */ +async function scheduleReliabilityAlert() { + const existing = await reliabilityAlertQueue.getRepeatableJobs(); + for (const job of existing) { + if (job.name === 'reliability-check') { + await reliabilityAlertQueue.removeRepeatableByKey(job.key); + } + } + + await reliabilityAlertQueue.add( + 'reliability-check', + {}, + { + repeat: { pattern: '*/5 * * * *' }, // Every 5 minutes + removeOnComplete: true, + removeOnFail: { count: 10 }, + }, + ); + console.log('[ReliabilityAlert] Cron scheduled (every 5 mins)'); +} + +/** + * Run the reliability check. + * Looks at the last 15 minutes of ApiAnalytics. + * If a project has >50 total requests and >5% error rate (5xx or 4xx depending on preference, we'll use >= 500 for true platform errors), + * it writes a PlatformEvent 'reliability_spike'. + */ +async function runReliabilityCheck() { + const { ApiAnalytics, Project, PlatformEvent } = require('../models'); + + const now = new Date(); + const fifteenMinsAgo = new Date(now.getTime() - 15 * 60 * 1000); + + // Aggregate API calls by project for the last 15 mins + const agg = await ApiAnalytics.aggregate([ + { $match: { timestamp: { $gte: fifteenMinsAgo } } }, + { + $group: { + _id: '$projectId', + totalRequests: { $sum: 1 }, + errors: { $sum: { $cond: [{ $gte: ['$statusCode', 500] }, 1, 0] } }, + }, + }, + ]); + + if (agg.length === 0) return; + + const spikes = []; + + for (const row of agg) { + // Threshold: at least 20 requests in 15 mins to care about a % spike + if (row.totalRequests >= 20) { + const errorRate = row.errors / row.totalRequests; + if (errorRate >= 0.05) { + spikes.push({ + projectId: row._id, + totalRequests: row.totalRequests, + errors: row.errors, + errorRate: Math.round(errorRate * 100), + }); + } + } + } + + if (spikes.length === 0) return; + + // Resolve project owners + const projectIds = spikes.map((s) => s.projectId).filter(Boolean); + const projects = await Project.find({ _id: { $in: projectIds } }) + .select('owner') + .lean(); + + const ownerMap = {}; + for (const p of projects) { + ownerMap[p._id.toString()] = p.owner; + } + + // Create PlatformEvents + const eventsToInsert = []; + for (const spike of spikes) { + const ownerId = ownerMap[spike.projectId.toString()]; + if (!ownerId) continue; + + eventsToInsert.push({ + developerId: ownerId, + projectId: spike.projectId, + event: 'reliability_spike', + properties: { + window: '15m', + totalRequests: spike.totalRequests, + errors: spike.errors, + errorRatePct: spike.errorRate, + }, + timestamp: now, + }); + } + + if (eventsToInsert.length > 0) { + await PlatformEvent.insertMany(eventsToInsert); + console.log(`[ReliabilityAlert] Detected ${eventsToInsert.length} spikes, recorded PlatformEvents.`); + } +} + +/** + * Initialize the worker. + */ +function initReliabilityAlertWorker() { + const worker = new Worker( + QUEUE_NAME, + async () => { + await runReliabilityCheck(); + }, + { connection, concurrency: 1 }, + ); + + worker.on('failed', (job, err) => + console.error('[ReliabilityAlert] Job failed:', err.message), + ); + + console.log('[ReliabilityAlert] Worker initialized'); + return worker; +} + +module.exports = { + reliabilityAlertQueue, + scheduleReliabilityAlert, + initReliabilityAlertWorker, + runReliabilityCheck, +}; From 9ff6e73c4efe3f8f02117176ab949f8d6c8e5291 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:36:37 +0000 Subject: [PATCH 2/4] fix: address PR review feedback for analytics metrics stack Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/97646711-efe2-4fe4-a00f-1a81c8dfd698 Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com> --- .../controllers/admin.metrics.controller.js | 113 ++++++++++++------ apps/dashboard-api/src/routes/events.js | 3 +- apps/public-api/src/app.js | 89 +++++++------- apps/public-api/src/middlewares/api_usage.js | 19 ++- apps/public-api/src/utils/emitEvent.js | 2 +- .../components/Dashboard/DeveloperMetrics.jsx | 10 +- .../common/src/queues/activityRollupQueue.js | 17 +-- .../src/queues/reliabilityAlertQueue.js | 2 +- 8 files changed, 156 insertions(+), 99 deletions(-) diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js index b740f6aa3..2801f5dd3 100644 --- a/apps/dashboard-api/src/controllers/admin.metrics.controller.js +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -1,5 +1,4 @@ -const mongoose = require('mongoose'); -const { Developer, Project, Log, PlatformEvent, DeveloperActivity } = require('@urbackend/common'); +const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity } = require('@urbackend/common'); /** * Guard: only callable by the platform admin. @@ -34,9 +33,9 @@ module.exports.getOverview = async (req, res) => { Developer.countDocuments(), Developer.countDocuments({ isVerified: true }), Project.countDocuments(), - Log.countDocuments(), - Log.distinct('projectId', { - status: { $gte: 200, $lt: 300 }, + ApiAnalytics.countDocuments(), + ApiAnalytics.distinct('projectId', { + statusCode: { $gte: 200, $lt: 300 }, timestamp: { $gte: sevenDaysAgo }, }), ]); @@ -76,14 +75,19 @@ module.exports.getActivationFunnel = async (req, res) => { { $match: { event: { $in: FUNNEL_STEPS } } }, { $group: { - _id: '$event', - uniqueDevs: { $addToSet: '$developerId' }, + _id: { event: '$event', developerId: '$developerId' }, + }, + }, + { + $group: { + _id: '$_id.event', + count: { $sum: 1 }, }, }, { $project: { event: '$_id', - count: { $size: '$uniqueDevs' }, + count: 1, _id: 0, }, }, @@ -127,12 +131,22 @@ module.exports.getCohorts = async (req, res) => { const cohortEnd = new Date(Date.UTC(year, mo, 1)); // Developers who signed up in this cohort month - const signups = await PlatformEvent.find({ - event: 'signup_completed', - timestamp: { $gte: cohortStart, $lt: cohortEnd }, - }) - .select('developerId timestamp') - .lean(); + const signups = await PlatformEvent.aggregate([ + { + $match: { + event: 'signup_completed', + timestamp: { $gte: cohortStart, $lt: cohortEnd }, + developerId: { $ne: null }, + }, + }, + { $sort: { timestamp: 1 } }, + { + $group: { + _id: '$developerId', + signupTimestamp: { $first: '$timestamp' }, + }, + }, + ]); const cohortSize = signups.length; if (cohortSize === 0) { @@ -143,33 +157,58 @@ module.exports.getCohorts = async (req, res) => { }); } - // For each developer, check if active on D+1, D+7, D+30 - const checkDayRetention = async (daysAfter) => { + const DAY_MS = 24 * 60 * 60 * 1000; + const toUtcDay = (date) => { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + return d; + }; + const toUtcDayKey = (date) => toUtcDay(date).toISOString(); + + const targetOffsets = [1, 7, 30]; + const targetKeySets = { + 1: new Set(), + 7: new Set(), + 30: new Set(), + }; + + let minTarget = null; + let maxTarget = null; + + for (const signup of signups) { + const developerKey = signup._id.toString(); + const signupDay = toUtcDay(signup.signupTimestamp); + for (const offset of targetOffsets) { + const target = new Date(signupDay.getTime() + offset * DAY_MS); + const targetKey = `${developerKey}:${toUtcDayKey(target)}`; + targetKeySets[offset].add(targetKey); + if (!minTarget || target < minTarget) minTarget = target; + if (!maxTarget || target > maxTarget) maxTarget = target; + } + } + + const activities = await DeveloperActivity.find({ + developerId: { $in: signups.map((s) => s._id) }, + date: { $gte: minTarget, $lt: new Date(maxTarget.getTime() + DAY_MS) }, + }) + .select('developerId date') + .lean(); + + const activeKeySet = new Set( + activities.map((activity) => `${activity.developerId.toString()}:${toUtcDayKey(activity.date)}`), + ); + + const countRetained = (offset) => { let retained = 0; - await Promise.all( - signups.map(async (s) => { - const base = new Date(s.timestamp); - base.setUTCHours(0, 0, 0, 0); - const target = new Date(base); - target.setUTCDate(target.getUTCDate() + daysAfter); - const next = new Date(target); - next.setUTCDate(next.getUTCDate() + 1); - - const exists = await DeveloperActivity.findOne({ - developerId: s.developerId, - date: { $gte: target, $lt: next }, - }).lean(); - if (exists) retained++; - }), - ); + for (const key of targetKeySets[offset]) { + if (activeKeySet.has(key)) retained++; + } return retained; }; - const [d1, d7, d30] = await Promise.all([ - checkDayRetention(1), - checkDayRetention(7), - checkDayRetention(30), - ]); + const d1 = countRetained(1); + const d7 = countRetained(7); + const d30 = countRetained(30); return res.json({ success: true, diff --git a/apps/dashboard-api/src/routes/events.js b/apps/dashboard-api/src/routes/events.js index 5d25b02d4..2a41fa1f7 100644 --- a/apps/dashboard-api/src/routes/events.js +++ b/apps/dashboard-api/src/routes/events.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { track } = require('../controllers/events.controller'); +const authMiddleware = require('../middlewares/authMiddleware'); // Middleware: dashboard JWT auth const { verifyEmail } = require('@urbackend/common'); @@ -10,6 +11,6 @@ const { verifyEmail } = require('@urbackend/common'); * Receives frontend analytics events (onboarding steps, key copy, etc.) * Requires a valid dashboard session. */ -router.post('/track', verifyEmail, track); +router.post('/track', authMiddleware, verifyEmail, track); module.exports = router; diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 34821d897..fc67398f0 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -26,22 +26,6 @@ const {initAuthEmailWorker, initPublicEmailWorker} = require('@urbackend/common' const {initActivityRollupWorker, scheduleActivityRollup} = require('@urbackend/common'); const {initReliabilityAlertWorker, scheduleReliabilityAlert} = require('@urbackend/common'); -// Initialize webhook worker -if (process.env.NODE_ENV !== 'test') { - initWebhookWorker(); - initAuthEmailWorker(); - initPublicEmailWorker(); - initActivityRollupWorker(); - scheduleActivityRollup().catch((err) => - console.error('[ActivityRollup] Failed to schedule cron:', err.message) - ); - initReliabilityAlertWorker(); - scheduleReliabilityAlert().catch((err) => - console.error('[ReliabilityAlert] Failed to schedule cron:', err.message) - ); -} - - app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(standardizeApiResponse); @@ -140,38 +124,59 @@ if (process.env.NODE_ENV !== 'test') { const { connectDB } = require('@urbackend/common'); - // Start DB & Server - connectDB(); + const startWorkers = () => { + initWebhookWorker(); + initAuthEmailWorker(); + initPublicEmailWorker(); + initActivityRollupWorker(); + scheduleActivityRollup().catch((err) => + console.error('[ActivityRollup] Failed to schedule cron:', err.message) + ); + initReliabilityAlertWorker(); + scheduleReliabilityAlert().catch((err) => + console.error('[ReliabilityAlert] Failed to schedule cron:', err.message) + ); + }; + + const bootstrap = async () => { + await connectDB(); + startWorkers(); - const server = app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - }); + const server = app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); - // SHUTDOWN - const gracefulShutdown = async () => { - console.log('🛑 SIGTERM/SIGINT received. Shutting down gracefully...'); - - server.close(async () => { - console.log('✅ HTTP server closed.'); - try { - await mongoose.connection.close(false); - console.log('✅ MongoDB connection closed.'); - process.exit(0); - } catch (err) { - console.error('❌ Error closing MongoDB connection:', err); + // SHUTDOWN + const gracefulShutdown = async () => { + console.log('🛑 SIGTERM/SIGINT received. Shutting down gracefully...'); + + server.close(async () => { + console.log('✅ HTTP server closed.'); + try { + await mongoose.connection.close(false); + console.log('✅ MongoDB connection closed.'); + process.exit(0); + } catch (err) { + console.error('❌ Error closing MongoDB connection:', err); + process.exit(1); + } + }); + + // Force close after 10s + setTimeout(() => { + console.error('Force shutting down...'); process.exit(1); - } - }); + }, 10000); + }; - // Force close after 10s - setTimeout(() => { - console.error('Force shutting down...'); - process.exit(1); - }, 10000); + process.on('SIGTERM', gracefulShutdown); + process.on('SIGINT', gracefulShutdown); }; - process.on('SIGTERM', gracefulShutdown); - process.on('SIGINT', gracefulShutdown); + bootstrap().catch((err) => { + console.error('❌ Failed to bootstrap public-api:', err); + process.exit(1); + }); } // Export for Testing diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 763ba2b08..e7a084bbd 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -1,6 +1,7 @@ const rateLimit = require('express-rate-limit'); const { Log, redis, ApiAnalytics } = require('@urbackend/common'); const { getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('../utils/usageCounter'); +const FIRST_API_SUCCESS_FLAG_TTL_SECONDS = 2 * 365 * 24 * 60 * 60; // Rate Limiter const limiter = rateLimit({ @@ -20,11 +21,13 @@ const logger = (req, res, next) => { // Capture start time for response time measurement const startHr = process.hrtime(); - // Check for Data, Storage, AND UserAuth routes + // Check for routes included in platform analytics if ( req.originalUrl.startsWith('/api/data') || req.originalUrl.startsWith('/api/storage') || - req.originalUrl.startsWith('/api/userAuth') + req.originalUrl.startsWith('/api/userAuth') || + req.originalUrl.startsWith('/api/mail') || + req.originalUrl.startsWith('/api/schemas') ) { res.on('finish', async () => { // --- Existing logging and usage counter --- @@ -60,7 +63,7 @@ const logger = (req, res, next) => { try { await ApiAnalytics.create({ projectId: req.project._id, - endpoint: req.route?.path || req.originalUrl, + endpoint: req.originalUrl, method: req.method, statusCode: res.statusCode, responseTimeMs: parseFloat(responseTimeMs), @@ -78,7 +81,13 @@ const logger = (req, res, next) => { setImmediate(async () => { try { const flagKey = `project:activation:first_api_success:${req.project._id}`; - const isFirst = await redis.set(flagKey, '1', 'NX'); + const isFirst = await redis.set( + flagKey, + '1', + 'EX', + FIRST_API_SUCCESS_FLAG_TTL_SECONDS, + 'NX' + ); if (isFirst) { const { Project, PlatformEvent } = require('@urbackend/common'); const proj = await Project.findById(req.project._id).select('owner').lean(); @@ -107,4 +116,4 @@ const logger = (req, res, next) => { next(); }; -module.exports = { limiter, logger }; \ No newline at end of file +module.exports = { limiter, logger }; diff --git a/apps/public-api/src/utils/emitEvent.js b/apps/public-api/src/utils/emitEvent.js index 2ebdd0efb..c7cda2501 100644 --- a/apps/public-api/src/utils/emitEvent.js +++ b/apps/public-api/src/utils/emitEvent.js @@ -1,4 +1,4 @@ -const { PlatformEvent, Project } = require('@urbackend/common'); +const { PlatformEvent } = require('@urbackend/common'); /** * emitEvent (public-api variant) — fire-and-forget PlatformEvent writer. diff --git a/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx b/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx index 45e58c3b9..0a9304369 100644 --- a/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx +++ b/apps/web-dashboard/src/components/Dashboard/DeveloperMetrics.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import api from '../../utils/api'; -import { Activity, BarChart2 } from 'lucide-react'; +import { BarChart2 } from 'lucide-react'; export default function DeveloperMetrics() { const [metrics, setMetrics] = useState(null); @@ -70,19 +70,19 @@ export default function DeveloperMetrics() {
API Calls
-
{engagement.apiCalls?.toLocaleString() || 0}
+
{engagement.totalApiCalls?.toLocaleString() || 0}
Mails Sent
-
{engagement.mailSent?.toLocaleString() || 0}
+
{engagement.totalMailSent?.toLocaleString() || 0}
Storage
-
{engagement.storageUploads?.toLocaleString() || 0}
+
{engagement.totalStorageUploads?.toLocaleString() || 0}
Webhooks
-
{engagement.webhooksFired?.toLocaleString() || 0}
+
{engagement.totalWebhooksFired?.toLocaleString() || 0}
diff --git a/packages/common/src/queues/activityRollupQueue.js b/packages/common/src/queues/activityRollupQueue.js index eea5dc65e..3585c58cb 100644 --- a/packages/common/src/queues/activityRollupQueue.js +++ b/packages/common/src/queues/activityRollupQueue.js @@ -1,6 +1,5 @@ const { Queue, Worker } = require('bullmq'); const connection = require('../config/redis'); -const mongoose = require('mongoose'); const QUEUE_NAME = 'activity-rollup-queue'; @@ -25,7 +24,7 @@ async function scheduleActivityRollup() { 'daily-rollup', {}, { - repeat: { cron: '5 0 * * *' }, // 00:05 UTC daily + repeat: { pattern: '5 0 * * *' }, // 00:05 UTC daily removeOnComplete: true, removeOnFail: { count: 10 }, }, @@ -47,13 +46,13 @@ function getYesterdayMidnightUtc() { * Run the rollup for a given day (defaults to yesterday UTC). * * Algorithm: - * 1. Derive the Log collection (all projects) + * 1. Derive the ApiAnalytics collection (all projects) * 2. Group logs by projectId → count API calls, mail, storage, webhooks * 3. Resolve project owner for each projectId * 4. Upsert one DeveloperActivity per developer per day */ async function runRollup(targetDate) { - const { Log, Project, DeveloperActivity } = require('../models'); + const { ApiAnalytics, Project, DeveloperActivity } = require('../models'); const dayStart = targetDate || getYesterdayMidnightUtc(); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); @@ -61,17 +60,20 @@ async function runRollup(targetDate) { console.log(`[ActivityRollup] Running for ${dayStart.toISOString()}`); // 1. Aggregate logs by project for the day - const logAgg = await Log.aggregate([ + const logAgg = await ApiAnalytics.aggregate([ { $match: { timestamp: { $gte: dayStart, $lt: dayEnd } } }, { $group: { _id: '$projectId', apiCallCount: { $sum: 1 }, mailCount: { - $sum: { $cond: [{ $regexMatch: { input: '$path', regex: /\/api\/mail/ } }, 1, 0] }, + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/mail/ } }, 1, 0] }, }, storageCount: { - $sum: { $cond: [{ $regexMatch: { input: '$path', regex: /\/api\/storage/ } }, 1, 0] }, + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/storage/ } }, 1, 0] }, + }, + webhookCount: { + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/webhooks?/ } }, 1, 0] }, }, }, }, @@ -114,6 +116,7 @@ async function runRollup(targetDate) { devMap[key].apiCallCount += row.apiCallCount; devMap[key].mailSentCount += row.mailCount; devMap[key].storageUploadsCount += row.storageCount; + devMap[key].webhookTriggeredCount += row.webhookCount; } // 4. Upsert one record per developer diff --git a/packages/common/src/queues/reliabilityAlertQueue.js b/packages/common/src/queues/reliabilityAlertQueue.js index 79d895622..a9d021943 100644 --- a/packages/common/src/queues/reliabilityAlertQueue.js +++ b/packages/common/src/queues/reliabilityAlertQueue.js @@ -32,7 +32,7 @@ async function scheduleReliabilityAlert() { /** * Run the reliability check. * Looks at the last 15 minutes of ApiAnalytics. - * If a project has >50 total requests and >5% error rate (5xx or 4xx depending on preference, we'll use >= 500 for true platform errors), + * If a project has >=20 total requests and >5% error rate (using >= 500 for true platform errors), * it writes a PlatformEvent 'reliability_spike'. */ async function runReliabilityCheck() { From 115d03204b40c1077b1199e9293aa574af241106 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:38:15 +0000 Subject: [PATCH 3/4] fix: normalize analytics endpoint path logging Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/97646711-efe2-4fe4-a00f-1a81c8dfd698 Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com> --- apps/public-api/src/middlewares/api_usage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index e7a084bbd..c3b9469bd 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -63,7 +63,7 @@ const logger = (req, res, next) => { try { await ApiAnalytics.create({ projectId: req.project._id, - endpoint: req.originalUrl, + endpoint: req.originalUrl.split('?')[0], method: req.method, statusCode: res.statusCode, responseTimeMs: parseFloat(responseTimeMs), From ac4eef0a1e12d260524c40b36622148beba4e3ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:48:52 +0000 Subject: [PATCH 4/4] fix: harden analytics error responses per review Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/9df9708d-0389-45cb-97ef-0f88fa9147fc Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com> --- .../controllers/admin.metrics.controller.js | 21 ++++++++++++------- .../src/controllers/analytics.controller.js | 19 ++++++++++------- .../src/controllers/events.controller.js | 5 +++-- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js index 2801f5dd3..d467cbecd 100644 --- a/apps/dashboard-api/src/controllers/admin.metrics.controller.js +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -52,7 +52,8 @@ module.exports.getOverview = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getOverview error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -106,7 +107,8 @@ module.exports.getActivationFunnel = async (req, res) => { return res.json({ success: true, data: { steps }, message: '' }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getActivationFunnel error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -225,7 +227,8 @@ module.exports.getCohorts = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getCohorts error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -274,7 +277,8 @@ module.exports.getFeatureUsage = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getFeatureUsage error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -318,7 +322,8 @@ module.exports.getReliability = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getReliability error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -357,7 +362,8 @@ module.exports.getTopProjects = async (req, res) => { return res.json({ success: true, data: { projects: agg }, message: '' }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getTopProjects error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -398,6 +404,7 @@ module.exports.getChurnSignals = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[admin.metrics] getChurnSignals error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 8484499c5..c46eb8e86 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -84,7 +84,8 @@ module.exports.getGlobalStats = async (req, res) => { message: "" }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[analytics] getGlobalStats error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -114,7 +115,8 @@ module.exports.getRecentActivity = async (req, res) => { res.json(formattedLogs); } catch (err) { - res.status(500).json({ error: err.message }); + console.error('[analytics] getRecentActivity error:', err); + res.status(500).json({ error: 'Internal server error' }); } }; @@ -157,7 +159,8 @@ module.exports.getActivationFunnel = async (req, res) => { return res.json({ success: true, data: { steps }, message: '' }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[analytics] getActivationFunnel error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -211,7 +214,8 @@ module.exports.getRetention = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[analytics] getRetention error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -272,7 +276,8 @@ module.exports.getEngagement = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[analytics] getEngagement error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; @@ -316,7 +321,7 @@ module.exports.getNorthStar = async (req, res) => { message: '', }); } catch (err) { - res.status(500).json({ success: false, data: {}, message: err.message }); + console.error('[analytics] getNorthStar error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } }; - diff --git a/apps/dashboard-api/src/controllers/events.controller.js b/apps/dashboard-api/src/controllers/events.controller.js index a09753734..bf7d7c508 100644 --- a/apps/dashboard-api/src/controllers/events.controller.js +++ b/apps/dashboard-api/src/controllers/events.controller.js @@ -23,7 +23,7 @@ module.exports.track = async (req, res) => { const { event, properties = {}, projectId } = req.body; if (!event || typeof event !== 'string') { - return res.status(400).json({ success: false, message: 'event name is required' }); + return res.status(400).json({ success: false, data: {}, message: 'event name is required' }); } const normalizedEvent = event.trim().toLowerCase().replace(/\s+/g, '_'); @@ -31,6 +31,7 @@ module.exports.track = async (req, res) => { if (!ALLOWED_FRONTEND_EVENTS.has(normalizedEvent)) { return res.status(400).json({ success: false, + data: {}, message: `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`, }); } @@ -46,6 +47,6 @@ module.exports.track = async (req, res) => { return res.json({ success: true, data: {}, message: 'Event queued' }); } catch (err) { console.error('[events.controller] track error:', err); - return res.status(500).json({ success: false, message: 'Internal server error' }); + return res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); } };