Skip to content

Commit ab625ea

Browse files
feat(analytics): add auto-capture middleware for API requests
Express middleware that automatically tracks `api_request` events via the analytics service. Uses `res.on('finish')` to capture response metadata (status code, response time) after the response is sent. - Captures distinctId from req.user._id (or 'anonymous') - Captures organization group from req.organization._id - Records endpoint, method, statusCode, and responseTime properties - Skips health checks (/api/health), static assets, and favicon - No-op when PostHog is not configured (analytics service short-circuits) - Registered app-wide in analytics.init.js Includes 13 unit tests covering all branches. Closes #3295
1 parent 583b42a commit ab625ea

3 files changed

Lines changed: 252 additions & 3 deletions

File tree

modules/analytics/analytics.init.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
* Module dependencies
33
*/
44
import AnalyticsService from './services/analytics.service.js';
5+
import analyticsMiddleware from './middlewares/analytics.middleware.js';
56

67
/**
78
* Initialise the analytics module.
89
* Called automatically by the Express init loop (matched via the
910
* `modules/{name}/{name}.init.js` glob in config/assets.js).
10-
* @param {object} _app - Express application instance (unused)
11+
* Registers the auto-capture middleware so every API request is tracked.
12+
* @param {object} app - Express application instance
1113
* @returns {void}
1214
*/
13-
// eslint-disable-next-line no-unused-vars
14-
export default (_app) => {
15+
export default (app) => {
1516
AnalyticsService.init();
17+
app.use(analyticsMiddleware);
1618
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import AnalyticsService from '../services/analytics.service.js';
5+
6+
/**
7+
* Route prefixes to skip when auto-capturing API request events.
8+
* Health checks and static assets generate high-frequency, low-value noise.
9+
* @type {string[]}
10+
*/
11+
const SKIP_PREFIXES = ['/api/health', '/public', '/favicon'];
12+
13+
/**
14+
* Express middleware that auto-captures `api_request` events via the
15+
* analytics service. Attaches a `res.on('finish')` listener so that the
16+
* status code and response time are available when the event is recorded.
17+
*
18+
* The middleware is a no-op when PostHog is not configured because
19+
* `AnalyticsService.track()` itself short-circuits without a client.
20+
*
21+
* @param {import('express').Request} req - Express request object
22+
* @param {import('express').Response} res - Express response object
23+
* @param {import('express').NextFunction} next - Express next callback
24+
* @returns {void}
25+
*/
26+
const analyticsMiddleware = (req, res, next) => {
27+
const url = req.originalUrl || req.url;
28+
29+
// Skip routes that produce high-frequency, low-value events
30+
if (SKIP_PREFIXES.some((prefix) => url.startsWith(prefix))) {
31+
return next();
32+
}
33+
34+
const start = Date.now();
35+
36+
res.on('finish', () => {
37+
const distinctId = req.user?._id ? String(req.user._id) : 'anonymous';
38+
const groups = req.organization?._id ? { company: String(req.organization._id) } : undefined;
39+
40+
AnalyticsService.track(distinctId, 'api_request', {
41+
endpoint: url,
42+
method: req.method,
43+
statusCode: res.statusCode,
44+
responseTime: Date.now() - start,
45+
}, groups);
46+
});
47+
48+
return next();
49+
};
50+
51+
export default analyticsMiddleware;
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for analytics auto-capture middleware
8+
*/
9+
describe('Analytics middleware unit tests:', () => {
10+
let analyticsMiddleware;
11+
let mockTrack;
12+
13+
/** @type {import('express').Request} */
14+
let req;
15+
/** @type {import('express').Response & { _finishHandlers: Function[] }} */
16+
let res;
17+
/** @type {jest.Mock} */
18+
let next;
19+
20+
beforeEach(async () => {
21+
jest.resetModules();
22+
23+
mockTrack = jest.fn();
24+
25+
jest.unstable_mockModule('../services/analytics.service.js', () => ({
26+
default: {
27+
track: mockTrack,
28+
},
29+
}));
30+
31+
const mod = await import('../middlewares/analytics.middleware.js');
32+
analyticsMiddleware = mod.default;
33+
34+
// Build minimal Express-like req/res mocks
35+
req = {
36+
originalUrl: '/api/tasks',
37+
url: '/api/tasks',
38+
method: 'GET',
39+
user: { _id: 'user-123' },
40+
organization: { _id: 'org-456' },
41+
};
42+
43+
const finishHandlers = [];
44+
res = {
45+
statusCode: 200,
46+
on: jest.fn((event, handler) => {
47+
if (event === 'finish') finishHandlers.push(handler);
48+
}),
49+
_finishHandlers: finishHandlers,
50+
};
51+
52+
next = jest.fn();
53+
});
54+
55+
afterEach(() => {
56+
jest.restoreAllMocks();
57+
});
58+
59+
/**
60+
* Helper — trigger all registered 'finish' handlers on res.
61+
*/
62+
const triggerFinish = () => {
63+
res._finishHandlers.forEach((fn) => fn());
64+
};
65+
66+
test('should call next immediately', () => {
67+
analyticsMiddleware(req, res, next);
68+
expect(next).toHaveBeenCalledTimes(1);
69+
});
70+
71+
test('should register a finish listener on res', () => {
72+
analyticsMiddleware(req, res, next);
73+
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
74+
});
75+
76+
test('should track api_request with correct properties on finish', () => {
77+
analyticsMiddleware(req, res, next);
78+
triggerFinish();
79+
80+
expect(mockTrack).toHaveBeenCalledWith(
81+
'user-123',
82+
'api_request',
83+
expect.objectContaining({
84+
endpoint: '/api/tasks',
85+
method: 'GET',
86+
statusCode: 200,
87+
responseTime: expect.any(Number),
88+
}),
89+
{ company: 'org-456' },
90+
);
91+
});
92+
93+
test('should use "anonymous" when req.user is absent', () => {
94+
req.user = undefined;
95+
req.organization = undefined;
96+
analyticsMiddleware(req, res, next);
97+
triggerFinish();
98+
99+
expect(mockTrack).toHaveBeenCalledWith(
100+
'anonymous',
101+
'api_request',
102+
expect.any(Object),
103+
undefined,
104+
);
105+
});
106+
107+
test('should use "anonymous" when req.user._id is missing', () => {
108+
req.user = {};
109+
analyticsMiddleware(req, res, next);
110+
triggerFinish();
111+
112+
expect(mockTrack).toHaveBeenCalledWith(
113+
'anonymous',
114+
'api_request',
115+
expect.any(Object),
116+
expect.anything(),
117+
);
118+
});
119+
120+
test('should omit groups when req.organization is absent', () => {
121+
req.organization = undefined;
122+
analyticsMiddleware(req, res, next);
123+
triggerFinish();
124+
125+
expect(mockTrack).toHaveBeenCalledWith(
126+
'user-123',
127+
'api_request',
128+
expect.any(Object),
129+
undefined,
130+
);
131+
});
132+
133+
test('should skip /api/health routes', () => {
134+
req.originalUrl = '/api/health';
135+
analyticsMiddleware(req, res, next);
136+
137+
expect(next).toHaveBeenCalledTimes(1);
138+
expect(res.on).not.toHaveBeenCalled();
139+
});
140+
141+
test('should skip /api/health sub-routes', () => {
142+
req.originalUrl = '/api/health/ready';
143+
analyticsMiddleware(req, res, next);
144+
145+
expect(res.on).not.toHaveBeenCalled();
146+
});
147+
148+
test('should skip /public static assets', () => {
149+
req.originalUrl = '/public/images/logo.png';
150+
analyticsMiddleware(req, res, next);
151+
152+
expect(res.on).not.toHaveBeenCalled();
153+
});
154+
155+
test('should skip /favicon requests', () => {
156+
req.originalUrl = '/favicon.ico';
157+
analyticsMiddleware(req, res, next);
158+
159+
expect(res.on).not.toHaveBeenCalled();
160+
});
161+
162+
test('should capture correct statusCode from res on finish', () => {
163+
analyticsMiddleware(req, res, next);
164+
res.statusCode = 404;
165+
triggerFinish();
166+
167+
expect(mockTrack).toHaveBeenCalledWith(
168+
expect.any(String),
169+
'api_request',
170+
expect.objectContaining({ statusCode: 404 }),
171+
expect.anything(),
172+
);
173+
});
174+
175+
test('should capture responseTime as a non-negative number', () => {
176+
analyticsMiddleware(req, res, next);
177+
triggerFinish();
178+
179+
const properties = mockTrack.mock.calls[0][2];
180+
expect(properties.responseTime).toBeGreaterThanOrEqual(0);
181+
});
182+
183+
test('should fall back to req.url when originalUrl is absent', () => {
184+
req.originalUrl = undefined;
185+
req.url = '/api/fallback';
186+
analyticsMiddleware(req, res, next);
187+
triggerFinish();
188+
189+
expect(mockTrack).toHaveBeenCalledWith(
190+
expect.any(String),
191+
'api_request',
192+
expect.objectContaining({ endpoint: '/api/fallback' }),
193+
expect.anything(),
194+
);
195+
});
196+
});

0 commit comments

Comments
 (0)