Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ Breaking changes and upgrade notes for downstream projects.

---

## Analytics: request-aware feature-flag helpers (2026-04-23)

`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):

```js
import analytics from '../../../lib/services/analytics.js';

// Route handler / middleware
const flag = await analytics.getFeatureFlagForRequest('checkout-v2', req);
if (await analytics.isFeatureEnabledForRequest('billing-portal', req)) { ... }
```

Resolution chain: `req.user?.id` → `req.sessionID` → `'anonymous'`. Defensive fallback: `req == null` also resolves to `'anonymous'`.

### Non-breaking

- The existing `getFeatureFlag(flag, distinctId, options)` / `isFeatureEnabled(flag, distinctId, options)` remain public for cron, worker, and scheduled-job callers that have no `req`.
- Higher-level `FeatureFlagsService` (`analytics.featureFlags.js`) is unchanged.

### Action for downstream

Optional — pull via `/update-stack`. Existing route code keeps working. New routes should prefer the `*ForRequest` variants to avoid the repeated distinctId boilerplate.

---

## Redoc replaces Scalar for /api/docs (2026-04-13)

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).
Expand Down
54 changes: 54 additions & 0 deletions lib/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,58 @@
} catch (_) { /* analytics must never break caller */ }
};

/**
* Resolve the PostHog distinctId for a request.
*
* Chain: `req.user?.id` → `req.sessionID` → `'anonymous'`.
*
* Returns `'anonymous'` when `req` is null/undefined so callers can use
* the request-scoped helpers defensively without a pre-check. Callers
* outside a request context (cron, worker, scheduled jobs) should keep
* using {@link getFeatureFlag} / {@link isFeatureEnabled} with an
* explicit identifier.
* @param {import('express').Request|null|undefined} req - Express request
* @returns {string} A stable distinct identifier
*/
const resolveDistinctId = (req) => {

Check warning on line 137 in lib/services/analytics.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

lib/services/analytics.js#L137

Non-serializable expression must be wrapped with $(...)
if (!req) return 'anonymous';
// Use nullish checks so legitimate identifiers like `0` or empty strings
// are preserved — any defined id is a valid PostHog distinctId.
if (req.user?.id != null) return String(req.user.id);
if (req.sessionID != null) return String(req.sessionID);
return 'anonymous';
};

/**
* Evaluate a feature flag using a distinctId extracted from the request.
*
* Sugar over {@link getFeatureFlag} that removes the boilerplate
* `req.user?.id ?? req.sessionID ?? 'anonymous'` every route was
* repeating — and guarantees the anonymous fallback is never forgotten.
*
* Use this in route handlers / middlewares. For callers without a
* request (cron, worker, job), keep using {@link getFeatureFlag}.
* @param {string} flag - Feature flag key
* @param {import('express').Request|null|undefined} req - Express request
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
*/
const getFeatureFlagForRequest = async (flag, req, options) =>

Check warning on line 160 in lib/services/analytics.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

lib/services/analytics.js#L160

Non-serializable expression must be wrapped with $(...)
getFeatureFlag(flag, resolveDistinctId(req), options);

/**
* Request-aware variant of {@link isFeatureEnabled}.
*
* Same distinctId resolution as {@link getFeatureFlagForRequest}:
* `req.user?.id` → `req.sessionID` → `'anonymous'`.
* @param {string} flag - Feature flag key
* @param {import('express').Request|null|undefined} req - Express request
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
*/
const isFeatureEnabledForRequest = async (flag, req, options) =>

Check warning on line 173 in lib/services/analytics.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

lib/services/analytics.js#L173

Non-serializable expression must be wrapped with $(...)
isFeatureEnabled(flag, resolveDistinctId(req), options);

/**
* Flush pending events and shut down the PostHog client.
* Safe to call even when the client was never initialised.
Expand All @@ -139,6 +191,8 @@
groupIdentify,
getFeatureFlag,
isFeatureEnabled,
getFeatureFlagForRequest,
isFeatureEnabledForRequest,
captureException,
shutdown,
};
211 changes: 211 additions & 0 deletions lib/services/tests/analytics.forRequest.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Unit tests for the request-aware feature-flag helpers (#3487).
*
* These helpers wrap `getFeatureFlag` / `isFeatureEnabled` and resolve the
* distinctId from an Express request:
* req.user?.id → req.sessionID → 'anonymous'
*
* Covers the three canonical cases called out in the issue:
* - authenticated user (req.user.id)
* - session-only (anon browsing with a sessionID)
* - no req at all (defensive fallback)
* plus a couple of defensive edge cases.
*/
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';

describe('analytics request-aware feature-flag helpers:', () => {
let AnalyticsService;
let mockPostHogInstance;

beforeEach(async () => {
jest.resetModules();

mockPostHogInstance = {
capture: jest.fn(),
identify: jest.fn(),
groupIdentify: jest.fn(),
getFeatureFlag: jest.fn().mockResolvedValue('variant-a'),
isFeatureEnabled: jest.fn().mockResolvedValue(true),
shutdown: jest.fn().mockResolvedValue(undefined),
};

jest.unstable_mockModule('posthog-node', () => ({
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
}));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { apiKey: 'phk_test', host: 'https://posthog.test' } },
}));

const mod = await import('../analytics.js');
AnalyticsService = mod.default;
await AnalyticsService.init();
});

afterEach(async () => {
await AnalyticsService.shutdown();
jest.restoreAllMocks();
});

describe('getFeatureFlagForRequest', () => {
test('uses req.user.id when authenticated', async () => {
const req = { user: { id: 'user-42' }, sessionID: 'sess-abc' };

const result = await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req, { personProperties: { plan: 'pro' } });

expect(result).toBe('variant-a');
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'user-42',
{ personProperties: { plan: 'pro' } },
);
});

test('falls back to req.sessionID when req.user is absent (anonymous browsing)', async () => {
const req = { sessionID: 'sess-abc' };

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'sess-abc',
undefined,
);
});

test('defaults to "anonymous" when req has neither user nor sessionID', async () => {
const req = {};

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'anonymous',
undefined,
);
});

test('defaults to "anonymous" when req itself is null/undefined (defensive edge)', async () => {
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', null);
await AnalyticsService.getFeatureFlagForRequest('checkout-v2', undefined);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenNthCalledWith(1, 'checkout-v2', 'anonymous', undefined);
expect(mockPostHogInstance.getFeatureFlag).toHaveBeenNthCalledWith(2, 'checkout-v2', 'anonymous', undefined);
});

test('ignores req.user when user.id is missing (falls back to sessionID)', async () => {
const req = { user: { email: 'a@b.com' }, sessionID: 'sess-abc' };

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'sess-abc',
undefined,
);
});

test('coerces non-string user.id to string', async () => {
const req = { user: { id: 42 } };

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'42',
undefined,
);
});

test('accepts user.id === 0 (nullish-check, not truthy)', async () => {
const req = { user: { id: 0 }, sessionID: 'sess-abc' };

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'0',
undefined,
);
});

test('accepts empty string sessionID when req.user is absent', async () => {
const req = { sessionID: '' };

await AnalyticsService.getFeatureFlagForRequest('checkout-v2', req);

expect(mockPostHogInstance.getFeatureFlag).toHaveBeenCalledWith(
'checkout-v2',
'',
undefined,
);
});

test('returns undefined when PostHog is not configured', async () => {
await AnalyticsService.shutdown();
jest.resetModules();
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: {} },
}));
const mod = await import('../analytics.js');
const svc = mod.default;
await svc.init();

const result = await svc.getFeatureFlagForRequest('checkout-v2', { user: { id: 'user-42' } });

expect(result).toBeUndefined();
});
});

describe('isFeatureEnabledForRequest', () => {
test('uses req.user.id when authenticated', async () => {
const req = { user: { id: 'user-42' }, sessionID: 'sess-abc' };

const result = await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req);

expect(result).toBe(true);
expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
'billing-portal',
'user-42',
undefined,
);
});

test('falls back to sessionID for anonymous browsing', async () => {
const req = { sessionID: 'sess-abc' };

await AnalyticsService.isFeatureEnabledForRequest('billing-portal', req, { personProperties: {} });

expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
'billing-portal',
'sess-abc',
{ personProperties: {} },
);
});

test('defaults to "anonymous" with no req (edge: called outside request context by mistake)', async () => {
await AnalyticsService.isFeatureEnabledForRequest('billing-portal', undefined);

expect(mockPostHogInstance.isFeatureEnabled).toHaveBeenCalledWith(
'billing-portal',
'anonymous',
undefined,
);
});

test('returns undefined when PostHog is not configured', async () => {
await AnalyticsService.shutdown();
jest.resetModules();
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: {} },
}));
const mod = await import('../analytics.js');
const svc = mod.default;
await svc.init();

const result = await svc.isFeatureEnabledForRequest('billing-portal', { user: { id: 'user-42' } });

expect(result).toBeUndefined();
});
});
});
Loading