Skip to content
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

Implement the 'rollout' config option for percentage rollouts of sub-features #2880

Merged
merged 12 commits into from
Jan 8, 2025
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);
sammacbeth marked this conversation as resolved.
Show resolved Hide resolved
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
Loading