Skip to content

fix: correct feature flag and experiment evaluation#527

Merged
Blaumaus merged 4 commits intomainfrom
fix/correct-ff-and-experiments
May 5, 2026
Merged

fix: correct feature flag and experiment evaluation#527
Blaumaus merged 4 commits intomainfrom
fix/correct-ff-and-experiments

Conversation

@Blaumaus
Copy link
Copy Markdown
Member

@Blaumaus Blaumaus commented May 5, 2026

Changes

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features

    • Custom analytics events now trigger automatic experiment exposure recording.
    • Validation prevents duplicate experiment variant keys.
    • Bot detection treats feature-flag requests as a distinct endpoint.
  • Bug Fixes

    • Targeting rules no longer match when visitor attributes are missing.
    • Flag evaluation returns empty flags for bots or missing/inactive projects.
  • Performance

    • Earlier rate-limiting, bot checks, cached project lookup, and exposure-based attribution improve evaluation and experiment reporting.

@Blaumaus Blaumaus self-assigned this May 5, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 774a6b8a-2550-45f3-b0f9-df1430ec7298

📥 Commits

Reviewing files that changed from the base of the PR and between 05c48e6 and 42d4a2b.

📒 Files selected for processing (1)
  • backend/apps/community/src/feature-flag/feature-flag.controller.ts

📝 Walkthrough

Walkthrough

Adds async experiment exposure tracking for custom events, an exposure-attribution subquery for experiment results/charts, unique-variant-key validation, stricter targeting-rule handling for missing attributes, earlier rate-limiting/bot checks and Redis-backed project loading for flag evaluation, forwardRef module wiring, and adds feature_flag to BotEndpoint.

Changes

Experiment Exposure Tracking

Layer / File(s) Summary
Module Wiring
backend/apps/cloud/src/analytics/analytics.module.ts
AnalyticsModule imports ExperimentModule via forwardRef to allow ExperimentService injection.
Controller Integration
backend/apps/cloud/src/analytics/analytics.controller.ts
AnalyticsController injects ExperimentService; after inserting a custom event it asynchronously calls trackCustomEventExperimentExposures(pid, eventName, profileId, created) which finds running experiments (CUSTOM_EVENT), computes deterministic variantKey via getExperimentVariant on sorted variants + profileId, builds exposure rows and async-inserts them into experiment_exposures; errors are caught and logged.
Type Extension
backend/apps/cloud/src/analytics/bot-detection.service.ts, backend/apps/community/src/analytics/bot-detection.service.ts
Added 'feature_flag' to the BotEndpoint union type.

Experiment Results, Attribution & Variant Validation

Layer / File(s) Summary
Validation
backend/apps/cloud/src/experiment/experiment.controller.ts
Added validateUniqueVariantKeys(variants) and call sites in create/update to reject duplicate variant.key values (throws BadRequestException).
Feature-Flag Linking
backend/apps/cloud/src/experiment/experiment.controller.ts
Refactored updateExperiment linkage logic: compute target mode/flag id, validate ownership, unlink previous flag, and link/clear experimentId on the target flag as appropriate.
Exposure Attribution Subquery
backend/apps/cloud/src/experiment/experiment.controller.ts
Added getExposureAttributionSubquery(experiment) producing a ClickHouse subquery that attributes one variantKey and exposureCreated per (pid, profileId) according to multipleVariantHandling (FIRST_EXPOSURE/EXCLUDE/etc.).
Results / Charts
backend/apps/cloud/src/experiment/experiment.controller.ts
getExperimentResults and generateExperimentChart now use the attribution subquery for exposures/conversions and constrain conversions with c.created >= e.exposureCreated to align table and chart semantics.

Feature-Flag Evaluation Flow & Wiring

Layer / File(s) Summary
Targeting & Bucketing
backend/apps/cloud/src/feature-flag/evaluation.ts, backend/apps/community/src/feature-flag/evaluation.ts
matchesTargetingRules no longer returns true when attributes is missing; missing attributes cause inclusive rules to fail and exclusive rules to be skipped. getExperimentVariant changes hashing normalization and returns null when no variant threshold matches.
Controller Request Flow
backend/apps/cloud/src/feature-flag/feature-flag.controller.ts, backend/apps/community/src/feature-flag/feature-flag.controller.ts
evaluateFlags now extracts user-agent/origin, applies IP and project rate limits early, runs analyticsService.checkBot (returns { flags: {} } for bots), uses projectService.getRedisProject with fail-closed fallback, enforces IP/origin/country blacklists earlier, and (cloud) switches flag retrieval from findEnabledByProject to findByProject. Experiment lookups for flag-linked experiments now filter by ExposureTrigger.FEATURE_FLAG.
Service & Module Wiring
backend/apps/cloud/src/feature-flag/feature-flag.service.ts, backend/apps/cloud/src/feature-flag/feature-flag.module.ts
FeatureFlagService.delete accepts an optional EntityManager for transactional deletes; FeatureFlagModule imports AnalyticsModule via forwardRef.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AnalyticsController
    participant ExperimentService
    participant ClickHouse
    Client->>AnalyticsController: POST /analytics/custom (pid, ev, profileId)
    AnalyticsController->>ClickHouse: insert transformed event into events table
    AnalyticsController->>ExperimentService: find running experiments (pid, CUSTOM_EVENT, eventName)
    ExperimentService-->>AnalyticsController: experiments + variants
    AnalyticsController->>AnalyticsController: sort variants, compute variantKey via getExperimentVariant(profileId)
    AnalyticsController->>ClickHouse: async_insert JSONEachRow -> experiment_exposures (pid, experimentId, variantKey, profileId, created)
    ClickHouse-->>AnalyticsController: async_insert result (logged)
    AnalyticsController-->>Client: 200 OK (event response)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Swetrix/swetrix#451: Introduces experiment entities/services and initial experiment flows that this PR extends.
  • Swetrix/swetrix#526: Related analytics controller changes and custom-event handling that overlap with this PR.
  • Swetrix/swetrix#450: Related feature-flag / analytics integration changes referenced by this PR.

Poem

🐰 I hopped through logs and hashes bright,
Events recorded in the night.
Variants chosen, keys made true,
Charts now match what exposures do.
A little hop — experiments pursue.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: correct feature flag and experiment evaluation' directly and concisely summarizes the main change—correcting feature flag and experiment evaluation behavior across the codebase.
Description check ✅ Passed The pull request description follows the template structure with all required sections completed or appropriately marked. Key decisions are documented: Community Edition support confirmed, no schema migrations needed, and no public endpoint documentation changes required.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/correct-ff-and-experiments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/apps/community/src/feature-flag/feature-flag.controller.ts (1)

263-316: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep disabled flags out of the public evaluation payload.

Switching to findByProject() makes this unauthenticated endpoint return disabled flag keys as false. That exposes staged/internal flag names to anyone with a project id, and it also records synthetic false evaluations for disabled flags in feature_flag_evaluations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/community/src/feature-flag/feature-flag.controller.ts` around
lines 263 - 316, The endpoint is returning disabled flags as false because you
call featureFlagService.findByProject() and pass all flags to evaluateFlags and
trackEvaluations; filter out flags where flag.enabled (or staged/enabled
property) is false before calling this.featureFlagService.evaluateFlags and
before invoking this.trackEvaluations so disabled flags are neither evaluated
nor recorded; alternatively use a service method like
featureFlagService.findEnabledByProject or apply flags = flags.filter(f =>
f.enabled) prior to evaluateFlags/trackEvaluations so the returned payload and
feature_flag_evaluations contain only enabled flags.
backend/apps/cloud/src/feature-flag/evaluation.ts (1)

187-195: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't force unallocated traffic into the last variant.

When the variant percentages add up to less than 100, this fallback silently assigns the remainder to the last variant instead of returning no assignment. That skews experiment exposure and results.

Proposed fix
   for (const variant of variants) {
     cumulativePercentage += variant.rolloutPercentage
     if (normalizedValue < cumulativePercentage) {
       return variant.key
     }
   }

-  return variants[variants.length - 1].key
+  return null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/feature-flag/evaluation.ts` around lines 187 - 195,
The current loop forcibly assigns any leftover traffic to the last variant by
returning variants[variants.length - 1].key when cumulativePercentage < 100;
change this so that if normalizedValue is not less than the running
cumulativePercentage (i.e., the variant percentages sum to less than 100 and the
user falls in the unallocated range) the function returns no assignment (null or
undefined) instead of defaulting to the last variant. Modify the logic around
cumulativePercentage, variants, and normalizedValue so that after the for-loop
you return null/undefined when the normalizedValue wasn't matched, rather than
returning the last variant key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/cloud/src/analytics/analytics.controller.ts`:
- Around line 1674-1679: The code is awaiting
trackCustomEventExperimentExposures(...) which forces the custom-event request
to wait for an extra DB read/ClickHouse write; remove the await and invoke
trackCustomEventExperimentExposures(eventsDTO.pid, eventsDTO.ev, profileId,
transformed.created) as a fire-and-forget call so the request path isn't blocked
(the function already catches/logs its own errors). Ensure the call remains in
the same place (same parameters) but is not awaited; do not add additional
synchronous error handling that would re-block the request.

In `@backend/apps/cloud/src/experiment/experiment.controller.ts`:
- Around line 1192-1216: In getExposureAttributionSubquery, replace the
variantSelector expression for the MultipleVariantHandling.EXCLUDE branch
(currently yielding any(variantKey)) with the same argMin(variantKey,
tuple(created, variantKey)) used for FIRST_EXPOSURE so the selected variant is
explicitly paired with min(created); update the variantSelector conditional that
checks experiment.multipleVariantHandling (and keep existing
multiVariantFilter/HAVING logic) so both FIRST_EXPOSURE and EXCLUDE use argMin
for clear variant-timestamp pairing.

In `@backend/apps/cloud/src/feature-flag/feature-flag.controller.ts`:
- Around line 299-304: The code currently swallows Redis misses by returning {
flags: {} } when this.projectService.getRedisProject(...) throws; instead, treat
Redis as a fast path and fall back to the authoritative lookup: catch errors
from getRedisProject and call the authoritative method (e.g.
this.projectService.getProject(evaluateDto.pid) or equivalent) to retrieve the
project, only returning { flags: {} } if the authoritative lookup returns no
project; apply the same change for the second occurrence at lines ~308-309 so
transient Redis failures don't produce a silent "all flags off" response.

---

Outside diff comments:
In `@backend/apps/cloud/src/feature-flag/evaluation.ts`:
- Around line 187-195: The current loop forcibly assigns any leftover traffic to
the last variant by returning variants[variants.length - 1].key when
cumulativePercentage < 100; change this so that if normalizedValue is not less
than the running cumulativePercentage (i.e., the variant percentages sum to less
than 100 and the user falls in the unallocated range) the function returns no
assignment (null or undefined) instead of defaulting to the last variant. Modify
the logic around cumulativePercentage, variants, and normalizedValue so that
after the for-loop you return null/undefined when the normalizedValue wasn't
matched, rather than returning the last variant key.

In `@backend/apps/community/src/feature-flag/feature-flag.controller.ts`:
- Around line 263-316: The endpoint is returning disabled flags as false because
you call featureFlagService.findByProject() and pass all flags to evaluateFlags
and trackEvaluations; filter out flags where flag.enabled (or staged/enabled
property) is false before calling this.featureFlagService.evaluateFlags and
before invoking this.trackEvaluations so disabled flags are neither evaluated
nor recorded; alternatively use a service method like
featureFlagService.findEnabledByProject or apply flags = flags.filter(f =>
f.enabled) prior to evaluateFlags/trackEvaluations so the returned payload and
feature_flag_evaluations contain only enabled flags.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b77aa91e-3b91-41d3-aeea-127279b1a23f

📥 Commits

Reviewing files that changed from the base of the PR and between f267387 and af99d99.

📒 Files selected for processing (11)
  • backend/apps/cloud/src/analytics/analytics.controller.ts
  • backend/apps/cloud/src/analytics/analytics.module.ts
  • backend/apps/cloud/src/analytics/bot-detection.service.ts
  • backend/apps/cloud/src/experiment/experiment.controller.ts
  • backend/apps/cloud/src/feature-flag/evaluation.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.module.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.service.ts
  • backend/apps/community/src/analytics/bot-detection.service.ts
  • backend/apps/community/src/feature-flag/evaluation.ts
  • backend/apps/community/src/feature-flag/feature-flag.controller.ts

Comment thread backend/apps/cloud/src/analytics/analytics.controller.ts Outdated
Comment thread backend/apps/cloud/src/experiment/experiment.controller.ts
Comment thread backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/apps/cloud/src/feature-flag/evaluation.ts (1)

183-195: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the experiment hash bucket strictly below 100.

hashValue / 0xffffffff can evaluate to exactly 100 when the first 32 bits are all 1. With the new return null fallback, that bucket never matches any variant even when rollout totals add up to 100.

Suggested fix
-  const normalizedValue = (hashValue / 0xffffffff) * 100
+  const normalizedValue = (hashValue / 0x100000000) * 100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/feature-flag/evaluation.ts` around lines 183 - 195,
The normalized bucket can equal 100 when hashValue is 0xffffffff, causing no
variant to match; update the bucket calculation so it never reaches 100 by
dividing by 0x100000000 (or 4294967296) instead of 0xffffffff when computing
normalizedValue, i.e. change the divisor used in normalizedValue computation
(the variables involved are hashValue and normalizedValue) so the loop over
variants (using cumulativePercentage and variant.rolloutPercentage) will
correctly match when rollouts total 100.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/cloud/src/analytics/analytics.controller.ts`:
- Around line 1746-1750: Change the direct awaited ClickHouse insert into a
fire-and-forget async_insert call: call clickhouse.insert({ table:
'experiment_exposures', values: exposures, format: 'JSONEachRow',
clickhouse_settings: { async_insert: 1 } }) without awaiting it and attach a
.catch to log any error (so the request path isn't blocked but errors are
recorded); update the call site that currently awaits clickhouse.insert(...) in
analytics.controller.ts and reference the exposures variable and the
clickhouse.insert invocation when making the change.

In `@backend/apps/community/src/feature-flag/feature-flag.controller.ts`:
- Around line 243-248: The catch block around the call to
this.projectService.getRedisProject(evaluateDto.pid) silently swallows errors;
update that catch to log a warning via this.logger.warn (include the caught
error and the evaluateDto.pid/context) before returning { flags: {} } so Redis
failures are visible in logs; reference the getRedisProject call and mirror the
style used in analyticsService.checkBot for message formatting and error
inclusion.

---

Outside diff comments:
In `@backend/apps/cloud/src/feature-flag/evaluation.ts`:
- Around line 183-195: The normalized bucket can equal 100 when hashValue is
0xffffffff, causing no variant to match; update the bucket calculation so it
never reaches 100 by dividing by 0x100000000 (or 4294967296) instead of
0xffffffff when computing normalizedValue, i.e. change the divisor used in
normalizedValue computation (the variables involved are hashValue and
normalizedValue) so the loop over variants (using cumulativePercentage and
variant.rolloutPercentage) will correctly match when rollouts total 100.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ddb91da-a99f-4dc4-a168-700a81781218

📥 Commits

Reviewing files that changed from the base of the PR and between af99d99 and e695056.

📒 Files selected for processing (5)
  • backend/apps/cloud/src/analytics/analytics.controller.ts
  • backend/apps/cloud/src/experiment/experiment.controller.ts
  • backend/apps/cloud/src/feature-flag/evaluation.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/community/src/feature-flag/feature-flag.controller.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/cloud/src/experiment/experiment.controller.ts

Comment thread backend/apps/cloud/src/analytics/analytics.controller.ts Outdated
Comment thread backend/apps/community/src/feature-flag/feature-flag.controller.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/community/src/feature-flag/feature-flag.controller.ts`:
- Around line 203-206: The Swagger ApiOperation description claims it “Evaluates
all feature flags” but the handler invokes findEnabledByProject(...), so update
the contract to match implementation: either change the description text in the
ApiOperation decorators to say “Evaluates enabled feature flags” or change the
lookup call from findEnabledByProject(...) to the method that returns all flags
(e.g., findByProject(...) or equivalent). Apply the same fix to the second
occurrence referenced (around the other ApiOperation at lines ~267-269) so
descriptions and query logic remain consistent.
- Around line 222-240: The per-project rate limiter
checkRateLimit(evaluateDto.pid, 'feature-flag-evaluate-project', 5000, 60) is
being executed before analyticsService.checkBot(...) so bot requests get charged
against the shared project bucket; modify the flow so you call
this.analyticsService.checkBot(...) first and, if botResult.isBot is true,
return { flags: {} } without invoking checkRateLimit, or alter checkRateLimit to
accept a skip/metadata parameter and short-circuit when botResult.isBot; ensure
you reference checkRateLimit, this.analyticsService.checkBot, and
evaluateDto.pid when changing the order or adding the skip logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cef120e-c158-4d75-8947-e4a5deaea296

📥 Commits

Reviewing files that changed from the base of the PR and between e695056 and 05c48e6.

📒 Files selected for processing (3)
  • backend/apps/cloud/src/analytics/analytics.controller.ts
  • backend/apps/cloud/src/feature-flag/evaluation.ts
  • backend/apps/community/src/feature-flag/feature-flag.controller.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/apps/cloud/src/feature-flag/evaluation.ts

Comment thread backend/apps/community/src/feature-flag/feature-flag.controller.ts Outdated
Comment thread backend/apps/community/src/feature-flag/feature-flag.controller.ts Outdated
@Blaumaus Blaumaus merged commit 6b212e7 into main May 5, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant