Skip to content

Commit bcfe797

Browse files
feat(analytics): add request-aware feature flag helpers (#3487) (#3491)
* feat(analytics): add request-aware feature flag helpers (#3487) Routes today repeat the same distinctId plumbing for every flag call: const id = req.user?.id ?? req.sessionID ?? 'anonymous'; await analytics.getFeatureFlag('checkout-v2', id); This is verbose and the anonymous fallback is easy to forget (flag evaluated without a user silently returns the default value). Add two sugar helpers on AnalyticsService that extract the distinctId from an Express request: analytics.getFeatureFlagForRequest(flag, req, options) analytics.isFeatureEnabledForRequest(flag, req, options) Resolution chain: req.user?.id → req.sessionID → 'anonymous'. Defensive edge: req == null also resolves to 'anonymous' so the helper can be called without a pre-check. Existing getFeatureFlag / isFeatureEnabled remain public — cron, worker, and scheduled-job callers have no req and keep passing the identifier explicitly. Non-breaking for downstream. Tests: 11 unit tests cover the three canonical cases (authenticated user, session-only, no req), plus defensive edges (null req, non-id user object, numeric user.id coercion, no-PostHog passthrough). MIGRATIONS.md: entry documents the new helpers and opt-in migration. Closes #3487 * fix(analytics): use nullish checks in resolveDistinctId CodeRabbit/Copilot feedback: truthy checks (`if (req.user?.id)`) would coerce `0` or empty-string identifiers to `'anonymous'`. Switch to `!= null` so any defined id/sessionID is preserved and stringified. Added 2 tests (`id: 0`, empty sessionID) — suite now 13 tests.
1 parent b6e10c5 commit bcfe797

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

MIGRATIONS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Analytics: request-aware feature-flag helpers (2026-04-23)
8+
9+
`analytics` service gains two sugar helpers that extract the PostHog `distinctId` from an Express request, so routes no longer need to repeat `req.user?.id ?? req.sessionID ?? 'anonymous'` (and can never forget the anonymous fallback):
10+
11+
```js
12+
import analytics from '../../../lib/services/analytics.js';
13+
14+
// Route handler / middleware
15+
const flag = await analytics.getFeatureFlagForRequest('checkout-v2', req);
16+
if (await analytics.isFeatureEnabledForRequest('billing-portal', req)) { ... }
17+
```
18+
19+
Resolution chain: `req.user?.id``req.sessionID``'anonymous'`. Defensive fallback: `req == null` also resolves to `'anonymous'`.
20+
21+
### Non-breaking
22+
23+
- The existing `getFeatureFlag(flag, distinctId, options)` / `isFeatureEnabled(flag, distinctId, options)` remain public for cron, worker, and scheduled-job callers that have no `req`.
24+
- Higher-level `FeatureFlagsService` (`analytics.featureFlags.js`) is unchanged.
25+
26+
### Action for downstream
27+
28+
Optional — pull via `/update-stack`. Existing route code keeps working. New routes should prefer the `*ForRequest` variants to avoid the repeated distinctId boilerplate.
29+
30+
---
31+
732
## Redoc replaces Scalar for /api/docs (2026-04-13)
833

934
The `/api/docs` UI is now served by [redoc-express](https://www.npmjs.com/package/redoc-express) instead of `@scalar/express-api-reference`. Redoc renders the same OpenAPI spec (`/api/spec.json`) with a cleaner three-panel layout better suited to a consumer-facing API reference (no try-it-out panel — the API is API-key-gated and meant for programmatic use).

lib/services/analytics.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,58 @@ const captureException = (err, ctx = {}) => {
121121
} catch (_) { /* analytics must never break caller */ }
122122
};
123123

124+
/**
125+
* Resolve the PostHog distinctId for a request.
126+
*
127+
* Chain: `req.user?.id` → `req.sessionID` → `'anonymous'`.
128+
*
129+
* Returns `'anonymous'` when `req` is null/undefined so callers can use
130+
* the request-scoped helpers defensively without a pre-check. Callers
131+
* outside a request context (cron, worker, scheduled jobs) should keep
132+
* using {@link getFeatureFlag} / {@link isFeatureEnabled} with an
133+
* explicit identifier.
134+
* @param {import('express').Request|null|undefined} req - Express request
135+
* @returns {string} A stable distinct identifier
136+
*/
137+
const resolveDistinctId = (req) => {
138+
if (!req) return 'anonymous';
139+
// Use nullish checks so legitimate identifiers like `0` or empty strings
140+
// are preserved — any defined id is a valid PostHog distinctId.
141+
if (req.user?.id != null) return String(req.user.id);
142+
if (req.sessionID != null) return String(req.sessionID);
143+
return 'anonymous';
144+
};
145+
146+
/**
147+
* Evaluate a feature flag using a distinctId extracted from the request.
148+
*
149+
* Sugar over {@link getFeatureFlag} that removes the boilerplate
150+
* `req.user?.id ?? req.sessionID ?? 'anonymous'` every route was
151+
* repeating — and guarantees the anonymous fallback is never forgotten.
152+
*
153+
* Use this in route handlers / middlewares. For callers without a
154+
* request (cron, worker, job), keep using {@link getFeatureFlag}.
155+
* @param {string} flag - Feature flag key
156+
* @param {import('express').Request|null|undefined} req - Express request
157+
* @param {Object} [options] - Additional options forwarded to PostHog
158+
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
159+
*/
160+
const getFeatureFlagForRequest = async (flag, req, options) =>
161+
getFeatureFlag(flag, resolveDistinctId(req), options);
162+
163+
/**
164+
* Request-aware variant of {@link isFeatureEnabled}.
165+
*
166+
* Same distinctId resolution as {@link getFeatureFlagForRequest}:
167+
* `req.user?.id` → `req.sessionID` → `'anonymous'`.
168+
* @param {string} flag - Feature flag key
169+
* @param {import('express').Request|null|undefined} req - Express request
170+
* @param {Object} [options] - Additional options forwarded to PostHog
171+
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
172+
*/
173+
const isFeatureEnabledForRequest = async (flag, req, options) =>
174+
isFeatureEnabled(flag, resolveDistinctId(req), options);
175+
124176
/**
125177
* Flush pending events and shut down the PostHog client.
126178
* Safe to call even when the client was never initialised.
@@ -139,6 +191,8 @@ export default {
139191
groupIdentify,
140192
getFeatureFlag,
141193
isFeatureEnabled,
194+
getFeatureFlagForRequest,
195+
isFeatureEnabledForRequest,
142196
captureException,
143197
shutdown,
144198
};
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* Unit tests for the request-aware feature-flag helpers (#3487).
3+
*
4+
* These helpers wrap `getFeatureFlag` / `isFeatureEnabled` and resolve the
5+
* distinctId from an Express request:
6+
* req.user?.id → req.sessionID → 'anonymous'
7+
*
8+
* Covers the three canonical cases called out in the issue:
9+
* - authenticated user (req.user.id)
10+
* - session-only (anon browsing with a sessionID)
11+
* - no req at all (defensive fallback)
12+
* plus a couple of defensive edge cases.
13+
*/
14+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
15+
16+
describe('analytics request-aware feature-flag helpers:', () => {
17+
let AnalyticsService;
18+
let mockPostHogInstance;
19+
20+
beforeEach(async () => {
21+
jest.resetModules();
22+
23+
mockPostHogInstance = {
24+
capture: jest.fn(),
25+
identify: jest.fn(),
26+
groupIdentify: jest.fn(),
27+
getFeatureFlag: jest.fn().mockResolvedValue('variant-a'),
28+
isFeatureEnabled: jest.fn().mockResolvedValue(true),
29+
shutdown: jest.fn().mockResolvedValue(undefined),
30+
};
31+
32+
jest.unstable_mockModule('posthog-node', () => ({
33+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
34+
}));
35+
36+
jest.unstable_mockModule('../../../config/index.js', () => ({
37+
default: { posthog: { apiKey: 'phk_test', host: 'https://posthog.test' } },
38+
}));
39+
40+
const mod = await import('../analytics.js');
41+
AnalyticsService = mod.default;
42+
await AnalyticsService.init();
43+
});
44+
45+
afterEach(async () => {
46+
await AnalyticsService.shutdown();
47+
jest.restoreAllMocks();
48+
});
49+
50+
describe('getFeatureFlagForRequest', () => {
51+
test('uses req.user.id when authenticated', async () => {
52+
const req = { user: { id: 'user-42' }, sessionID: 'sess-abc' };
53+
54+
const result = await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req, { personProperties: { plan: 'pro' } });
55+
56+
expect(result).toBe('variant-a');
57+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
58+
'checkout-v2',
59+
'user-42',
60+
{ personProperties: { plan: 'pro' } },
61+
);
62+
});
63+
64+
test('falls back to req.sessionID when req.user is absent (anonymous browsing)', async () => {
65+
const req = { sessionID: 'sess-abc' };
66+
67+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
68+
69+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
70+
'checkout-v2',
71+
'sess-abc',
72+
undefined,
73+
);
74+
});
75+
76+
test('defaults to "anonymous" when req has neither user nor sessionID', async () => {
77+
const req = {};
78+
79+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
80+
81+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
82+
'checkout-v2',
83+
'anonymous',
84+
undefined,
85+
);
86+
});
87+
88+
test('defaults to "anonymous" when req itself is null/undefined (defensive edge)', async () => {
89+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', null);
90+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', undefined);
91+
92+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenNthCalledWith(1, 'checkout-v2', 'anonymous', undefined);
93+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenNthCalledWith(2, 'checkout-v2', 'anonymous', undefined);
94+
});
95+
96+
test('ignores req.user when user.id is missing (falls back to sessionID)', async () => {
97+
const req = { user: { email: 'a@b.com' }, sessionID: 'sess-abc' };
98+
99+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
100+
101+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
102+
'checkout-v2',
103+
'sess-abc',
104+
undefined,
105+
);
106+
});
107+
108+
test('coerces non-string user.id to string', async () => {
109+
const req = { user: { id: 42 } };
110+
111+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
112+
113+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
114+
'checkout-v2',
115+
'42',
116+
undefined,
117+
);
118+
});
119+
120+
test('accepts user.id === 0 (nullish-check, not truthy)', async () => {
121+
const req = { user: { id: 0 }, sessionID: 'sess-abc' };
122+
123+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
124+
125+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
126+
'checkout-v2',
127+
'0',
128+
undefined,
129+
);
130+
});
131+
132+
test('accepts empty string sessionID when req.user is absent', async () => {
133+
const req = { sessionID: '' };
134+
135+
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);
136+
137+
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
138+
'checkout-v2',
139+
'',
140+
undefined,
141+
);
142+
});
143+
144+
test('returns undefined when PostHog is not configured', async () => {
145+
await AnalyticsService.shutdown();
146+
jest.resetModules();
147+
jest.unstable_mockModule('../../../config/index.js', () => ({
148+
default: { posthog: {} },
149+
}));
150+
const mod = await import('../analytics.js');
151+
const svc = mod.default;
152+
await svc.init();
153+
154+
const result = await svc.getFeatureFlagForRequest('checkout-v2', { user: { id: 'user-42' } });
155+
156+
expect(result).toBeUndefined();
157+
});
158+
});
159+
160+
describe('isFeatureEnabledForRequest', () => {
161+
test('uses req.user.id when authenticated', async () => {
162+
const req = { user: { id: 'user-42' }, sessionID: 'sess-abc' };
163+
164+
const result = await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req);
165+
166+
expect(result).toBe(true);
167+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
168+
'billing-portal',
169+
'user-42',
170+
undefined,
171+
);
172+
});
173+
174+
test('falls back to sessionID for anonymous browsing', async () => {
175+
const req = { sessionID: 'sess-abc' };
176+
177+
await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req, { personProperties: {} });
178+
179+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
180+
'billing-portal',
181+
'sess-abc',
182+
{ personProperties: {} },
183+
);
184+
});
185+
186+
test('defaults to "anonymous" with no req (edge: called outside request context by mistake)', async () => {
187+
await AnalyticsService.isFeatureEnabledForRequest('billing-portal', undefined);
188+
189+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
190+
'billing-portal',
191+
'anonymous',
192+
undefined,
193+
);
194+
});
195+
196+
test('returns undefined when PostHog is not configured', async () => {
197+
await AnalyticsService.shutdown();
198+
jest.resetModules();
199+
jest.unstable_mockModule('../../../config/index.js', () => ({
200+
default: { posthog: {} },
201+
}));
202+
const mod = await import('../analytics.js');
203+
const svc = mod.default;
204+
await svc.init();
205+
206+
const result = await svc.isFeatureEnabledForRequest('billing-portal', { user: { id: 'user-42' } });
207+
208+
expect(result).toBeUndefined();
209+
});
210+
});
211+
});

0 commit comments

Comments
 (0)