From af99d99a5c46ecc208c03b59636820f61dffc702 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 15:56:24 +0100 Subject: [PATCH 1/4] fix: correct feature flag and experiment evaluation --- .../src/analytics/analytics.controller.ts | 83 ++++++++ .../cloud/src/analytics/analytics.module.ts | 2 + .../src/analytics/bot-detection.service.ts | 1 + .../src/experiment/experiment.controller.ts | 177 ++++++++++++------ .../apps/cloud/src/feature-flag/evaluation.ts | 9 +- .../feature-flag/feature-flag.controller.ts | 56 +++++- .../src/feature-flag/feature-flag.module.ts | 2 +- .../src/feature-flag/feature-flag.service.ts | 7 +- .../src/analytics/bot-detection.service.ts | 1 + .../community/src/feature-flag/evaluation.ts | 13 +- .../feature-flag/feature-flag.controller.ts | 47 ++++- 11 files changed, 310 insertions(+), 88 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 4f3063996..1c5795413 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -95,6 +95,12 @@ import { GetKeywordsDto } from './dto/get-keywords.dto' import { GetBotStatsDto } from './dto/get-bot-stats.dto' import { GSCService } from '../project/gsc.service' import { GetProfileIdDto, GetSessionIdDto } from './dto/get-id.dto' +import { ExperimentService } from '../experiment/experiment.service' +import { + ExperimentStatus, + ExposureTrigger, +} from '../experiment/entity/experiment.entity' +import { getExperimentVariant } from '../feature-flag/evaluation' dayjs.extend(utc) dayjs.extend(dayjsTimezone) @@ -205,6 +211,7 @@ export class AnalyticsController { private readonly analyticsService: AnalyticsService, private readonly logger: AppLoggerService, private readonly gscService: GSCService, + private readonly experimentService: ExperimentService, ) {} @ApiBearerAuth() @@ -1663,6 +1670,13 @@ export class AnalyticsController { values: [transformed], clickhouse_settings: { async_insert: 1 }, }) + + await this.trackCustomEventExperimentExposures( + eventsDTO.pid, + eventsDTO.ev, + profileId, + transformed.created, + ) } catch (reason) { this.logger.error(reason) throw new InternalServerErrorException( @@ -1673,6 +1687,75 @@ export class AnalyticsController { return {} } + private async trackCustomEventExperimentExposures( + pid: string, + eventName: string, + profileId: string, + created: string, + ) { + try { + const experiments = await this.experimentService.find({ + where: { + project: { id: pid }, + status: ExperimentStatus.RUNNING, + exposureTrigger: ExposureTrigger.CUSTOM_EVENT, + customEventName: eventName, + }, + relations: ['variants'], + }) + + if (_isEmpty(experiments)) { + return + } + + const exposures = [] + for (const experiment of experiments) { + if (!experiment.variants || experiment.variants.length === 0) { + continue + } + + const sortedVariants = [...experiment.variants].sort((a, b) => + a.key.localeCompare(b.key), + ) + const variantKey = getExperimentVariant( + experiment.id, + sortedVariants.map((variant) => ({ + key: variant.key, + rolloutPercentage: variant.rolloutPercentage, + })), + profileId, + ) + + if (!variantKey) { + continue + } + + exposures.push({ + pid, + experimentId: experiment.id, + variantKey, + profileId, + created, + }) + } + + if (_isEmpty(exposures)) { + return + } + + await clickhouse.insert({ + table: 'experiment_exposures', + values: exposures, + format: 'JSONEachRow', + }) + } catch (reason) { + this.logger.warn( + { reason }, + 'Failed to track custom event experiment exposures', + ) + } + } + @Post('hb') @Auth(true, true) async heartbeat( diff --git a/backend/apps/cloud/src/analytics/analytics.module.ts b/backend/apps/cloud/src/analytics/analytics.module.ts index 529dda282..3e348ce82 100644 --- a/backend/apps/cloud/src/analytics/analytics.module.ts +++ b/backend/apps/cloud/src/analytics/analytics.module.ts @@ -11,6 +11,7 @@ import { UserModule } from '../user/user.module' import { AppLoggerModule } from '../logger/logger.module' import { ProjectModule } from '../project/project.module' import { RevenueModule } from '../revenue/revenue.module' +import { ExperimentModule } from '../experiment/experiment.module' @Module({ imports: [ @@ -19,6 +20,7 @@ import { RevenueModule } from '../revenue/revenue.module' AppLoggerModule, ProjectModule, forwardRef(() => RevenueModule), + forwardRef(() => ExperimentModule), ], providers: [ AnalyticsService, diff --git a/backend/apps/cloud/src/analytics/bot-detection.service.ts b/backend/apps/cloud/src/analytics/bot-detection.service.ts index 79175a021..cab195c09 100644 --- a/backend/apps/cloud/src/analytics/bot-detection.service.ts +++ b/backend/apps/cloud/src/analytics/bot-detection.service.ts @@ -20,6 +20,7 @@ export type BotEndpoint = | 'pageview' | 'custom' | 'error' + | 'feature_flag' | 'heartbeat' | 'noscript' diff --git a/backend/apps/cloud/src/experiment/experiment.controller.ts b/backend/apps/cloud/src/experiment/experiment.controller.ts index 869176359..d4c4bf91a 100644 --- a/backend/apps/cloud/src/experiment/experiment.controller.ts +++ b/backend/apps/cloud/src/experiment/experiment.controller.ts @@ -74,6 +74,17 @@ import { Pagination } from '../common/pagination' const EXPERIMENTS_MAXIMUM = 20 // Maximum experiments per project const FEATURE_FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ +const validateUniqueVariantKeys = (variants: Array<{ key: string }>): void => { + const seen = new Set() + + for (const variant of variants) { + if (seen.has(variant.key)) { + throw new BadRequestException('Variant keys must be unique') + } + seen.add(variant.key) + } +} + type GoalEventConditions = { eventType: 'pageview' | 'custom_event' matchColumn: 'pg' | 'event_name' @@ -253,6 +264,8 @@ export class ExperimentController { ) } + validateUniqueVariantKeys(experimentDto.variants) + const controlVariants = experimentDto.variants.filter((v) => v.isControl) if (controlVariants.length !== 1) { throw new BadRequestException( @@ -445,6 +458,8 @@ export class ExperimentController { ) } + validateUniqueVariantKeys(experimentDto.variants) + const controlVariants = experimentDto.variants.filter( (v) => v.isControl, ) @@ -484,31 +499,47 @@ export class ExperimentController { } let featureFlag = experiment.featureFlag - if ( - experimentDto.featureFlagMode !== undefined && - experimentDto.featureFlagMode !== experiment.featureFlagMode - ) { - if (experimentDto.featureFlagMode === FeatureFlagMode.LINK) { - if (!experimentDto.existingFeatureFlagId) { - throw new BadRequestException( - 'Feature flag ID is required when linking an existing flag', - ) - } - const existingFlag = await this.featureFlagService.findOne({ - where: { - id: experimentDto.existingFeatureFlagId, - project: { id: experiment.project.id }, - }, - }) - if (!existingFlag) { - throw new NotFoundException('Feature flag not found') - } - if (existingFlag.experimentId && existingFlag.experimentId !== id) { - throw new BadRequestException( - 'This feature flag is already linked to another experiment', + const targetFeatureFlagMode = + experimentDto.featureFlagMode ?? experiment.featureFlagMode + + if (targetFeatureFlagMode === FeatureFlagMode.LINK) { + const targetFlagId = + experimentDto.existingFeatureFlagId ?? + (experiment.featureFlagMode === FeatureFlagMode.LINK + ? experiment.featureFlag?.id + : undefined) + + if (!targetFlagId) { + throw new BadRequestException( + 'Feature flag ID is required when linking an existing flag', + ) + } + + const existingFlag = await this.featureFlagService.findOne({ + where: { + id: targetFlagId, + project: { id: experiment.project.id }, + }, + }) + if (!existingFlag) { + throw new NotFoundException('Feature flag not found') + } + if (existingFlag.experimentId && existingFlag.experimentId !== id) { + throw new BadRequestException( + 'This feature flag is already linked to another experiment', + ) + } + + if ( + experiment.featureFlag && + experiment.featureFlag.id !== existingFlag.id + ) { + if (experiment.featureFlagMode === FeatureFlagMode.CREATE) { + await this.featureFlagService.delete( + experiment.featureFlag.id, + transactionalEntityManager, ) - } - if (experiment.featureFlag) { + } else { await this.featureFlagService.update( experiment.featureFlag.id, { @@ -517,6 +548,9 @@ export class ExperimentController { transactionalEntityManager, ) } + } + + if (existingFlag.experimentId !== id) { await this.featureFlagService.update( existingFlag.id, { @@ -524,8 +558,22 @@ export class ExperimentController { }, transactionalEntityManager, ) - featureFlag = existingFlag } + + featureFlag = existingFlag + } else if ( + targetFeatureFlagMode === FeatureFlagMode.CREATE && + experiment.featureFlag && + experiment.featureFlagMode === FeatureFlagMode.LINK + ) { + await this.featureFlagService.update( + experiment.featureFlag.id, + { + experimentId: null, + }, + transactionalEntityManager, + ) + featureFlag = null } const updatePayload: Partial = { @@ -919,15 +967,16 @@ export class ExperimentController { diff, ) + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) + const exposuresQuery = ` - SELECT + SELECT variantKey, - uniqExact(profileId) as exposures - FROM experiment_exposures - WHERE - pid = {pid:FixedString(12)} - AND experimentId = {experimentId:String} - AND created BETWEEN {groupFrom:String} AND {groupTo:String} + count() as exposures + FROM ( + ${exposureAttributionSubquery} + ) GROUP BY variantKey ` @@ -964,14 +1013,14 @@ export class ExperimentController { SELECT e.variantKey, uniqExact(e.profileId) as conversions - FROM experiment_exposures e + FROM ( + ${exposureAttributionSubquery} + ) e INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} - AND e.experimentId = {experimentId:String} - AND e.created BETWEEN {groupFrom:String} AND {groupTo:String} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} - AND c.created >= e.created + AND c.created >= e.exposureCreated AND ${matchCondition} ${metaCondition} GROUP BY e.variantKey @@ -1140,6 +1189,33 @@ export class ExperimentController { } } + private getExposureAttributionSubquery(experiment: Experiment): string { + const variantSelector = + experiment.multipleVariantHandling === + MultipleVariantHandling.FIRST_EXPOSURE + ? 'argMin(variantKey, tuple(created, variantKey))' + : 'any(variantKey)' + const multiVariantFilter = + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE + ? 'HAVING uniqExact(variantKey) = 1' + : '' + + return ` + SELECT + pid, + profileId, + ${variantSelector} as variantKey, + min(created) as exposureCreated + FROM experiment_exposures + WHERE + pid = {pid:FixedString(12)} + AND experimentId = {experimentId:String} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY pid, profileId + ${multiVariantFilter} + ` + } + /** * Generate time-series chart data for experiment win probabilities */ @@ -1159,16 +1235,18 @@ export class ExperimentController { safeTimezone, ) - // Important: table results use overall uniqExact(profileId) counts per variant. + // Important: table results use one attributed variant per profile. // For the time-series we must avoid "summing per-bucket uniques", because a profile // can appear in multiple time buckets (re-exposed / repeated events). Instead we: - // - bucket exposures by the first exposure per (variantKey, profileId) - // - bucket conversions by the first conversion per (variantKey, profileId) + // - bucket exposures by each profile's attributed exposure timestamp + // - bucket conversions by the first conversion per attributed variant/profile // This guarantees that the last chart point uses the same data as the table. const dateColumnsGroupBy = this.getTimeBucketDateColumnsGroupBy(timeBucket) + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) const exposuresDateColumnsSelect = this.getTimeBucketDateColumnsSelect( timeBucket, - 'firstCreated', + 'exposureCreated', ) const exposuresQuery = ` @@ -1177,16 +1255,7 @@ export class ExperimentController { variantKey, count() as exposures FROM ( - SELECT - variantKey, - profileId, - min(created) as firstCreated - FROM experiment_exposures - WHERE - pid = {pid:FixedString(12)} - AND experimentId = {experimentId:String} - AND created BETWEEN {groupFrom:String} AND {groupTo:String} - GROUP BY variantKey, profileId + ${exposureAttributionSubquery} ) GROUP BY ${dateColumnsGroupBy}, variantKey ORDER BY ${dateColumnsGroupBy} @@ -1234,14 +1303,14 @@ export class ExperimentController { e.variantKey as variantKey, e.profileId as profileId, min(c.created) as firstConversion - FROM experiment_exposures e + FROM ( + ${exposureAttributionSubquery} + ) e INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} - AND e.experimentId = {experimentId:String} - AND e.created BETWEEN {groupFrom:String} AND {groupTo:String} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} - AND c.created >= e.created + AND c.created >= e.exposureCreated AND ${matchCondition} ${metaCondition} GROUP BY e.variantKey, e.profileId diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 6665c1dba..00f3ef2b4 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -87,14 +87,13 @@ function matchesTargetingRules( rules: TargetingRule[], attributes?: Record, ): boolean { - if (!attributes) { - return true - } - for (const rule of rules) { - const attributeValue = attributes[rule.column] + const attributeValue = attributes?.[rule.column] if (attributeValue === undefined) { + if (!rule.isExclusive) { + return false + } continue } diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts index 38f84dd2d..110b1590d 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts @@ -50,9 +50,12 @@ import { } from './dto/feature-flag.dto' import { FeatureFlagService } from './feature-flag.service' import { ExperimentService } from '../experiment/experiment.service' -import { ExperimentStatus } from '../experiment/entity/experiment.entity' +import { + ExperimentStatus, + ExposureTrigger, +} from '../experiment/entity/experiment.entity' import { clickhouse } from '../common/integrations/clickhouse' -import { getIPFromHeaders, getIPDetails } from '../common/utils' +import { checkRateLimit, getIPFromHeaders, getIPDetails } from '../common/utils' import { getExperimentVariant } from './evaluation' import { trackCustom } from '../common/analytics' @@ -256,7 +259,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -266,10 +269,39 @@ export class FeatureFlagController { this.logger.log({ pid: evaluateDto.pid }, 'POST /feature-flag/evaluate') const ip = getIPFromHeaders(headers) || reqIP || '' + const userAgent = headers['user-agent'] || '' + const origin = headers.origin || '' - const project = await this.projectService.findOne({ - where: { id: evaluateDto.pid }, - }) + if (ip) { + await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) + } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + + const botResult = await this.analyticsService.checkBot( + evaluateDto.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + null, + 'feature_flag', + ) + + if (botResult.isBot) { + return { flags: {} } + } + + let project + try { + project = await this.projectService.getRedisProject(evaluateDto.pid) + } catch { + return { flags: {} } + } // Return empty flags instead of revealing whether a project exists // This prevents project ID enumeration attacks @@ -277,13 +309,16 @@ export class FeatureFlagController { return { flags: {} } } - const flags = await this.featureFlagService.findEnabledByProject( - evaluateDto.pid, - ) + this.analyticsService.checkIpBlacklist(project, ip) + this.analyticsService.checkOrigin(project, origin) + this.analyticsService.checkIfAccountSuspended(project) // Derive attributes from request headers (like analytics does) - const userAgent = headers['user-agent'] || '' const { country, city, region } = getIPDetails(ip) + this.analyticsService.checkCountryBlacklist(project, country) + + const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) @@ -337,6 +372,7 @@ export class FeatureFlagController { where: flagsWithExperiments.map((f) => ({ id: f.experimentId, status: ExperimentStatus.RUNNING, + exposureTrigger: ExposureTrigger.FEATURE_FLAG, })), relations: ['variants'], }) diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.module.ts b/backend/apps/cloud/src/feature-flag/feature-flag.module.ts index ebf6926f9..993b5b4f8 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.module.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.module.ts @@ -16,7 +16,7 @@ import { FeatureFlagController } from './feature-flag.controller' ProjectModule, AppLoggerModule, UserModule, - AnalyticsModule, + forwardRef(() => AnalyticsModule), forwardRef(() => ExperimentModule), ], providers: [FeatureFlagService], diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.service.ts b/backend/apps/cloud/src/feature-flag/feature-flag.service.ts index cbfeedad5..6ceb4c47f 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.service.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.service.ts @@ -113,8 +113,11 @@ export class FeatureFlagService { return repository.update(id, flagData) } - async delete(id: string): Promise { - return this.featureFlagRepository.delete(id) + async delete(id: string, manager?: EntityManager): Promise { + const repository = manager + ? manager.getRepository(FeatureFlag) + : this.featureFlagRepository + return repository.delete(id) } /** diff --git a/backend/apps/community/src/analytics/bot-detection.service.ts b/backend/apps/community/src/analytics/bot-detection.service.ts index 69553ad49..8bf54ddc6 100644 --- a/backend/apps/community/src/analytics/bot-detection.service.ts +++ b/backend/apps/community/src/analytics/bot-detection.service.ts @@ -20,6 +20,7 @@ export type BotEndpoint = | 'pageview' | 'custom' | 'error' + | 'feature_flag' | 'heartbeat' | 'noscript' diff --git a/backend/apps/community/src/feature-flag/evaluation.ts b/backend/apps/community/src/feature-flag/evaluation.ts index eac9c8115..758a54147 100644 --- a/backend/apps/community/src/feature-flag/evaluation.ts +++ b/backend/apps/community/src/feature-flag/evaluation.ts @@ -91,18 +91,13 @@ function matchesTargetingRules( rules: TargetingRule[], attributes?: Record, ): boolean { - if (!attributes) { - // If no attributes provided, we can't match any rules - // Return true to be permissive (flag will be shown) - return true - } - for (const rule of rules) { - const attributeValue = attributes[rule.column] + const attributeValue = attributes?.[rule.column] - // Check if we have the attribute if (attributeValue === undefined) { - // If attribute not provided, skip this rule (be permissive) + if (!rule.isExclusive) { + return false + } continue } diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 0f1245fd5..167399daa 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -44,7 +44,7 @@ import { } from './dto/feature-flag.dto' import { FeatureFlagService } from './feature-flag.service' import { clickhouse } from '../common/integrations/clickhouse' -import { getIPFromHeaders, getIPDetails } from '../common/utils' +import { checkRateLimit, getIPFromHeaders, getIPDetails } from '../common/utils' const FEATURE_FLAGS_MAXIMUM = 50 // Maximum feature flags per project const FEATURE_FLAGS_PAGINATION_MAX_TAKE = 100 @@ -203,7 +203,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -213,8 +213,39 @@ export class FeatureFlagController { this.logger.log({ pid: evaluateDto.pid }, 'POST /feature-flag/evaluate') const ip = getIPFromHeaders(headers) || reqIP || '' + const userAgent = headers['user-agent'] || '' + const origin = headers.origin || '' - const project = await this.projectService.getRedisProject(evaluateDto.pid) + if (ip) { + await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) + } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + + const botResult = await this.analyticsService.checkBot( + evaluateDto.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + null, + 'feature_flag', + ) + + if (botResult.isBot) { + return { flags: {} } + } + + let project + try { + project = await this.projectService.getRedisProject(evaluateDto.pid) + } catch { + return { flags: {} } + } // Return empty flags instead of revealing whether a project exists // This prevents project ID enumeration attacks @@ -222,13 +253,15 @@ export class FeatureFlagController { return { flags: {} } } - const flags = await this.featureFlagService.findEnabledByProject( - evaluateDto.pid, - ) + this.analyticsService.checkIpBlacklist(project, ip) + this.analyticsService.checkOrigin(project, origin) // Derive attributes from request headers (like analytics does) - const userAgent = headers['user-agent'] || '' const { country, city, region } = getIPDetails(ip) + this.analyticsService.checkCountryBlacklist(project, country) + + const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) From e695056ac3435ff267d4d6442874afd45ae8622c Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 17:10:46 +0100 Subject: [PATCH 2/4] fix feature flag and experiment evaluation edge cases --- backend/apps/cloud/src/analytics/analytics.controller.ts | 2 +- backend/apps/cloud/src/experiment/experiment.controller.ts | 3 ++- backend/apps/cloud/src/feature-flag/evaluation.ts | 2 +- .../apps/cloud/src/feature-flag/feature-flag.controller.ts | 2 +- .../community/src/feature-flag/feature-flag.controller.ts | 4 +++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 1c5795413..fecb0f163 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -1671,7 +1671,7 @@ export class AnalyticsController { clickhouse_settings: { async_insert: 1 }, }) - await this.trackCustomEventExperimentExposures( + void this.trackCustomEventExperimentExposures( eventsDTO.pid, eventsDTO.ev, profileId, diff --git a/backend/apps/cloud/src/experiment/experiment.controller.ts b/backend/apps/cloud/src/experiment/experiment.controller.ts index d4c4bf91a..f12e29fe9 100644 --- a/backend/apps/cloud/src/experiment/experiment.controller.ts +++ b/backend/apps/cloud/src/experiment/experiment.controller.ts @@ -1192,7 +1192,8 @@ export class ExperimentController { private getExposureAttributionSubquery(experiment: Experiment): string { const variantSelector = experiment.multipleVariantHandling === - MultipleVariantHandling.FIRST_EXPOSURE + MultipleVariantHandling.FIRST_EXPOSURE || + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE ? 'argMin(variantKey, tuple(created, variantKey))' : 'any(variantKey)' const multiVariantFilter = diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 00f3ef2b4..3c5095358 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -192,5 +192,5 @@ export function getExperimentVariant( } } - return variants[variants.length - 1].key + return null } diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts index 110b1590d..1efe4ba28 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts @@ -300,7 +300,7 @@ export class FeatureFlagController { try { project = await this.projectService.getRedisProject(evaluateDto.pid) } catch { - return { flags: {} } + project = await this.projectService.getFullProject(evaluateDto.pid) } // Return empty flags instead of revealing whether a project exists diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 167399daa..b6d9f0c15 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -260,7 +260,9 @@ export class FeatureFlagController { const { country, city, region } = getIPDetails(ip) this.analyticsService.checkCountryBlacklist(project, country) - const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const flags = await this.featureFlagService.findEnabledByProject( + evaluateDto.pid, + ) const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) From 05c48e69218a00915459e8b61080a8ab87fe1ee5 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 18:01:44 +0100 Subject: [PATCH 3/4] fix: address feature flag exposure review findings --- .../src/analytics/analytics.controller.ts | 18 +++++++++++++----- .../apps/cloud/src/feature-flag/evaluation.ts | 2 +- .../feature-flag/feature-flag.controller.ts | 6 +++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index fecb0f163..2818c83c2 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -1743,11 +1743,19 @@ export class AnalyticsController { return } - await clickhouse.insert({ - table: 'experiment_exposures', - values: exposures, - format: 'JSONEachRow', - }) + clickhouse + .insert({ + table: 'experiment_exposures', + values: exposures, + format: 'JSONEachRow', + clickhouse_settings: { async_insert: 1 }, + }) + .catch((reason) => { + this.logger.warn( + { reason }, + 'Failed to async insert custom event experiment exposures', + ) + }) } catch (reason) { this.logger.warn( { reason }, diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 3c5095358..e627ac87c 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -182,7 +182,7 @@ export function getExperimentVariant( const hashValue = parseInt(hash.substring(0, 8), 16) - const normalizedValue = (hashValue / 0xffffffff) * 100 + const normalizedValue = (hashValue / 0x100000000) * 100 let cumulativePercentage = 0 for (const variant of variants) { diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index b6d9f0c15..44e296f54 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -243,7 +243,11 @@ export class FeatureFlagController { let project try { project = await this.projectService.getRedisProject(evaluateDto.pid) - } catch { + } catch (reason) { + this.logger.warn( + `Failed to get Redis project for pid ${evaluateDto.pid} (feature_flag): ${reason}`, + 'evaluate', + ) return { flags: {} } } From 42d4a2b272164eb3be6e0e139b7625269c05863b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 18:09:07 +0100 Subject: [PATCH 4/4] fix: align feature flag evaluation behaviour --- .../src/feature-flag/feature-flag.controller.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 44e296f54..0e70b1c5c 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -203,7 +203,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -219,13 +219,6 @@ export class FeatureFlagController { if (ip) { await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) } - await checkRateLimit( - evaluateDto.pid, - 'feature-flag-evaluate-project', - 5000, - 60, - ) - const botResult = await this.analyticsService.checkBot( evaluateDto.pid, userAgent, @@ -240,6 +233,13 @@ export class FeatureFlagController { return { flags: {} } } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + let project try { project = await this.projectService.getRedisProject(evaluateDto.pid)