Skip to content

Conversation

@MarioAslau
Copy link

@MarioAslau MarioAslau commented Dec 16, 2025

Explanation

Right now, only the ID is used as a seed to determine what bucket the user falls into in an A/B test.

Due to this, if there’s more than 1 A/B test configured, they will never be independent of each other

Solution:
Define the distribution using profile/metametrics ID + A/B test ID as a seed instead of just profile/metametrics ID

  • added createDeterministicSeed: Convert concatenated strings into valid hex format

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Uses a per-flag deterministic seed (metaMetricsId + flag name) for threshold selection to decouple A/B assignments across flags.

  • Controller
    • Use per-flag deterministic seed for threshold-based flags: createDeterministicSeed(metaMetricsId, remoteFeatureFlagName)generateDeterministicRandomNumber(seed).
  • Utils
    • Add createDeterministicSeed (SHA-256 via @noble/hashes), lowercases inputs, returns 0x-prefixed 32-byte hex; throws on empty ID.
  • Tests
    • Add tests ensuring independent group assignment across flags and deterministic selection across calls.
    • Update expectations for threshold outcomes; add tests for multi-version + A/B selection.
    • Add comprehensive tests for createDeterministicSeed (format, casing, errors).
  • Dependencies
    • Add @noble/hashes.

Written by Cursor Bugbot for commit 526f5fe. This will update automatically on new commits. Configure here.

@MarioAslau MarioAslau requested review from a team as code owners December 16, 2025 03:47
@MarioAslau MarioAslau requested a review from salimtb December 16, 2025 03:48
@MarioAslau MarioAslau removed the request for review from salimtb December 17, 2025 00:05
@MarioAslau MarioAslau requested a review from asalsys December 17, 2025 00:05
}

const seed = metaMetricsId + featureFlagName;
const hashBuffer = sha256(seed);
Copy link
Member

Choose a reason for hiding this comment

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

If async is OK here we should consider using crypto.subtle.digest

Copy link
Member

Choose a reason for hiding this comment

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

Just upstreamed this which may be useful here

Copy link
Author

Choose a reason for hiding this comment

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

@FrederikBolding can you expand on that ?

I've used sha256 from noble/hashes to keep consistency with what I've noticed in other packages.

Copy link
Author

Choose a reason for hiding this comment

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

It doesn't seem to be published/exported yet in the current version of metamask/utils

Copy link
Member

Choose a reason for hiding this comment

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

It was published earlier today!

Copy link
Member

Choose a reason for hiding this comment

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

SHA-256 using native code will perform better across mobile and extension! We automatically fall back to noble if unavailable

@asalsys
Copy link
Contributor

asalsys commented Dec 17, 2025

LGTM!

@Gudahtt
Copy link
Member

Gudahtt commented Dec 17, 2025

Why was no-changelog applied?

@MarioAslau
Copy link
Author

Why was no-changelog applied?

Sorry @Gudahtt, reflex from the mobile repo


if (Array.isArray(processedValue) && thresholdValue) {
if (Array.isArray(processedValue)) {
const deterministicSeed = createDeterministicSeed(
Copy link
Member

Choose a reason for hiding this comment

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

This is a slow cryptographic operation and it runs once per flag, but we don't need to run this for all flags. It's unnecessary unless the flag has a threshold.

Perhaps we can calculate this later, after we've determined that there is a threshold on this specific flag? That way it can be skipped for non-threshold flags, which is the majority of them.

Copy link
Member

Choose a reason for hiding this comment

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

It's also not ideal to run this every time flags get updated 🤔.

*
* @param metaMetricsId - The user's MetaMetrics ID (must be non-empty)
* @param featureFlagName - The feature flag name to create unique seed per flag
* @returns A hex string with '0x' prefix suitable for generateDeterministicRandomNumber
Copy link
Member

Choose a reason for hiding this comment

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

The 0x-prefixed input for generateDeterministicRandomNumber is meant to support legacy metrics IDs. We had hoped to remove that functionality when dropping support for those legacy IDs. Migrating to a format we planned to deprecate is not ideal.

Copy link
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

The function generateDeterministicRandomNumber attempts to deterministically generate a number between 0 and 1 cheaply. That's why we didn't use a hash function.

With a hash function, it's relatively easy to ensure that the output is uniform in distribution across different inputs (we can use a hash function known to have this property, we don't need to be careful to maintain this guarantee ourselves). It's quite a bit simpler than the approach taken now in generateDeterministicRandomNumber.

If we're using a hash function anyway, there is no need to keep generateDeterministicRandomNumber around, it serves no purpose. We can derive a number between 0 and 1 directly from the hash function. I don't think it makes much sense to keep it around but pass an artificially-generated legacy metrics ID to it.

I'd recommend reconsidering this approach. Either we should attempt to find a cheaper way to derive the threshold without using a hash function, or align on that as a solution and drop the unnecessary extra code.

@Gudahtt
Copy link
Member

Gudahtt commented Dec 17, 2025

After thinking about this further, a hash function is the only viable approach I can think of.

We could hash id + flagName, and cache it so that it only needs to be generated once. That shouldn't have any significant performance issues, especially if we follow @FrederikBolding 's suggestion and use crypto.subtle.digest to take advantage of platform-specific optimizations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable A/B Testing in RemoteFeatureFlag - Make a/b tests independent

5 participants