Skip to content

Commit 3ea2085

Browse files
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
1 parent f8542e3 commit 3ea2085

3 files changed

Lines changed: 264 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,56 @@ const isFeatureEnabled = async (flag, distinctId, options) => {
9393
return client.isFeatureEnabled(flag, distinctId, options);
9494
};
9595

96+
/**
97+
* Resolve the PostHog distinctId for a request.
98+
*
99+
* Chain: `req.user?.id` → `req.sessionID` → `'anonymous'`.
100+
*
101+
* Returns `'anonymous'` when `req` is null/undefined so callers can use
102+
* the request-scoped helpers defensively without a pre-check. Callers
103+
* outside a request context (cron, worker, scheduled jobs) should keep
104+
* using {@link getFeatureFlag} / {@link isFeatureEnabled} with an
105+
* explicit identifier.
106+
* @param {import('express').Request|null|undefined} req - Express request
107+
* @returns {string} A stable distinct identifier
108+
*/
109+
const resolveDistinctId = (req) => {
110+
if (!req) return 'anonymous';
111+
if (req.user?.id) return String(req.user.id);
112+
if (req.sessionID) return String(req.sessionID);
113+
return 'anonymous';
114+
};
115+
116+
/**
117+
* Evaluate a feature flag using a distinctId extracted from the request.
118+
*
119+
* Sugar over {@link getFeatureFlag} that removes the boilerplate
120+
* `req.user?.id ?? req.sessionID ?? 'anonymous'` every route was
121+
* repeating — and guarantees the anonymous fallback is never forgotten.
122+
*
123+
* Use this in route handlers / middlewares. For callers without a
124+
* request (cron, worker, job), keep using {@link getFeatureFlag}.
125+
* @param {string} flag - Feature flag key
126+
* @param {import('express').Request|null|undefined} req - Express request
127+
* @param {Object} [options] - Additional options forwarded to PostHog
128+
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
129+
*/
130+
const getFeatureFlagForRequest = async (flag, req, options) =>
131+
getFeatureFlag(flag, resolveDistinctId(req), options);
132+
133+
/**
134+
* Request-aware variant of {@link isFeatureEnabled}.
135+
*
136+
* Same distinctId resolution as {@link getFeatureFlagForRequest}:
137+
* `req.user?.id` → `req.sessionID` → `'anonymous'`.
138+
* @param {string} flag - Feature flag key
139+
* @param {import('express').Request|null|undefined} req - Express request
140+
* @param {Object} [options] - Additional options forwarded to PostHog
141+
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
142+
*/
143+
const isFeatureEnabledForRequest = async (flag, req, options) =>
144+
isFeatureEnabled(flag, resolveDistinctId(req), options);
145+
96146
/**
97147
* Flush pending events and shut down the PostHog client.
98148
* Safe to call even when the client was never initialised.
@@ -111,5 +161,7 @@ export default {
111161
groupIdentify,
112162
getFeatureFlag,
113163
isFeatureEnabled,
164+
getFeatureFlagForRequest,
165+
isFeatureEnabledForRequest,
114166
shutdown,
115167
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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('returns undefined when PostHog is not configured', async () => {
121+
await AnalyticsService.shutdown();
122+
jest.resetModules();
123+
jest.unstable_mockModule('../../../config/index.js', () => ({
124+
default: { posthog: {} },
125+
}));
126+
const mod = await import('../analytics.js');
127+
const svc = mod.default;
128+
await svc.init();
129+
130+
const result = await svc.getFeatureFlagForRequest('checkout-v2', { user: { id: 'user-42' } });
131+
132+
expect(result).toBeUndefined();
133+
});
134+
});
135+
136+
describe('isFeatureEnabledForRequest', () => {
137+
test('uses req.user.id when authenticated', async () => {
138+
const req = { user: { id: 'user-42' }, sessionID: 'sess-abc' };
139+
140+
const result = await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req);
141+
142+
expect(result).toBe(true);
143+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
144+
'billing-portal',
145+
'user-42',
146+
undefined,
147+
);
148+
});
149+
150+
test('falls back to sessionID for anonymous browsing', async () => {
151+
const req = { sessionID: 'sess-abc' };
152+
153+
await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req, { personProperties: {} });
154+
155+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
156+
'billing-portal',
157+
'sess-abc',
158+
{ personProperties: {} },
159+
);
160+
});
161+
162+
test('defaults to "anonymous" with no req (edge: called outside request context by mistake)', async () => {
163+
await AnalyticsService.isFeatureEnabledForRequest('billing-portal', undefined);
164+
165+
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
166+
'billing-portal',
167+
'anonymous',
168+
undefined,
169+
);
170+
});
171+
172+
test('returns undefined when PostHog is not configured', async () => {
173+
await AnalyticsService.shutdown();
174+
jest.resetModules();
175+
jest.unstable_mockModule('../../../config/index.js', () => ({
176+
default: { posthog: {} },
177+
}));
178+
const mod = await import('../analytics.js');
179+
const svc = mod.default;
180+
await svc.init();
181+
182+
const result = await svc.isFeatureEnabledForRequest('billing-portal', { user: { id: 'user-42' } });
183+
184+
expect(result).toBeUndefined();
185+
});
186+
});
187+
});

0 commit comments

Comments
 (0)