Skip to content

Commit 723ba4a

Browse files
feat(analytics): auto-capture middleware for API requests (#3305)
* 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 * fix(analytics): strip query strings, configurable prefixes, add integration tests - Strip query strings from endpoint before sending to analytics (prevents PII leak) - Make skip prefixes configurable via createAnalyticsMiddleware(options) factory - Export SKIP_PREFIXES and createAnalyticsMiddleware as named exports - Add integration test suite with supertest (9 tests covering real HTTP lifecycle) - Tighten groups assertion in unit test for anonymous user with org - Document lazy identity resolution in JSDoc (req.user/req.organization read at finish time)
1 parent c22fcfe commit 723ba4a

4 files changed

Lines changed: 467 additions & 3 deletions

File tree

modules/analytics/analytics.init.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
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+
*
12+
* Registers the auto-capture middleware so every API request is tracked.
13+
* The middleware reads identity context (`req.user`, `req.organization`)
14+
* lazily inside the `res.on('finish')` handler, so it is safe to mount
15+
* here — before route-level auth middleware is wired.
16+
*
17+
* @param {object} app - Express application instance
1118
* @returns {Promise<void>}
1219
*/
13-
// eslint-disable-next-line no-unused-vars
14-
export default async (_app) => {
20+
export default async (app) => {
1521
await AnalyticsService.init();
22+
app.use(analyticsMiddleware);
1623
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import AnalyticsService from '../services/analytics.service.js';
5+
6+
/**
7+
* Default 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+
* Create an 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+
* Identity context (`req.user`, `req.organization`) is read lazily inside the
19+
* `finish` handler — not at middleware execution time — so the middleware can
20+
* safely be mounted before auth/org resolution.
21+
*
22+
* The middleware is a no-op when PostHog is not configured because
23+
* `AnalyticsService.track()` itself short-circuits without a client.
24+
*
25+
* @param {Object} [options] - Configuration options
26+
* @param {string[]} [options.skipPrefixes] - Route prefixes to skip (defaults to SKIP_PREFIXES)
27+
* @returns {import('express').RequestHandler}
28+
*/
29+
const createAnalyticsMiddleware = (options = {}) => {
30+
const prefixes = options.skipPrefixes || SKIP_PREFIXES;
31+
32+
return (req, res, next) => {
33+
const rawUrl = req.originalUrl || req.url;
34+
const endpoint = (rawUrl || '').split('?')[0] || '/';
35+
36+
// Skip routes that produce high-frequency, low-value events
37+
if (prefixes.some((prefix) => endpoint.startsWith(prefix))) {
38+
return next();
39+
}
40+
41+
const start = Date.now();
42+
43+
res.on('finish', () => {
44+
const distinctId = req.user?._id ? String(req.user._id) : 'anonymous';
45+
const groups = req.organization?._id ? { company: String(req.organization._id) } : undefined;
46+
47+
AnalyticsService.track(distinctId, 'api_request', {
48+
endpoint,
49+
method: req.method,
50+
statusCode: res.statusCode,
51+
responseTime: Date.now() - start,
52+
}, groups);
53+
});
54+
55+
return next();
56+
};
57+
};
58+
59+
/**
60+
* Pre-built middleware instance using default options.
61+
* @type {import('express').RequestHandler}
62+
*/
63+
const analyticsMiddleware = createAnalyticsMiddleware();
64+
65+
export { createAnalyticsMiddleware, SKIP_PREFIXES };
66+
export default analyticsMiddleware;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeAll, afterAll, beforeEach, describe, test, expect } from '@jest/globals';
5+
import express from 'express';
6+
import request from 'supertest';
7+
8+
/**
9+
* Integration tests for analytics auto-capture middleware.
10+
* Boots a minimal Express app with the middleware mounted to validate
11+
* real HTTP request/response lifecycle behaviour.
12+
*/
13+
describe('Analytics middleware integration tests:', () => {
14+
let app;
15+
let mockTrack;
16+
17+
beforeAll(async () => {
18+
jest.resetModules();
19+
20+
mockTrack = jest.fn();
21+
22+
jest.unstable_mockModule('../services/analytics.service.js', () => ({
23+
default: {
24+
track: mockTrack,
25+
},
26+
}));
27+
28+
const { createAnalyticsMiddleware } = await import('../middlewares/analytics.middleware.js');
29+
30+
app = express();
31+
app.use(createAnalyticsMiddleware());
32+
33+
// Simulate auth middleware populating req.user / req.organization
34+
app.use((req, _res, next) => {
35+
if (req.headers['x-test-user']) {
36+
req.user = { _id: req.headers['x-test-user'] };
37+
}
38+
if (req.headers['x-test-org']) {
39+
req.organization = { _id: req.headers['x-test-org'] };
40+
}
41+
next();
42+
});
43+
44+
app.get('/api/tasks', (_req, res) => res.json({ ok: true }));
45+
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
46+
app.get('/public/logo.png', (_req, res) => res.send('img'));
47+
app.get('/favicon.ico', (_req, res) => res.send('ico'));
48+
app.get('/api/error', (_req, res) => res.status(500).json({ error: true }));
49+
});
50+
51+
beforeEach(() => {
52+
mockTrack.mockClear();
53+
});
54+
55+
afterAll(() => {
56+
jest.restoreAllMocks();
57+
});
58+
59+
test('should track a normal API request with user and org', async () => {
60+
await request(app)
61+
.get('/api/tasks')
62+
.set('x-test-user', 'user-abc')
63+
.set('x-test-org', 'org-xyz')
64+
.expect(200);
65+
66+
expect(mockTrack).toHaveBeenCalledTimes(1);
67+
expect(mockTrack).toHaveBeenCalledWith(
68+
'user-abc',
69+
'api_request',
70+
expect.objectContaining({
71+
endpoint: '/api/tasks',
72+
method: 'GET',
73+
statusCode: 200,
74+
responseTime: expect.any(Number),
75+
}),
76+
{ company: 'org-xyz' },
77+
);
78+
});
79+
80+
test('should use "anonymous" when no user header is set', async () => {
81+
await request(app)
82+
.get('/api/tasks')
83+
.expect(200);
84+
85+
expect(mockTrack).toHaveBeenCalledTimes(1);
86+
expect(mockTrack).toHaveBeenCalledWith(
87+
'anonymous',
88+
'api_request',
89+
expect.any(Object),
90+
undefined,
91+
);
92+
});
93+
94+
test('should omit groups when no org header is set', async () => {
95+
await request(app)
96+
.get('/api/tasks')
97+
.set('x-test-user', 'user-abc')
98+
.expect(200);
99+
100+
expect(mockTrack).toHaveBeenCalledTimes(1);
101+
const groups = mockTrack.mock.calls[0][3];
102+
expect(groups).toBeUndefined();
103+
});
104+
105+
test('should not track /api/health requests', async () => {
106+
await request(app)
107+
.get('/api/health')
108+
.expect(200);
109+
110+
expect(mockTrack).not.toHaveBeenCalled();
111+
});
112+
113+
test('should not track /public requests', async () => {
114+
await request(app)
115+
.get('/public/logo.png')
116+
.expect(200);
117+
118+
expect(mockTrack).not.toHaveBeenCalled();
119+
});
120+
121+
test('should not track /favicon requests', async () => {
122+
await request(app)
123+
.get('/favicon.ico')
124+
.expect(200);
125+
126+
expect(mockTrack).not.toHaveBeenCalled();
127+
});
128+
129+
test('should capture real status code for error responses', async () => {
130+
await request(app)
131+
.get('/api/error')
132+
.set('x-test-user', 'user-abc')
133+
.expect(500);
134+
135+
expect(mockTrack).toHaveBeenCalledTimes(1);
136+
expect(mockTrack).toHaveBeenCalledWith(
137+
'user-abc',
138+
'api_request',
139+
expect.objectContaining({ statusCode: 500 }),
140+
undefined,
141+
);
142+
});
143+
144+
test('should capture positive responseTime', async () => {
145+
await request(app)
146+
.get('/api/tasks')
147+
.expect(200);
148+
149+
const properties = mockTrack.mock.calls[0][2];
150+
expect(properties.responseTime).toBeGreaterThanOrEqual(0);
151+
});
152+
153+
test('should strip query strings from endpoint', async () => {
154+
await request(app)
155+
.get('/api/tasks?page=1&token=secret')
156+
.expect(200);
157+
158+
expect(mockTrack).toHaveBeenCalledTimes(1);
159+
const properties = mockTrack.mock.calls[0][2];
160+
expect(properties.endpoint).toBe('/api/tasks');
161+
});
162+
});

0 commit comments

Comments
 (0)