-
-
Notifications
You must be signed in to change notification settings - Fork 10
feat(analytics): feature flags service (local eval) #3302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
PierreBrisorgueil
wants to merge
1
commit into
feat/analytics-posthog-sdk
from
feat/analytics-feature-flags
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
58 changes: 58 additions & 0 deletions
58
modules/analytics/middlewares/analytics.requireFeatureFlag.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| /** | ||
| * Module dependencies | ||
| */ | ||
| import FeatureFlagsService from '../services/analytics.featureFlags.service.js'; | ||
|
|
||
| import responses from '../../../lib/helpers/responses.js'; | ||
|
|
||
| /** | ||
| * Returns Express middleware that gates access based on a PostHog feature flag. | ||
| * | ||
| * The middleware evaluates the flag for the authenticated user, passing | ||
| * organisation context when available. Behaviour by scenario: | ||
| * | ||
| * - Flag enabled -> next() | ||
| * - Flag disabled -> 403 | ||
| * - Analytics not configured (no PostHog key) -> next() (fail-open so | ||
| * projects not using PostHog are never blocked) | ||
| * | ||
| * @param {string} flagName - PostHog feature flag key | ||
| * @returns {Function} Express middleware function | ||
| */ | ||
| function requireFeatureFlag(flagName) { | ||
| return async function requireFeatureFlagMiddleware(req, res, next) { | ||
| const distinctId = req.user?._id ? String(req.user._id) : undefined; | ||
| if (!distinctId) { | ||
| return responses.error(res, 401, 'Unauthorized', 'Authentication required to evaluate feature flag')(); | ||
| } | ||
|
|
||
| try { | ||
| const options = {}; | ||
| if (req.organization?._id) { | ||
| options.groups = { company: String(req.organization._id) }; | ||
| } | ||
|
|
||
| const enabled = await FeatureFlagsService.isEnabled(flagName, distinctId, options); | ||
|
|
||
| // isEnabled returns false both when the flag is off AND when analytics | ||
| // is not configured. Distinguish by checking getVariant: undefined | ||
| // means not configured (fail-open), while false/string means configured. | ||
| if (!enabled) { | ||
| const variant = await FeatureFlagsService.getVariant(flagName, distinctId, options); | ||
| // undefined -> analytics not configured -> fail-open | ||
| if (variant === undefined) return next(); | ||
|
|
||
| return responses.error(res, 403, 'Forbidden', 'Feature not available on your current plan')({ | ||
| type: 'FEATURE_FLAG_DISABLED', | ||
| flag: flagName, | ||
| }); | ||
| } | ||
|
|
||
| return next(); | ||
| } catch (err) { | ||
| return next(err); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export default requireFeatureFlag; | ||
60 changes: 60 additions & 0 deletions
60
modules/analytics/services/analytics.featureFlags.service.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * Module dependencies | ||
| */ | ||
| import AnalyticsService from './analytics.service.js'; | ||
|
|
||
| /** | ||
| * Check whether a feature flag is enabled for a given user. | ||
| * Builds the PostHog options object from the provided context so callers | ||
| * don't need to know the PostHog SDK shape. | ||
| * | ||
| * Returns `false` when analytics is not configured (safe default) so that | ||
| * downstream projects without PostHog are never blocked. | ||
| * | ||
| * @param {string} flag - Feature flag key | ||
| * @param {string} distinctId - User identifier | ||
| * @param {Object} [options] - Evaluation context | ||
| * @param {Object} [options.personProperties] - Properties for local evaluation | ||
| * @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId }) | ||
| * @param {Object} [options.groupProperties] - Properties per group type | ||
| * @returns {Promise<boolean>} true when the flag is enabled, false otherwise | ||
| */ | ||
| const isEnabled = async (flag, distinctId, options = {}) => { | ||
| const { personProperties, groups, groupProperties } = options; | ||
| const phOptions = {}; | ||
| if (personProperties) phOptions.personProperties = personProperties; | ||
| if (groups) phOptions.groups = groups; | ||
| if (groupProperties) phOptions.groupProperties = groupProperties; | ||
|
|
||
| const result = await AnalyticsService.isFeatureEnabled(flag, distinctId, phOptions); | ||
| // Normalise undefined (not configured) to false for a safe default | ||
| return result === true; | ||
|
PierreBrisorgueil marked this conversation as resolved.
|
||
| }; | ||
|
PierreBrisorgueil marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Get the variant value of a feature flag for a given user. | ||
| * Returns the variant key string, a boolean, or `undefined` when analytics | ||
| * is not configured. | ||
| * | ||
| * @param {string} flag - Feature flag key | ||
| * @param {string} distinctId - User identifier | ||
| * @param {Object} [options] - Evaluation context | ||
| * @param {Object} [options.personProperties] - Properties for local evaluation | ||
| * @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId }) | ||
| * @param {Object} [options.groupProperties] - Properties per group type | ||
| * @returns {Promise<string|boolean|undefined>} Variant value | ||
| */ | ||
| const getVariant = async (flag, distinctId, options = {}) => { | ||
| const { personProperties, groups, groupProperties } = options; | ||
| const phOptions = {}; | ||
| if (personProperties) phOptions.personProperties = personProperties; | ||
| if (groups) phOptions.groups = groups; | ||
| if (groupProperties) phOptions.groupProperties = groupProperties; | ||
|
|
||
| return AnalyticsService.getFeatureFlag(flag, distinctId, phOptions); | ||
| }; | ||
|
|
||
| export default { | ||
| isEnabled, | ||
| getVariant, | ||
| }; | ||
126 changes: 126 additions & 0 deletions
126
modules/analytics/tests/analytics.featureFlags.service.unit.tests.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| /** | ||
| * Module dependencies. | ||
| */ | ||
| import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; | ||
|
|
||
| /** | ||
| * Unit tests for analytics feature flags service | ||
| */ | ||
| describe('Analytics feature flags service unit tests:', () => { | ||
| let FeatureFlagsService; | ||
| let mockAnalyticsService; | ||
|
|
||
| beforeEach(async () => { | ||
| jest.resetModules(); | ||
|
|
||
| mockAnalyticsService = { | ||
| isFeatureEnabled: jest.fn(), | ||
| getFeatureFlag: jest.fn(), | ||
| }; | ||
|
|
||
| jest.unstable_mockModule('../services/analytics.service.js', () => ({ | ||
| default: mockAnalyticsService, | ||
| })); | ||
|
|
||
| const mod = await import('../services/analytics.featureFlags.service.js'); | ||
| FeatureFlagsService = mod.default; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('isEnabled', () => { | ||
| test('should return true when flag is enabled', async () => { | ||
| mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true); | ||
|
|
||
| const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBe(true); | ||
| expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {}); | ||
| }); | ||
|
|
||
| test('should return false when flag is disabled', async () => { | ||
| mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false); | ||
|
|
||
| const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBe(false); | ||
| }); | ||
|
|
||
| test('should return false when analytics is not configured (undefined)', async () => { | ||
| mockAnalyticsService.isFeatureEnabled.mockResolvedValue(undefined); | ||
|
|
||
| const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBe(false); | ||
| }); | ||
|
PierreBrisorgueil marked this conversation as resolved.
|
||
|
|
||
| test('should pass personProperties and groups to analytics service', async () => { | ||
| mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true); | ||
|
|
||
| await FeatureFlagsService.isEnabled('my-flag', 'user-1', { | ||
| personProperties: { plan: 'pro' }, | ||
| groups: { company: 'org-1' }, | ||
| groupProperties: { company: { plan: 'enterprise' } }, | ||
| }); | ||
|
|
||
| expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', { | ||
| personProperties: { plan: 'pro' }, | ||
| groups: { company: 'org-1' }, | ||
| groupProperties: { company: { plan: 'enterprise' } }, | ||
| }); | ||
| }); | ||
|
|
||
| test('should omit undefined option keys from PostHog options', async () => { | ||
| mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false); | ||
|
|
||
| await FeatureFlagsService.isEnabled('my-flag', 'user-1', {}); | ||
|
|
||
| expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {}); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getVariant', () => { | ||
| test('should return variant string when flag has multivariate value', async () => { | ||
| mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-a'); | ||
|
|
||
| const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBe('variant-a'); | ||
| expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', {}); | ||
| }); | ||
|
|
||
| test('should return boolean when flag is a simple toggle', async () => { | ||
| mockAnalyticsService.getFeatureFlag.mockResolvedValue(false); | ||
|
|
||
| const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBe(false); | ||
| }); | ||
|
|
||
| test('should return undefined when analytics is not configured', async () => { | ||
| mockAnalyticsService.getFeatureFlag.mockResolvedValue(undefined); | ||
|
|
||
| const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); | ||
|
|
||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('should pass personProperties, groups, and groupProperties', async () => { | ||
| mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-b'); | ||
|
|
||
| await FeatureFlagsService.getVariant('my-flag', 'user-1', { | ||
| personProperties: { email: 'a@b.com' }, | ||
| groups: { company: 'org-1' }, | ||
| groupProperties: { company: { name: 'Acme' } }, | ||
| }); | ||
|
|
||
| expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', { | ||
| personProperties: { email: 'a@b.com' }, | ||
| groups: { company: 'org-1' }, | ||
| groupProperties: { company: { name: 'Acme' } }, | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
123 changes: 123 additions & 0 deletions
123
modules/analytics/tests/analytics.requireFeatureFlag.unit.tests.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * Module dependencies. | ||
| */ | ||
| import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; | ||
|
|
||
| /** | ||
| * Unit tests for requireFeatureFlag middleware | ||
| */ | ||
| describe('requireFeatureFlag middleware unit tests:', () => { | ||
| let requireFeatureFlag; | ||
| let mockFeatureFlagsService; | ||
| let req; | ||
| let res; | ||
| let next; | ||
|
|
||
| beforeEach(async () => { | ||
| jest.resetModules(); | ||
|
|
||
| mockFeatureFlagsService = { | ||
| isEnabled: jest.fn(), | ||
| getVariant: jest.fn(), | ||
| }; | ||
|
|
||
| jest.unstable_mockModule('../services/analytics.featureFlags.service.js', () => ({ | ||
| default: mockFeatureFlagsService, | ||
| })); | ||
|
|
||
| const mod = await import('../middlewares/analytics.requireFeatureFlag.js'); | ||
| requireFeatureFlag = mod.default; | ||
|
|
||
| req = { | ||
| user: { _id: 'user-123' }, | ||
| organization: { _id: 'org-456' }, | ||
| }; | ||
|
PierreBrisorgueil marked this conversation as resolved.
|
||
|
|
||
| res = { | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn().mockReturnThis(), | ||
| }; | ||
|
|
||
| next = jest.fn(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| test('should call next when flag is enabled', async () => { | ||
| mockFeatureFlagsService.isEnabled.mockResolvedValue(true); | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledWith(); | ||
| expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith( | ||
| 'beta-feature', | ||
| 'user-123', | ||
| { groups: { company: 'org-456' } }, | ||
| ); | ||
| }); | ||
|
|
||
| test('should return 403 when flag is disabled', async () => { | ||
| mockFeatureFlagsService.isEnabled.mockResolvedValue(false); | ||
| mockFeatureFlagsService.getVariant.mockResolvedValue(false); | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(next).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| expect(res.json).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| type: 'error', | ||
| message: 'Forbidden', | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| test('should call next when analytics is not configured (fail-open)', async () => { | ||
| mockFeatureFlagsService.isEnabled.mockResolvedValue(false); | ||
| mockFeatureFlagsService.getVariant.mockResolvedValue(undefined); | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledWith(); | ||
| }); | ||
|
|
||
| test('should return 401 when user is not authenticated', async () => { | ||
| req.user = undefined; | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(next).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(401); | ||
| }); | ||
|
|
||
| test('should not include groups when organization is absent', async () => { | ||
| req.organization = undefined; | ||
| mockFeatureFlagsService.isEnabled.mockResolvedValue(true); | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith( | ||
| 'beta-feature', | ||
| 'user-123', | ||
| {}, | ||
| ); | ||
| expect(next).toHaveBeenCalledWith(); | ||
| }); | ||
|
|
||
| test('should call next(err) on unexpected errors', async () => { | ||
| const error = new Error('PostHog timeout'); | ||
| mockFeatureFlagsService.isEnabled.mockRejectedValue(error); | ||
|
|
||
| const middleware = requireFeatureFlag('beta-feature'); | ||
| await middleware(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledWith(error); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.