Skip to content

Commit

Permalink
Implement the 'rollout' config option for percentage rollouts of sub-…
Browse files Browse the repository at this point in the history
…features (#2880)

* Initial remote config component wrapper

* Initial rollout implementation and start porting Android tests

* Remaining rollout tests

* Properly mock extension version

* Use post-processed config in static methods

* Comment how rollout works

* Lint

* Add privacy-configuration as a dev dependency

* Fix bad merge

* Move config ResourceLoader into it's own component

* Move config process to class method

* Rename config to remoteConfig for clarity
  • Loading branch information
sammacbeth authored Jan 8, 2025
1 parent fb4268d commit 7860833
Show file tree
Hide file tree
Showing 11 changed files with 869 additions and 41 deletions.
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"devDependencies": {
"@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0",
"@duckduckgo/pixel-schema": "github:duckduckgo/pixel-schema#v1.0.3",
"@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration",
"@fingerprintjs/fingerprintjs": "^4.5.1",
"@playwright/test": "^1.49.1",
"@types/chrome": "^0.0.269",
Expand Down
6 changes: 5 additions & 1 deletion shared/js/background/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import TrackersGlobal from './components/trackers';
import DebuggerConnection from './components/debugger-connection';
import Devtools from './components/devtools';
import DNRListeners from './components/dnr-listeners';
import RemoteConfig from './components/remote-config';
import initDebugBuild from './devbuild';
import initReloader from './devbuild-reloader';
import tabManager from './tab-manager';
Expand All @@ -43,7 +44,8 @@ settings.ready().then(() => {
onStartup();
});

const tds = new TDSStorage({ settings });
const remoteConfig = new RemoteConfig({ settings });
const tds = new TDSStorage({ settings, remoteConfig });
const devtools = new Devtools({ tds });
/**
* @type {{
Expand All @@ -54,6 +56,7 @@ const devtools = new Devtools({ tds });
* tds: TDSStorage;
* tabTracking: TabTracker;
* trackers: TrackersGlobal;
* remoteConfig: RemoteConfig;
* }}
*/
const components = {
Expand All @@ -66,6 +69,7 @@ const components = {
trackers: new TrackersGlobal({ tds }),
debugger: new DebuggerConnection({ tds, devtools }),
devtools,
remoteConfig,
};

// Chrome-only components
Expand Down
4 changes: 2 additions & 2 deletions shared/js/background/components/debugger-connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default class DebuggerConnection {
if (messageType === 'status') {
if (payload.lastBuild > lastUpdate) {
lastUpdate = payload.lastBuild;
this.tds.config.checkForUpdates(true);
this.tds.remoteConfig.checkForUpdates(true);
}
} else if (messageType === 'subscribe') {
const tabId = parseInt(payload.tabId, 10);
Expand Down Expand Up @@ -123,7 +123,7 @@ export default class DebuggerConnection {
}

async forceReloadConfig() {
this.tds.config.checkForUpdates(true);
this.tds.remoteConfig.checkForUpdates(true);
}

async disableDebugging() {
Expand Down
6 changes: 3 additions & 3 deletions shared/js/background/components/devtools.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class DevtoolsConnection {
*/
async toggleFeature({ feature }) {
if (feature === 'trackerAllowlist') {
await this.devtools.tds.config.modify((config) => {
await this.devtools.tds.remoteConfig.modify((config) => {
const currentState = config.features.trackerAllowlist.state;
config.features.trackerAllowlist.state = currentState === 'enabled' ? 'disabled' : 'enabled';
return config;
Expand All @@ -85,7 +85,7 @@ class DevtoolsConnection {
const enabled = tab.site?.enabledFeatures.includes(feature);
const tabDomain = tldts.getDomain(tab.site.domain);

await this.devtools.tds.config.modify((config) => {
await this.devtools.tds.remoteConfig.modify((config) => {
const excludedSites = config.features[feature].exceptions;
if (enabled) {
excludedSites.push({
Expand All @@ -111,7 +111,7 @@ class DevtoolsConnection {
const tabId = await this.tabId;
const tab = tabManager.get({ tabId });
if (tab.site?.isBroken && tab.url) {
await this.devtools.tds.config.modify((config) => {
await this.devtools.tds.remoteConfig.modify((config) => {
removeBroken(tab.site.domain, config);
if (tab.url) {
removeBroken(new URL(tab.url).hostname, config);
Expand Down
6 changes: 3 additions & 3 deletions shared/js/background/components/dnr-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export default class DNRListeners {
this.settings = settings;
this.tds = tds;
browser.runtime.onInstalled.addListener(this.postInstall.bind(this));
tds.config.onUpdate(onConfigUpdate);
tds.remoteConfig.onUpdate(onConfigUpdate);
tds.tds.onUpdate(onConfigUpdate);
this.settings.onSettingUpdate.addEventListener('GPC', async () => {
await this.tds.config.ready;
ensureGPCHeaderRule(this.tds.config.data);
await this.tds.remoteConfig.ready;
ensureGPCHeaderRule(this.tds.remoteConfig.config);
});
}

Expand Down
130 changes: 130 additions & 0 deletions shared/js/background/components/remote-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* @typedef {import('../settings.js')} Settings
* @typedef {import('./tds').default} TDSStorage
* @typedef {import('@duckduckgo/privacy-configuration/schema/config.js').GenericV4Config} Config
*/

import { getFeatureSettings, isFeatureEnabled, satisfiesMinVersion } from '../utils';
import { getExtensionVersion, getFromSessionStorage } from '../wrapper';
import ResourceLoader from './resource-loader';
import constants from '../../../data/constants';

/**
* @returns {Promise<string>}
*/
async function getConfigUrl() {
const override = await getFromSessionStorage('configURLOverride');
if (override) {
return override;
}
return constants.tdsLists[2].url;
}

export default class RemoteConfig extends ResourceLoader {
/**
* @param {{
* settings: Settings
* }} opts
*/
constructor({ settings }) {
super(
{
name: 'config',
remoteUrl: getConfigUrl,
localUrl: '/data/bundled/extension-config.json',
updateIntervalMinutes: 15,
},
{ settings },
);
/** @type {Config?} */
this.config = null;
this.settings = settings;
this.onUpdate(async (_, etag, v) => {
this.updateConfig(v);
});
}

/**
*
* @param {Config} configValue
*/
updateConfig(configValue) {
// copy config value before modification
const configCopy = structuredClone(configValue);
this.config = this._processRawConfig(configCopy);
}

/**
*
* @param {string} featureName
* @returns {boolean}
*/
isFeatureEnabled(featureName) {
return isFeatureEnabled(featureName, this.config || undefined);
}

/**
*
* @param {string} featureName
* @returns {object}
*/
getFeatureSettings(featureName) {
return getFeatureSettings(featureName, this.config || undefined);
}

isSubFeatureEnabled(featureName, subFeatureName) {
if (this.config) {
return isSubFeatureEnabled(featureName, subFeatureName, this.config);
} else {
return false;
}
}

/**
* Process config to apply rollout, targets and cohorts options to derive sub-feature enabled state.
* @param {Config} configValue
* @returns {Config}
*/
_processRawConfig(configValue) {
Object.entries(configValue.features).forEach(([featureName, feature]) => {
Object.entries(feature.features || {}).forEach(([name, subfeature]) => {
if (subfeature.rollout && subfeature.state === 'enabled') {
/* Handle a rollout: Dice roll is stored in settings and used that to decide
* whether the feature is set as 'enabled' or not.
*/
const rolloutSettingsKey = `rollouts.${featureName}.${name}.roll`;
const validSteps = subfeature.rollout.steps.filter((v) => v.percent > 0 && v.percent <= 100);
const rolloutPercent = validSteps.length > 0 ? validSteps.reverse()[0].percent : 0.0;
if (!this.settings.getSetting(rolloutSettingsKey)) {
this.settings.updateSetting(rolloutSettingsKey, Math.random() * 100);
}
const dieRoll = this.settings.getSetting(rolloutSettingsKey);
subfeature.state = rolloutPercent >= dieRoll ? 'enabled' : 'disabled';
}
});
});
return configValue;
}
}

/**
*
* @param {string} featureName
* @param {string} subFeatureName
* @param {Config} config
* @returns {boolean}
*/
export function isSubFeatureEnabled(featureName, subFeatureName, config) {
const feature = config.features[featureName];
const subFeature = (feature?.features || {})[subFeatureName];
if (!feature || !subFeature) {
return false;
}
if (subFeature.minSupportedVersion) {
const extensionVersionString = getExtensionVersion();
if (!satisfiesMinVersion(subFeature.minSupportedVersion, extensionVersionString)) {
return false;
}
}
return subFeature.state === 'enabled';
}
38 changes: 11 additions & 27 deletions shared/js/background/components/tds.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
import ResourceLoader from './resource-loader.js';
import constants from '../../../data/constants';
import { getFromSessionStorage } from '../wrapper.js';

/**
* @typedef {import('../settings.js')} Settings
* @typedef {import('./remote-config.js').default} RemoteConfig
*/

/**
* @returns {Promise<string>}
*/
async function getConfigUrl() {
const override = await getFromSessionStorage('configURLOverride');
if (override) {
return override;
}
return constants.tdsLists[2].url;
}

export default class TDSStorage {
/**
* @param {{
* settings: Settings
* settings: Settings,
* remoteConfig: RemoteConfig
* }} opts
*/
constructor({ settings }) {
this.tds = new ResourceLoader(
{
name: 'tds',
remoteUrl: constants.tdsLists[1].url,
updateIntervalMinutes: 15,
},
{ settings },
);
constructor({ settings, remoteConfig }) {
this.remoteConfig = remoteConfig;
/** @deprecated config is an alias of remoteConfig */
this.config = this.remoteConfig;
this.surrogates = new ResourceLoader(
{
name: 'surrogates',
Expand All @@ -40,18 +25,17 @@ export default class TDSStorage {
},
{ settings },
);
this.config = new ResourceLoader(
this.tds = new ResourceLoader(
{
name: 'config',
remoteUrl: getConfigUrl,
localUrl: '/data/bundled/extension-config.json',
name: 'tds',
remoteUrl: constants.tdsLists[1].url,
updateIntervalMinutes: 15,
},
{ settings },
);
}

ready() {
return Promise.all([this.tds.ready, this.surrogates.ready, this.config.ready]);
return Promise.all([this.tds.ready, this.surrogates.ready, this.remoteConfig.ready]);
}
}
2 changes: 1 addition & 1 deletion shared/js/background/storage/tds.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default {
_tds: { entities: {}, trackers: {}, domains: {}, cnames: {} },
_surrogates: '',
get config() {
return globalThis.components?.tds.config.data || this._config;
return globalThis.components?.remoteConfig.config || this._config;
},
get tds() {
return globalThis.components?.tds.tds.data || this._tds;
Expand Down
14 changes: 10 additions & 4 deletions shared/js/background/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import parseUserAgentString from '../shared-utils/parse-user-agent-string';
import sha1 from '../shared-utils/sha1';
const browserInfo = parseUserAgentString();

/**
* @typedef {import('@duckduckgo/privacy-configuration/schema/config.js').GenericV4Config} Config
*/

/**
* Produce a random float, matches the output of Math.random() but much more cryptographically psudo-random.
* @returns {number}
Expand Down Expand Up @@ -376,10 +380,11 @@ export function satisfiesMinVersion(minVersionString, extensionVersionString) {
* parameter to check if the state is equeal to other states (i.e. state === 'beta').
*
* @param {String} featureName - the name of the feature
* @param {Config} config
* @returns {boolean} - if feature is enabled
*/
export function isFeatureEnabled(featureName) {
const feature = tdsStorage.config.features[featureName];
export function isFeatureEnabled(featureName, config = tdsStorage.config) {
const feature = config.features[featureName];
if (!feature) {
return false;
}
Expand All @@ -399,10 +404,11 @@ export function isFeatureEnabled(featureName) {
* Returns the settings object associated with featureName in the config
*
* @param {String} featureName - the name of the feature
* @param {Config} config
* @returns {Object} - Settings associated in the config with featureName
*/
export function getFeatureSettings(featureName) {
const feature = tdsStorage.config.features[featureName];
export function getFeatureSettings(featureName, config = tdsStorage.config) {
const feature = config.features[featureName];
if (typeof feature !== 'object' || feature === null || !feature.settings) {
return {};
}
Expand Down
Loading

0 comments on commit 7860833

Please sign in to comment.