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..d467cbecd --- /dev/null +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -0,0 +1,410 @@ +const { Developer, Project, Log, ApiAnalytics, 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(), + ApiAnalytics.countDocuments(), + ApiAnalytics.distinct('projectId', { + statusCode: { $gte: 200, $lt: 300 }, + timestamp: { $gte: sevenDaysAgo }, + }), + ]); + + return res.json({ + success: true, + data: { + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + activeProjectsLast7d: northStarProjects.length, + }, + message: '', + }); + } catch (err) { + console.error('[admin.metrics] getOverview error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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: '$event', developerId: '$developerId' }, + }, + }, + { + $group: { + _id: '$_id.event', + count: { $sum: 1 }, + }, + }, + { + $project: { + event: '$_id', + count: 1, + _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) { + console.error('[admin.metrics] getActivationFunnel error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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.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) { + return res.json({ + success: true, + data: { month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }, + message: '', + }); + } + + 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; + for (const key of targetKeySets[offset]) { + if (activeKeySet.has(key)) retained++; + } + return retained; + }; + + const d1 = countRetained(1); + const d7 = countRetained(7); + const d30 = countRetained(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) { + console.error('[admin.metrics] getCohorts error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[admin.metrics] getFeatureUsage error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[admin.metrics] getReliability error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[admin.metrics] getTopProjects error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + 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 3a7ca1dd1..c46eb8e86 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 { @@ -88,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' }); } }; @@ -118,6 +115,213 @@ 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' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[analytics] getActivationFunnel error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[analytics] getRetention error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + console.error('[analytics] getEngagement error:', err); + res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + } +}; + +// --------------------------------------------------------------------------- +// 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) { + 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/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..bf7d7c508 --- /dev/null +++ b/apps/dashboard-api/src/controllers/events.controller.js @@ -0,0 +1,52 @@ +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, data: {}, 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, + data: {}, + 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, data: {}, 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..2a41fa1f7 --- /dev/null +++ b/apps/dashboard-api/src/routes/events.js @@ -0,0 +1,16 @@ +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'); + +/** + * POST /api/events/track + * Receives frontend analytics events (onboarding steps, key copy, etc.) + * Requires a valid dashboard session. + */ +router.post('/track', authMiddleware, 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..fc67398f0 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -23,13 +23,8 @@ const {emailQueue} = require('@urbackend/common'); const {authEmailQueue} = require('@urbackend/common'); const {initWebhookWorker} = require('@urbackend/common'); const {initAuthEmailWorker, initPublicEmailWorker} = require('@urbackend/common'); - -// Initialize webhook worker -if (process.env.NODE_ENV !== 'test') { - initWebhookWorker(); - initAuthEmailWorker(); - initPublicEmailWorker(); -} +const {initActivityRollupWorker, scheduleActivityRollup} = require('@urbackend/common'); +const {initReliabilityAlertWorker, scheduleReliabilityAlert} = require('@urbackend/common'); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -129,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 server = app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - }); + const bootstrap = async () => { + await connectDB(); + startWorkers(); - // 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); - } + const server = app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); }); - // Force close after 10s - setTimeout(() => { - console.error('Force shutting down...'); - process.exit(1); - }, 10000); + // 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); + }; + + 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 657a1e636..c3b9469bd 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 --- @@ -51,17 +54,16 @@ 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({ projectId: req.project._id, - endpoint: req.route?.path || req.originalUrl, + endpoint: req.originalUrl.split('?')[0], method: req.method, statusCode: res.statusCode, responseTimeMs: parseFloat(responseTimeMs), @@ -71,10 +73,47 @@ 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', + '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(); + 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); + } + }); + } }); } 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 new file mode 100644 index 000000000..c7cda2501 --- /dev/null +++ b/apps/public-api/src/utils/emitEvent.js @@ -0,0 +1,25 @@ +const { PlatformEvent } = 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..0a9304369 --- /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 { 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.totalApiCalls?.toLocaleString() || 0}
+
+
+
Mails Sent
+
{engagement.totalMailSent?.toLocaleString() || 0}
+
+
+
Storage
+
{engagement.totalStorageUploads?.toLocaleString() || 0}
+
+
+
Webhooks
+
{engagement.totalWebhooksFired?.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..3585c58cb --- /dev/null +++ b/packages/common/src/queues/activityRollupQueue.js @@ -0,0 +1,164 @@ +const { Queue, Worker } = require('bullmq'); +const connection = require('../config/redis'); + +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: { pattern: '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 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 { ApiAnalytics, 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 ApiAnalytics.aggregate([ + { $match: { timestamp: { $gte: dayStart, $lt: dayEnd } } }, + { + $group: { + _id: '$projectId', + apiCallCount: { $sum: 1 }, + mailCount: { + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/mail/ } }, 1, 0] }, + }, + storageCount: { + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/storage/ } }, 1, 0] }, + }, + webhookCount: { + $sum: { $cond: [{ $regexMatch: { input: '$endpoint', regex: /\/api\/webhooks?/ } }, 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; + devMap[key].webhookTriggeredCount += row.webhookCount; + } + + // 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..a9d021943 --- /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 >=20 total requests and >5% error rate (using >= 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, +};