-
Notifications
You must be signed in to change notification settings - Fork 72
feat(analytics): add real-time active users tracking #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // controllers/analytics/activeUsersController.js | ||
| import logger from "../../config/logger.js"; | ||
| import activeUsersService from "../../services/analytics/activeUsersService.js"; | ||
|
|
||
| /** | ||
| * GET /api/analytics/active-users | ||
| * Return the current number of concurrent active users (unique users seen | ||
| * within the configured inactivity window). | ||
| */ | ||
| export const getActiveUsers = async (req, res) => { | ||
| try { | ||
| const activeUsers = await activeUsersService.getActiveUserCount(); | ||
| res.status(200).json({ | ||
| success: true, | ||
| activeUsers, | ||
| timeoutSeconds: activeUsersService.getTimeoutSeconds(), | ||
| }); | ||
| } catch (error) { | ||
| logger.error("Failed to retrieve active user count:", error); | ||
| res.status(500).json({ | ||
| success: false, | ||
| message: "Failed to retrieve active user count", | ||
| }); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // middlewares/analytics/activityTracker.js | ||
| // | ||
| // Records the authenticated user's activity on every request so real-time | ||
| // active-user counts reflect live usage. Mounted globally in app.js; it only | ||
| // does work when the request carries a valid Bearer token — the user id is | ||
| // decoded from the JWT (no database lookup) and the Redis write is | ||
| // fire-and-forget, so tracking can never slow down or break a request. | ||
|
|
||
| import jwt from "jsonwebtoken"; | ||
| import logger from "../../config/logger.js"; | ||
| import activeUsersService from "../../services/analytics/activeUsersService.js"; | ||
|
|
||
| const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
printf '%s\n' '--- activityTracker.js ---'
cat -n src/middlewares/analytics/activityTracker.js
printf '%s\n' '--- .env.example candidates ---'
fd -HI '^\.env(\.example)?$' . -t f -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}
printf '%s\n' '--- middleware references ---'
rg -n -C 3 'activityTracker|trackActivity' src testRepository: Deen-Bridge/dnb-backend Length of output: 16005 🤖 get_repo_knowledge executed:
Length of output: 1200 Security Misconfiguration (CWE-798): Use of Hard-coded Credentials Reachability: External · Exploitability: Moderate Remove the fallback JWT secret. If Require 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| export const trackActivity = (req, res, next) => { | ||
| const authorization = req.headers?.authorization || ""; | ||
| if (authorization.startsWith("Bearer ")) { | ||
| try { | ||
| const decoded = jwt.verify(authorization.slice(7), JWT_SECRET); | ||
| if (decoded?.userId) { | ||
| activeUsersService | ||
| .trackActivity({ userId: decoded.userId }) | ||
| .catch((err) => logger.warn("Activity tracking skipped:", err.message)); | ||
| } | ||
| } catch { | ||
| // Invalid/expired token — the route's own auth will reject the request; | ||
| // there is nothing meaningful to track here. | ||
| } | ||
| } | ||
| next(); | ||
| }; | ||
|
|
||
| export default trackActivity; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // routes/analytics/activeUsersRoutes.js | ||
| // | ||
| // Real-time platform usage endpoints. Mounted at /api/analytics in app.js. | ||
| import express from "express"; | ||
| import { protect } from "../../middlewares/authMiddleware.js"; | ||
| import { getActiveUsers } from "../../controllers/analytics/activeUsersController.js"; | ||
|
|
||
| const router = express.Router(); | ||
|
|
||
| // Current concurrent active user count (any authenticated user may read it). | ||
| router.get("/active-users", protect, getActiveUsers); | ||
|
|
||
| export default router; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // services/analytics/activeUsersService.js | ||
| // | ||
| // Real-time active-user tracking backed by a Redis sorted set. Each | ||
| // authenticated request bumps the user's "last seen" score; users whose score | ||
| // falls outside the (configurable) activity window are pruned, and the | ||
| // concurrent active-user count is simply the size of the set. | ||
| // | ||
| // Degrades gracefully: when Redis is unavailable every method becomes a no-op | ||
| // (tracking is skipped, the count is 0) so the platform keeps working without | ||
| // the analytics layer. | ||
|
|
||
| import { getRedisClient, isRedisReady } from "../../config/redis.js"; | ||
|
|
||
| const ACTIVE_USERS_KEY = "analytics:active-users"; | ||
| const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes | ||
|
|
||
| export class ActiveUsersService { | ||
| /** | ||
| * @param {object} [options] | ||
| * @param {import("redis").RedisClientType|null} [options.redis] - Optional | ||
| * injected client (used by tests). Defaults to the app's shared client. | ||
| * @param {number|null} [options.timeoutSeconds] - Inactivity timeout override. | ||
| */ | ||
| constructor({ redis = null, timeoutSeconds = null } = {}) { | ||
| this.redis = redis; | ||
| this.timeoutSeconds = timeoutSeconds; | ||
| } | ||
|
|
||
| /** | ||
| * How long (seconds) a user may stay idle before they stop counting as | ||
| * active. Reads ACTIVE_USER_TIMEOUT_SECONDS unless overridden (e.g. by a | ||
| * test or a caller that wants a different window). | ||
| */ | ||
| getTimeoutSeconds() { | ||
| return ( | ||
| this.timeoutSeconds || | ||
| parseInt(process.env.ACTIVE_USER_TIMEOUT_SECONDS || String(DEFAULT_TIMEOUT_SECONDS), 10) || | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Validate the configured timeout before use. Line 37 accepts negative values. For example, Accept only positive finite integer values. Use the default or fail configuration validation for invalid values. 🤖 Prompt for AI Agents |
||
| DEFAULT_TIMEOUT_SECONDS | ||
| ); | ||
| } | ||
|
|
||
| /** @returns {import("redis").RedisClientType|null} The Redis client in use. */ | ||
| _client() { | ||
| return this.redis || getRedisClient(); | ||
| } | ||
|
|
||
| /** @returns {boolean} Whether a usable Redis client is available. */ | ||
| _isReady() { | ||
| return this.redis ? true : isRedisReady(); | ||
| } | ||
|
|
||
| /** | ||
| * Record activity for a user (idempotent — one entry per user) and prune | ||
| * entries that have been idle longer than the timeout. | ||
| * | ||
| * @param {object} params | ||
| * @param {string|number} params.userId - The authenticated user's id. | ||
| * @returns {Promise<number>} 1 when tracked, 0 when Redis is unavailable. | ||
| */ | ||
| async trackActivity({ userId }) { | ||
| if (!userId) return 0; | ||
| if (!this._isReady()) return 0; | ||
|
|
||
| const client = this._client(); | ||
| const now = Date.now(); | ||
| const timeoutMs = this.getTimeoutSeconds() * 1000; | ||
|
|
||
| await client.zAdd(ACTIVE_USERS_KEY, [{ score: now, value: String(userId) }]); | ||
| await client.zRemRangeByScore(ACTIVE_USERS_KEY, 0, now - timeoutMs); | ||
|
Comment on lines
+68
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Handle Redis command failures inside the service. A Redis client can become unavailable after Catch Redis command failures in both methods. Return Also applies to: 86-87 🤖 Prompt for AI Agents |
||
| return 1; | ||
| } | ||
|
|
||
| /** | ||
| * Current number of concurrent active users (unique users seen within the | ||
| * activity window). | ||
| * | ||
| * @returns {Promise<number>} The count, or 0 when Redis is unavailable. | ||
| */ | ||
| async getActiveUserCount() { | ||
| if (!this._isReady()) return 0; | ||
|
|
||
| const client = this._client(); | ||
| const now = Date.now(); | ||
| const timeoutMs = this.getTimeoutSeconds() * 1000; | ||
|
|
||
| await client.zRemRangeByScore(ACTIVE_USERS_KEY, 0, now - timeoutMs); | ||
| return client.zCard(ACTIVE_USERS_KEY); | ||
| } | ||
|
|
||
| /** | ||
| * Test/dependency-injection seam: swap in a Redis-compatible client. | ||
| * | ||
| * @param {import("redis").RedisClientType|null} client - The client to use. | ||
| */ | ||
| setRedis(client) { | ||
| this.redis = client; | ||
| } | ||
|
|
||
| /** | ||
| * Override the inactivity timeout (used by tests to simulate expiry). | ||
| * | ||
| * @param {number} seconds - Timeout in seconds. | ||
| */ | ||
| setTimeoutSeconds(seconds) { | ||
| this.timeoutSeconds = seconds; | ||
| } | ||
| } | ||
|
|
||
| export const activeUsersService = new ActiveUsersService(); | ||
| export default activeUsersService; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import request from "supertest"; | ||
| import mongoose from "mongoose"; | ||
| import { MongoMemoryServer } from "mongodb-memory-server"; | ||
|
|
||
| import app from "../app.js"; | ||
| import User from "../src/models/User.js"; | ||
| import activeUsersService from "../src/services/analytics/activeUsersService.js"; | ||
| import { seedUserAndLogin } from "./helpers/testAuth.js"; | ||
|
|
||
| // Minimal Redis-compatible in-memory client covering exactly the commands the | ||
| // active-users service uses (zAdd / zRemRangeByScore / zCard). The real Redis | ||
| // connection is unavailable in the test environment, so this fake stands in at | ||
| // the client seam — everything above it (middleware -> service -> endpoint) is | ||
| // exercised for real. | ||
| const createFakeRedis = () => { | ||
| const store = new Map(); // member -> score | ||
| return { | ||
| _store: store, | ||
| async zAdd(_key, members) { | ||
| for (const member of members) store.set(member.value, member.score); | ||
| return members.length; | ||
| }, | ||
| async zRemRangeByScore(_key, min, max) { | ||
| let removed = 0; | ||
| for (const [member, score] of store) { | ||
| if (score >= min && score <= max) { | ||
| store.delete(member); | ||
| removed += 1; | ||
| } | ||
| } | ||
| return removed; | ||
| }, | ||
| async zCard() { | ||
| return store.size; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| describe("Real-time active users tracking (#243)", () => { | ||
| let mongoServer; | ||
| let readerToken; | ||
| let authorToken; | ||
| let fakeRedis; | ||
|
|
||
| beforeAll(async () => { | ||
| if (mongoose.connection.readyState !== 0) { | ||
| await mongoose.disconnect(); | ||
| } | ||
| mongoServer = await MongoMemoryServer.create(); | ||
| await mongoose.connect(mongoServer.getUri()); | ||
|
|
||
| const reader = await seedUserAndLogin(app, { | ||
| name: "Active Reader", | ||
| email: "active-reader@example.com", | ||
| }); | ||
| readerToken = reader.token; | ||
|
|
||
| const author = await seedUserAndLogin(app, { | ||
| name: "Active Author", | ||
| email: "active-author@example.com", | ||
| role: "mentor", | ||
| }); | ||
| authorToken = author.token; | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| if (mongoose.connection.readyState !== 0) { | ||
| await mongoose.disconnect(); | ||
| } | ||
| if (mongoServer) { | ||
| await mongoServer.stop(); | ||
| } | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| // NOTE: seeded users are intentionally kept — their login tokens from | ||
| // beforeAll must stay valid. Only the Redis state is reset per test. | ||
| fakeRedis = createFakeRedis(); | ||
| activeUsersService.setRedis(fakeRedis); | ||
| activeUsersService.setTimeoutSeconds(300); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| activeUsersService.setRedis(null); | ||
| }); | ||
|
|
||
| it("requires authentication to read the active-user count", async () => { | ||
| const res = await request(app).get("/api/analytics/active-users"); | ||
|
|
||
| expect(res.status).toBe(401); | ||
| }); | ||
|
|
||
| it("counts the requesting user via the activity middleware", async () => { | ||
| const res = await request(app) | ||
| .get("/api/analytics/active-users") | ||
| .set("Authorization", `Bearer ${readerToken}`); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(res.body.success).toBe(true); | ||
| expect(res.body.timeoutSeconds).toBe(300); | ||
| // The middleware tracked this very request before the handler counted. | ||
| expect(res.body.activeUsers).toBeGreaterThanOrEqual(1); | ||
|
Comment on lines
+100
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Update the active-user response assertions. When the controller returns 🤖 Prompt for AI Agents |
||
| expect(fakeRedis._store.size).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| it("counts each unique user once and ignores repeated activity", async () => { | ||
| await request(app) | ||
| .get("/api/analytics/active-users") | ||
| .set("Authorization", `Bearer ${readerToken}`); | ||
| await request(app) | ||
| .get("/api/analytics/active-users") | ||
| .set("Authorization", `Bearer ${authorToken}`); | ||
|
|
||
| const res = await request(app) | ||
| .get("/api/analytics/active-users") | ||
| .set("Authorization", `Bearer ${readerToken}`); | ||
|
|
||
| expect(res.body.activeUsers).toBe(2); | ||
| }); | ||
|
|
||
| it("expires users who have been idle longer than the timeout", async () => { | ||
| // Seed one fresh entry (via the service) and one stale entry directly. | ||
| await activeUsersService.trackActivity({ userId: "fresh-user" }); | ||
| const staleScore = Date.now() - activeUsersService.getTimeoutSeconds() * 1000 - 1000; | ||
| fakeRedis._store.set("stale-user", staleScore); | ||
|
|
||
| const count = await activeUsersService.getActiveUserCount(); | ||
|
|
||
| expect(count).toBe(1); | ||
| expect(fakeRedis._store.has("stale-user")).toBe(false); | ||
| }); | ||
|
|
||
| it("respects a shorter timeout via the environment override seam", async () => { | ||
| activeUsersService.setTimeoutSeconds(60); | ||
| await activeUsersService.trackActivity({ userId: "fresh-user" }); | ||
| const staleScore = Date.now() - 61 * 1000; | ||
| fakeRedis._store.set("stale-user", staleScore); | ||
|
|
||
| const count = await activeUsersService.getActiveUserCount(); | ||
|
|
||
| expect(count).toBe(1); | ||
| }); | ||
|
|
||
| it("returns 0 without error when Redis is unavailable", async () => { | ||
| activeUsersService.setRedis(null); | ||
|
|
||
| const res = await request(app) | ||
| .get("/api/analytics/active-users") | ||
| .set("Authorization", `Bearer ${readerToken}`); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(res.body.activeUsers).toBe(0); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Use the required response envelope.
The success response omits
messageanddata. The error response omitsdata. Clients that consume{ success, message, data }cannot handle this endpoint consistently.Put
activeUsersandtimeoutSecondsindata. Include a success message. Returndata: nullfor the error response. As per path instructions,src/**/*.jsrequires consistent response shapes({ success, message, data }).Also applies to: 20-23
🤖 Prompt for AI Agents
Source: Path instructions