From 8ee4b72cfa286decd50679e7a2928714a891ffa4 Mon Sep 17 00:00:00 2001 From: Athira M Date: Sat, 27 Sep 2025 16:42:41 +0530 Subject: [PATCH 1/4] Process experiment metadata in RC fetch response (#9276) * feat: Process experiment metadata in RC fetch response * [Fix] Storage cache is not updating when there are no experiments in response * Add result of running yarn docgen:all * Address review comments * Fix yarn format failures * yarn docgen changes added * Export firebaseExperimentDescription --------- Co-authored-by: Athira M --- common/api-review/remote-config.api.md | 17 ++++ docs-devsite/_toc.yaml | 2 + docs-devsite/remote-config.fetchresponse.md | 13 ++++ ...te-config.firebaseexperimentdescription.md | 78 +++++++++++++++++++ docs-devsite/remote-config.md | 1 + .../remote-config/src/client/rest_client.ts | 8 +- packages/remote-config/src/public_types.ts | 36 ++++++++- packages/remote-config/test/api.test.ts | 14 +++- .../test/client/rest_client.test.ts | 26 +++++-- .../remote-config/test/remote_config.test.ts | 15 +++- 10 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 docs-devsite/remote-config.firebaseexperimentdescription.md diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index a9f5131e0bf..29939b3ac5f 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -41,6 +41,7 @@ export function fetchConfig(remoteConfig: RemoteConfig): Promise; export interface FetchResponse { config?: FirebaseRemoteConfigObject; eTag?: string; + experiments?: FirebaseExperimentDescription[]; status: number; templateVersion?: number; } @@ -51,6 +52,22 @@ export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; // @public export type FetchType = 'BASE' | 'REALTIME'; +// @public +export interface FirebaseExperimentDescription { + // (undocumented) + affectedParameterKeys?: string[]; + // (undocumented) + experimentId: string; + // (undocumented) + experimentStartTime: string; + // (undocumented) + timeToLiveMillis: string; + // (undocumented) + triggerTimeoutMillis: string; + // (undocumented) + variantId: string; +} + // @public export interface FirebaseRemoteConfigObject { // (undocumented) diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index 364d5b992c9..14611a7639d 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -649,6 +649,8 @@ toc: path: /docs/reference/js/remote-config.customsignals.md - title: FetchResponse path: /docs/reference/js/remote-config.fetchresponse.md + - title: FirebaseExperimentDescription + path: /docs/reference/js/remote-config.firebaseexperimentdescription.md - title: FirebaseRemoteConfigObject path: /docs/reference/js/remote-config.firebaseremoteconfigobject.md - title: RemoteConfig diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md index 1955dd47492..a2b94047132 100644 --- a/docs-devsite/remote-config.fetchresponse.md +++ b/docs-devsite/remote-config.fetchresponse.md @@ -26,6 +26,7 @@ export interface FetchResponse | --- | --- | --- | | [config](./remote-config.fetchresponse.md#fetchresponseconfig) | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines the map of parameters returned as "entries" in the fetch response body.

Only defined for 200 responses. | | [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.

Only defined for 200 and 304 responses. | +| [experiments](./remote-config.fetchresponse.md#fetchresponseexperiments) | [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface)\[\] | A/B Test and Rollout experiment metadata. | | [status](./remote-config.fetchresponse.md#fetchresponsestatus) | number | The HTTP status, which is useful for differentiating success responses with data from those without.

The Remote Config client is modeled after the native Fetch interface, so HTTP status is first-class.

Disambiguation: the fetch response returns a legacy "state" value that is redundant with the HTTP status code. The former is normalized into the latter. | | [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. | @@ -53,6 +54,18 @@ Defines the ETag response header value. eTag?: string; ``` +## FetchResponse.experiments + +A/B Test and Rollout experiment metadata. + +Only defined for 200 responses. + +Signature: + +```typescript +experiments?: FirebaseExperimentDescription[]; +``` + ## FetchResponse.status The HTTP status, which is useful for differentiating success responses with data from those without. diff --git a/docs-devsite/remote-config.firebaseexperimentdescription.md b/docs-devsite/remote-config.firebaseexperimentdescription.md new file mode 100644 index 00000000000..24c70690a2d --- /dev/null +++ b/docs-devsite/remote-config.firebaseexperimentdescription.md @@ -0,0 +1,78 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# FirebaseExperimentDescription interface +Defines experiment and variant attached to a config parameter. + +Signature: + +```typescript +export interface FirebaseExperimentDescription +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [affectedParameterKeys](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionaffectedparameterkeys) | string\[\] | | +| [experimentId](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionexperimentid) | string | | +| [experimentStartTime](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionexperimentstarttime) | string | | +| [timeToLiveMillis](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptiontimetolivemillis) | string | | +| [triggerTimeoutMillis](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptiontriggertimeoutmillis) | string | | +| [variantId](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionvariantid) | string | | + +## FirebaseExperimentDescription.affectedParameterKeys + +Signature: + +```typescript +affectedParameterKeys?: string[]; +``` + +## FirebaseExperimentDescription.experimentId + +Signature: + +```typescript +experimentId: string; +``` + +## FirebaseExperimentDescription.experimentStartTime + +Signature: + +```typescript +experimentStartTime: string; +``` + +## FirebaseExperimentDescription.timeToLiveMillis + +Signature: + +```typescript +timeToLiveMillis: string; +``` + +## FirebaseExperimentDescription.triggerTimeoutMillis + +Signature: + +```typescript +triggerTimeoutMillis: string; +``` + +## FirebaseExperimentDescription.variantId + +Signature: + +```typescript +variantId: string; +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index c9f803abf16..c3b5e4b5bf8 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -42,6 +42,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) | Observer interface for receiving real-time Remote Config update notifications.NOTE: Although an complete callback can be provided, it will never be called because the ConfigUpdate stream is never-ending. | | [CustomSignals](./remote-config.customsignals.md#customsignals_interface) | Defines the type for representing custom signals and their values.

The values in CustomSignals must be one of the following types:

  • string
  • number
  • null
| | [FetchResponse](./remote-config.fetchresponse.md#fetchresponse_interface) | Defines a successful response (200 or 304).

Modeled after the native Response interface, but simplified for Remote Config's use case. | +| [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface) | Defines experiment and variant attached to a config parameter. | | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines a self-descriptive reference for config key-value pairs. | | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The Firebase Remote Config service interface. | | [RemoteConfigOptions](./remote-config.remoteconfigoptions.md#remoteconfigoptions_interface) | Options for Remote Config initialization. | diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 42b0cab27c6..d5b0be92c3e 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -18,7 +18,8 @@ import { CustomSignals, FetchResponse, - FirebaseRemoteConfigObject + FirebaseRemoteConfigObject, + FirebaseExperimentDescription } from '../public_types'; import { RemoteConfigFetchClient, @@ -143,6 +144,7 @@ export class RestClient implements RemoteConfigFetchClient { let config: FirebaseRemoteConfigObject | undefined; let state: string | undefined; let templateVersion: number | undefined; + let experiments: FirebaseExperimentDescription[] | undefined; // JSON parsing throws SyntaxError if the response body isn't a JSON string. // Requesting application/json and checking for a 200 ensures there's JSON data. @@ -158,6 +160,7 @@ export class RestClient implements RemoteConfigFetchClient { config = responseBody['entries']; state = responseBody['state']; templateVersion = responseBody['templateVersion']; + experiments = responseBody['experimentDescriptions']; } // Normalizes based on legacy state. @@ -168,6 +171,7 @@ export class RestClient implements RemoteConfigFetchClient { } else if (state === 'NO_TEMPLATE' || state === 'EMPTY_CONFIG') { // These cases can be fixed remotely, so normalize to safe value. config = {}; + experiments = []; } // Normalize to exception-based control flow for non-success cases. @@ -180,6 +184,6 @@ export class RestClient implements RemoteConfigFetchClient { }); } - return { status, eTag: responseEtag, config, templateVersion }; + return { status, eTag: responseEtag, config, templateVersion, experiments }; } } diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index 964726a51f4..015e510973a 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -59,6 +59,34 @@ export interface FirebaseRemoteConfigObject { [key: string]: string; } +/** + * Defines experiment and variant attached to a config parameter. + * + * @public + */ +export interface FirebaseExperimentDescription { + // A string of max length 22 characters and of format: _exp_ + experimentId: string; + + // The variant of the experiment assigned to the app instance. + variantId: string; + + // When the experiment was started. + experimentStartTime: string; + + // How long the experiment can remain in STANDBY state. Valid range from 1 ms + // to 6 months. + triggerTimeoutMillis: string; + + // How long the experiment can remain in ON state. Valid range from 1 ms to 6 + // months. + timeToLiveMillis: string; + + // A repeated of Remote Config parameter keys that this experiment is + // affecting the value of. + affectedParameterKeys?: string[]; +} + /** * Defines a successful response (200 or 304). * @@ -99,8 +127,12 @@ export interface FetchResponse { */ templateVersion?: number; - // Note: we're not extracting experiment metadata until - // ABT and Analytics have Web SDKs. + /** + * A/B Test and Rollout experiment metadata. + * + * @remarks Only defined for 200 responses. + */ + experiments?: FirebaseExperimentDescription[]; } /** diff --git a/packages/remote-config/test/api.test.ts b/packages/remote-config/test/api.test.ts index f38b4ca0bee..54679199a8e 100644 --- a/packages/remote-config/test/api.test.ts +++ b/packages/remote-config/test/api.test.ts @@ -68,7 +68,16 @@ describe('Remote Config API', () => { status: 200, eTag: 'asdf', config: { 'foobar': 'hello world' }, - templateVersion: 1 + templateVersion: 1, + experiments: [ + { + experimentId: '_exp_1', + variantId: '1', + experimentStartTime: '2025-04-06T14:13:57.597Z', + triggerTimeoutMillis: '15552000000', + timeToLiveMillis: '15552000000' + } + ] }; let fetchStub: sinon.SinonStub; @@ -106,7 +115,8 @@ describe('Remote Config API', () => { Promise.resolve({ entries: response.config, state: 'OK', - templateVersion: response.templateVersion + templateVersion: response.templateVersion, + experimentDescriptions: response.experiments }) } as Response) ); diff --git a/packages/remote-config/test/client/rest_client.test.ts b/packages/remote-config/test/client/rest_client.test.ts index bda6fbce01a..d2284c4f8c9 100644 --- a/packages/remote-config/test/client/rest_client.test.ts +++ b/packages/remote-config/test/client/rest_client.test.ts @@ -78,7 +78,16 @@ describe('RestClient', () => { eTag: 'etag', state: 'UPDATE', entries: { color: 'sparkling' }, - templateVersion: 1 + templateVersion: 1, + experimentDescriptions: [ + { + experimentId: '_exp_1', + variantId: '1', + experimentStartTime: '2025-04-06T14:13:57.597Z', + triggerTimeoutMillis: '15552000000', + timeToLiveMillis: '15552000000' + } + ] }; fetchStub.returns( @@ -90,7 +99,8 @@ describe('RestClient', () => { Promise.resolve({ entries: expectedResponse.entries, state: expectedResponse.state, - templateVersion: expectedResponse.templateVersion + templateVersion: expectedResponse.templateVersion, + experimentDescriptions: expectedResponse.experimentDescriptions }) } as Response) ); @@ -101,7 +111,8 @@ describe('RestClient', () => { status: expectedResponse.status, eTag: expectedResponse.eTag, config: expectedResponse.entries, - templateVersion: expectedResponse.templateVersion + templateVersion: expectedResponse.templateVersion, + experiments: expectedResponse.experimentDescriptions }); }); @@ -191,7 +202,8 @@ describe('RestClient', () => { status: 304, eTag: 'response-etag', config: undefined, - templateVersion: undefined + templateVersion: undefined, + experiments: undefined }); }); @@ -230,7 +242,8 @@ describe('RestClient', () => { status: 304, eTag: 'etag', config: undefined, - templateVersion: undefined + templateVersion: undefined, + experiments: undefined }); }); @@ -248,7 +261,8 @@ describe('RestClient', () => { status: 200, eTag: 'etag', config: {}, - templateVersion: undefined + templateVersion: undefined, + experiments: [] }); } }); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 1cc6b62717e..44c105079b9 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -391,6 +391,15 @@ describe('RemoteConfig', () => { const CONFIG = { key: 'val' }; const NEW_ETAG = 'new_etag'; const TEMPLATE_VERSION = 1; + const EXPERIMENTS = [ + { + 'experimentId': '_exp_1', + 'variantId': '1', + 'experimentStartTime': '2025-04-06T14:13:57.597Z', + 'triggerTimeoutMillis': '15552000000', + 'timeToLiveMillis': '15552000000' + } + ]; let getLastSuccessfulFetchResponseStub: sinon.SinonStub; let getActiveConfigEtagStub: sinon.SinonStub; @@ -456,7 +465,8 @@ describe('RemoteConfig', () => { Promise.resolve({ config: CONFIG, eTag: NEW_ETAG, - templateVersion: TEMPLATE_VERSION + templateVersion: TEMPLATE_VERSION, + experiments: EXPERIMENTS }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -476,7 +486,8 @@ describe('RemoteConfig', () => { Promise.resolve({ config: CONFIG, eTag: NEW_ETAG, - templateVersion: TEMPLATE_VERSION + templateVersion: TEMPLATE_VERSION, + experiments: EXPERIMENTS }) ); getActiveConfigEtagStub.returns(Promise.resolve()); From 17126e976fd0f406ed20c189430847a0359dffdb Mon Sep 17 00:00:00 2001 From: Athira M Date: Mon, 29 Sep 2025 23:01:59 +0530 Subject: [PATCH 2/4] Add ABT support for remote config * feat: Process experiment metadata in RC fetch response * [Fix] Storage cache is not updating when there are no experiments in response * Add result of running yarn docgen:all * feat: Process experiment metadata in RC fetch response * feat: Add ABT support for remote config * [Fix] Storage cache is not updating when there are no experiments in response * Merge conflict fix * Yarn format fix * Fix merge conflicts * Address review comments * Fix yarn format failures * yarn docgen changes added * Export firebaseExperimentDescription * Address review comments --------- Co-authored-by: Athira M --- packages/remote-config/src/abt/experiment.ts | 76 +++++++++++++++ packages/remote-config/src/api.ts | 10 +- packages/remote-config/src/storage/storage.ts | 9 ++ .../remote-config/test/abt/experiment.test.ts | 92 +++++++++++++++++++ .../remote-config/test/remote_config.test.ts | 15 +++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packages/remote-config/src/abt/experiment.ts create mode 100644 packages/remote-config/test/abt/experiment.test.ts diff --git a/packages/remote-config/src/abt/experiment.ts b/packages/remote-config/src/abt/experiment.ts new file mode 100644 index 00000000000..07873beb373 --- /dev/null +++ b/packages/remote-config/src/abt/experiment.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Storage } from '../storage/storage'; +import { FirebaseExperimentDescription } from '../public_types'; + +export class Experiment { + constructor(private readonly storage: Storage) {} + + async updateActiveExperiments( + latestExperiments: FirebaseExperimentDescription[] + ): Promise { + const currentActiveExperiments = + (await this.storage.getActiveExperiments()) || new Set(); + const experimentInfoMap = this.createExperimentInfoMap(latestExperiments); + this.addActiveExperiments(currentActiveExperiments, experimentInfoMap); + this.removeInactiveExperiments(currentActiveExperiments, experimentInfoMap); + return this.storage.setActiveExperiments(new Set(experimentInfoMap.keys())); + } + + private createExperimentInfoMap( + latestExperiments: FirebaseExperimentDescription[] + ): Map { + const experimentInfoMap = new Map(); + for (const experiment of latestExperiments) { + experimentInfoMap.set(experiment.experimentId, experiment); + } + return experimentInfoMap; + } + + private addActiveExperiments( + currentActiveExperiments: Set, + experimentInfoMap: Map + ): void { + for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) { + if (!currentActiveExperiments.has(experimentId)) { + this.addExperimentToAnalytics(experimentId, experimentInfo.variantId); + } + } + } + + private removeInactiveExperiments( + currentActiveExperiments: Set, + experimentInfoMap: Map + ): void { + for (const experimentId of currentActiveExperiments) { + if (!experimentInfoMap.has(experimentId)) { + this.removeExperimentFromAnalytics(experimentId); + } + } + } + + private addExperimentToAnalytics( + _experimentId: string, + _variantId: string + ): void { + // TODO + } + + private removeExperimentFromAnalytics(_experimentId: string): void { + // TODO + } +} diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 62dc2697a64..39db9bae9f8 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -36,6 +36,7 @@ import { ERROR_FACTORY, ErrorCode, hasErrorCode } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; import { Value as ValueImpl } from './value'; import { LogLevel as FirebaseLogLevel } from '@firebase/logger'; +import { Experiment } from './abt/experiment'; /** * @@ -110,12 +111,19 @@ export async function activate(remoteConfig: RemoteConfig): Promise { // config. return false; } + const experiment = new Experiment(rc._storage); + const updateActiveExperiments = lastSuccessfulFetchResponse.experiments + ? experiment.updateActiveExperiments( + lastSuccessfulFetchResponse.experiments + ) + : Promise.resolve(); await Promise.all([ rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config), rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag), rc._storage.setActiveConfigTemplateVersion( lastSuccessfulFetchResponse.templateVersion - ) + ), + updateActiveExperiments ]); return true; } diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index bd262d29968..9764b95991e 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -71,6 +71,7 @@ export interface RealtimeBackoffMetadata { type ProjectNamespaceKeyFieldValue = | 'active_config' | 'active_config_etag' + | 'active_experiments' | 'last_fetch_status' | 'last_successful_fetch_timestamp_millis' | 'last_successful_fetch_response' @@ -165,6 +166,14 @@ export abstract class Storage { return this.set('active_config_etag', etag); } + getActiveExperiments(): Promise | undefined> { + return this.get>('active_experiments'); + } + + setActiveExperiments(experiments: Set): Promise { + return this.set>('active_experiments', experiments); + } + getThrottleMetadata(): Promise { return this.get('throttle_metadata'); } diff --git a/packages/remote-config/test/abt/experiment.test.ts b/packages/remote-config/test/abt/experiment.test.ts new file mode 100644 index 00000000000..4dce38c7c3e --- /dev/null +++ b/packages/remote-config/test/abt/experiment.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '../setup'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Experiment } from '../../src/abt/experiment'; +import { FirebaseExperimentDescription } from '../../src/public_types'; +import { Storage } from '../../src/storage/storage'; + +describe('Experiment', () => { + const storage = {} as Storage; + const experiment = new Experiment(storage); + + describe('updateActiveExperiments', () => { + beforeEach(() => { + storage.getActiveExperiments = sinon.stub(); + storage.setActiveExperiments = sinon.stub(); + }); + + it('adds mew experiments to storage', async () => { + const latestExperiments: FirebaseExperimentDescription[] = [ + { + experimentId: '_exp_3', + variantId: '1', + experimentStartTime: '0', + triggerTimeoutMillis: '0', + timeToLiveMillis: '0' + }, + { + experimentId: '_exp_1', + variantId: '2', + experimentStartTime: '0', + triggerTimeoutMillis: '0', + timeToLiveMillis: '0' + }, + { + experimentId: '_exp_2', + variantId: '1', + experimentStartTime: '0', + triggerTimeoutMillis: '0', + timeToLiveMillis: '0' + } + ]; + const expectedStoredExperiments = new Set(['_exp_3', '_exp_1', '_exp_2']); + storage.getActiveExperiments = sinon + .stub() + .returns(new Set(['_exp_1', '_exp_2'])); + + await experiment.updateActiveExperiments(latestExperiments); + + expect(storage.setActiveExperiments).to.have.been.calledWith( + expectedStoredExperiments + ); + }); + + it('removes missing experiment in fetch response from storage', async () => { + const latestExperiments: FirebaseExperimentDescription[] = [ + { + experimentId: '_exp_1', + variantId: '2', + experimentStartTime: '0', + triggerTimeoutMillis: '0', + timeToLiveMillis: '0' + } + ]; + const expectedStoredExperiments = new Set(['_exp_1']); + storage.getActiveExperiments = sinon + .stub() + .returns(new Set(['_exp_1', '_exp_2'])); + + await experiment.updateActiveExperiments(latestExperiments); + + expect(storage.setActiveExperiments).to.have.been.calledWith( + expectedStoredExperiments + ); + }); + }); +}); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 44c105079b9..1b5edb23b4a 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -47,6 +47,7 @@ import * as api from '../src/api'; import { fetchAndActivate } from '../src'; import { restore } from 'sinon'; import { RealtimeHandler } from '../src/client/realtime_handler'; +import { Experiment } from '../src/abt/experiment'; describe('RemoteConfig', () => { const ACTIVE_CONFIG = { @@ -401,6 +402,8 @@ describe('RemoteConfig', () => { } ]; + let sandbox: sinon.SinonSandbox; + let updateActiveExperimentsStub: sinon.SinonStub; let getLastSuccessfulFetchResponseStub: sinon.SinonStub; let getActiveConfigEtagStub: sinon.SinonStub; let getActiveConfigTemplateVersionStub: sinon.SinonStub; @@ -409,6 +412,11 @@ describe('RemoteConfig', () => { let setActiveConfigTemplateVersionStub: sinon.SinonStub; beforeEach(() => { + sandbox = sinon.createSandbox(); + updateActiveExperimentsStub = sandbox.stub( + Experiment.prototype, + 'updateActiveExperiments' + ); getLastSuccessfulFetchResponseStub = sinon.stub(); getActiveConfigEtagStub = sinon.stub(); getActiveConfigTemplateVersionStub = sinon.stub(); @@ -427,6 +435,10 @@ describe('RemoteConfig', () => { setActiveConfigTemplateVersionStub; }); + afterEach(() => { + sandbox.restore(); + }); + it('does not activate if last successful fetch response is undefined', async () => { getLastSuccessfulFetchResponseStub.returns(Promise.resolve()); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -440,6 +452,7 @@ describe('RemoteConfig', () => { expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; + expect(updateActiveExperimentsStub).to.not.have.been.called; }); it('does not activate if fetched and active etags are the same', async () => { @@ -458,6 +471,7 @@ describe('RemoteConfig', () => { expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; + expect(updateActiveExperimentsStub).to.not.have.been.called; }); it('activates if fetched and active etags are different', async () => { @@ -500,6 +514,7 @@ describe('RemoteConfig', () => { expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( TEMPLATE_VERSION ); + expect(updateActiveExperimentsStub).to.have.been.calledWith(EXPERIMENTS); }); }); From a19c1a78b74b145e5e912e9acffc16fa2a234f19 Mon Sep 17 00:00:00 2001 From: Athira M Date: Fri, 3 Oct 2025 00:12:43 +0530 Subject: [PATCH 3/4] Integrate experiments with firebase analytics internal (#9278) * feat: Process experiment metadata in RC fetch response * feat: Add ABT support for remote config * feat: Integrate firebase internal analytics with ABT * [Fix] Storage cache is not updating when there are no experiments in response * feat: Process experiment metadata in RC fetch response * [Fix] Storage cache is not updating when there are no experiments in response * Add result of running yarn docgen:all * feat: Process experiment metadata in RC fetch response * feat: Add ABT support for remote config * [Fix] Storage cache is not updating when there are no experiments in response * Merge conflict fix * Yarn format fix * Fix merge conflicts * Integrate ABT with Firebase analytics to add experiment as UP * Fix yarn format errors * Address review comments * Fix yarn format failures * yarn docgen changes added * Export firebaseExperimentDescription * Address review comments * Address review comments * Add unit tests * Add error handling * Remove log --------- Co-authored-by: Athira M --- packages/remote-config/src/abt/experiment.ts | 48 +++++++++++++++---- packages/remote-config/src/api.ts | 2 +- packages/remote-config/src/errors.ts | 8 +++- packages/remote-config/src/register.ts | 4 +- packages/remote-config/src/remote_config.ts | 8 +++- .../remote-config/test/abt/experiment.test.ts | 26 +++++++++- .../remote-config/test/remote_config.test.ts | 13 ++++- 7 files changed, 90 insertions(+), 19 deletions(-) diff --git a/packages/remote-config/src/abt/experiment.ts b/packages/remote-config/src/abt/experiment.ts index 07873beb373..85b309fbbc9 100644 --- a/packages/remote-config/src/abt/experiment.ts +++ b/packages/remote-config/src/abt/experiment.ts @@ -16,9 +16,22 @@ */ import { Storage } from '../storage/storage'; import { FirebaseExperimentDescription } from '../public_types'; +import { Provider } from '@firebase/component'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { Logger } from '@firebase/logger'; +import { RemoteConfig } from '../remote_config'; +import { ERROR_FACTORY, ErrorCode } from '../errors'; export class Experiment { - constructor(private readonly storage: Storage) {} + private storage: Storage; + private logger: Logger; + private analyticsProvider: Provider; + + constructor(rc: RemoteConfig) { + this.storage = rc._storage; + this.logger = rc._logger; + this.analyticsProvider = rc._analyticsProvider; + } async updateActiveExperiments( latestExperiments: FirebaseExperimentDescription[] @@ -45,32 +58,47 @@ export class Experiment { currentActiveExperiments: Set, experimentInfoMap: Map ): void { + const customProperty: Record = {}; for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) { if (!currentActiveExperiments.has(experimentId)) { - this.addExperimentToAnalytics(experimentId, experimentInfo.variantId); + customProperty[experimentId] = experimentInfo.variantId; } } + this.addExperimentToAnalytics(customProperty); } private removeInactiveExperiments( currentActiveExperiments: Set, experimentInfoMap: Map ): void { + const customProperty: Record = {}; for (const experimentId of currentActiveExperiments) { if (!experimentInfoMap.has(experimentId)) { - this.removeExperimentFromAnalytics(experimentId); + customProperty[experimentId] = null; } } + this.addExperimentToAnalytics(customProperty); } private addExperimentToAnalytics( - _experimentId: string, - _variantId: string + customProperty: Record ): void { - // TODO - } - - private removeExperimentFromAnalytics(_experimentId: string): void { - // TODO + if (Object.keys(customProperty).length === 0) { + return; + } + try { + const analytics = this.analyticsProvider.getImmediate({ optional: true }); + if (analytics) { + analytics.setUserProperties({ properties: customProperty }); + } else { + // TODO: Update warning message + this.logger.warn(`Analytics is not imported correctly`); + } + } catch (error) { + // TODO: Update error message + throw ERROR_FACTORY.create(ErrorCode.ANALYTICS_UNAVAILABLE, { + originalErrorMessage: (error as Error)?.message + }); + } } } diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 39db9bae9f8..5533bf9129c 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -111,7 +111,7 @@ export async function activate(remoteConfig: RemoteConfig): Promise { // config. return false; } - const experiment = new Experiment(rc._storage); + const experiment = new Experiment(rc); const updateActiveExperiments = lastSuccessfulFetchResponse.experiments ? experiment.updateActiveExperiments( lastSuccessfulFetchResponse.experiments diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index dea9f43e922..7311b64bca8 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -37,7 +37,8 @@ export const enum ErrorCode { CONFIG_UPDATE_STREAM_ERROR = 'stream-error', CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable', CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid', - CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched' + CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched', + ANALYTICS_UNAVAILABLE = 'analytics-unavailable' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -84,7 +85,9 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: 'The stream invalidation message was unparsable: {$originalErrorMessage}', [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: - 'Unable to fetch the latest config: {$originalErrorMessage}' + 'Unable to fetch the latest config: {$originalErrorMessage}', + [ErrorCode.ANALYTICS_UNAVAILABLE]: + 'Connection to firebase analytics failed: {$originalErrorMessage}' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -108,6 +111,7 @@ interface ErrorParams { [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string }; [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string }; [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string }; + [ErrorCode.ANALYTICS_UNAVAILABLE]: { originalErrorMessage: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index eade371ca89..0a34024622f 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -66,6 +66,7 @@ export function registerRemoteConfig(): void { const installations = container .getProvider('installations-internal') .getImmediate(); + const analyticsProvider = container.getProvider('analytics-internal'); // Normalizes optional inputs. const { projectId, apiKey, appId } = app.options; @@ -127,7 +128,8 @@ export function registerRemoteConfig(): void { storageCache, storage, logger, - realtimeHandler + realtimeHandler, + analyticsProvider ); // Starts warming cache. diff --git a/packages/remote-config/src/remote_config.ts b/packages/remote-config/src/remote_config.ts index bd32c938304..aa9502262fd 100644 --- a/packages/remote-config/src/remote_config.ts +++ b/packages/remote-config/src/remote_config.ts @@ -25,6 +25,8 @@ import { StorageCache } from './storage/storage_cache'; import { RemoteConfigFetchClient } from './client/remote_config_fetch_client'; import { Storage } from './storage/storage'; import { Logger } from '@firebase/logger'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { Provider } from '@firebase/component'; import { RealtimeHandler } from './client/realtime_handler'; const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute @@ -88,6 +90,10 @@ export class RemoteConfig implements RemoteConfigType { /** * @internal */ - readonly _realtimeHandler: RealtimeHandler + readonly _realtimeHandler: RealtimeHandler, + /** + * @internal + */ + readonly _analyticsProvider: Provider ) {} } diff --git a/packages/remote-config/test/abt/experiment.test.ts b/packages/remote-config/test/abt/experiment.test.ts index 4dce38c7c3e..8e0b0cb5f13 100644 --- a/packages/remote-config/test/abt/experiment.test.ts +++ b/packages/remote-config/test/abt/experiment.test.ts @@ -20,18 +20,32 @@ import * as sinon from 'sinon'; import { Experiment } from '../../src/abt/experiment'; import { FirebaseExperimentDescription } from '../../src/public_types'; import { Storage } from '../../src/storage/storage'; +import { Provider } from '@firebase/component'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { Logger } from '@firebase/logger'; +import { RemoteConfig } from '../../src/remote_config'; describe('Experiment', () => { const storage = {} as Storage; - const experiment = new Experiment(storage); + const analyticsProvider = {} as Provider; + const logger = {} as Logger; + const rc = { + _storage: storage, + _analyticsProvider: analyticsProvider, + _logger: logger + } as RemoteConfig; + const experiment = new Experiment(rc); describe('updateActiveExperiments', () => { beforeEach(() => { storage.getActiveExperiments = sinon.stub(); storage.setActiveExperiments = sinon.stub(); + analyticsProvider.getImmediate = sinon.stub().returns({ + setUserProperties: sinon.stub() + }); }); - it('adds mew experiments to storage', async () => { + it('adds new experiments to storage', async () => { const latestExperiments: FirebaseExperimentDescription[] = [ { experimentId: '_exp_3', @@ -59,12 +73,16 @@ describe('Experiment', () => { storage.getActiveExperiments = sinon .stub() .returns(new Set(['_exp_1', '_exp_2'])); + const analytics = analyticsProvider.getImmediate(); await experiment.updateActiveExperiments(latestExperiments); expect(storage.setActiveExperiments).to.have.been.calledWith( expectedStoredExperiments ); + expect(analytics.setUserProperties).to.have.been.calledWith({ + properties: { '_exp_3': '1' } + }); }); it('removes missing experiment in fetch response from storage', async () => { @@ -81,12 +99,16 @@ describe('Experiment', () => { storage.getActiveExperiments = sinon .stub() .returns(new Set(['_exp_1', '_exp_2'])); + const analytics = analyticsProvider.getImmediate(); await experiment.updateActiveExperiments(latestExperiments); expect(storage.setActiveExperiments).to.have.been.calledWith( expectedStoredExperiments ); + expect(analytics.setUserProperties).to.have.been.calledWith({ + properties: { '_exp_2': null } + }); }); }); }); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 1b5edb23b4a..de70e13dfc2 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -46,8 +46,10 @@ import { import * as api from '../src/api'; import { fetchAndActivate } from '../src'; import { restore } from 'sinon'; -import { RealtimeHandler } from '../src/client/realtime_handler'; import { Experiment } from '../src/abt/experiment'; +import { Provider } from '@firebase/component'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { RealtimeHandler } from '../src/client/realtime_handler'; describe('RemoteConfig', () => { const ACTIVE_CONFIG = { @@ -71,6 +73,7 @@ describe('RemoteConfig', () => { let logger: Logger; let realtimeHandler: RealtimeHandler; let rc: RemoteConfigType; + let analyticsProvider: Provider; let getActiveConfigStub: sinon.SinonStub; let loggerDebugSpy: sinon.SinonSpy; @@ -82,6 +85,7 @@ describe('RemoteConfig', () => { client = {} as RemoteConfigFetchClient; storageCache = {} as StorageCache; storage = {} as Storage; + analyticsProvider = {} as Provider; realtimeHandler = {} as RealtimeHandler; logger = new Logger('package-name'); getActiveConfigStub = sinon.stub().returns(undefined); @@ -94,7 +98,8 @@ describe('RemoteConfig', () => { storageCache, storage, logger, - realtimeHandler + realtimeHandler, + analyticsProvider ); }); @@ -439,6 +444,10 @@ describe('RemoteConfig', () => { sandbox.restore(); }); + afterEach(() => { + sandbox.restore(); + }); + it('does not activate if last successful fetch response is undefined', async () => { getLastSuccessfulFetchResponseStub.returns(Promise.resolve()); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); From fa63f97e7b4fe13d92f4f922929722e2d3616eca Mon Sep 17 00:00:00 2001 From: Athira M Date: Mon, 6 Oct 2025 13:56:23 +0530 Subject: [PATCH 4/4] Fix messages and docs --- docs-devsite/remote-config.fetchresponse.md | 4 ++-- packages/remote-config/src/public_types.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md index a2b94047132..9c55c50e689 100644 --- a/docs-devsite/remote-config.fetchresponse.md +++ b/docs-devsite/remote-config.fetchresponse.md @@ -26,7 +26,7 @@ export interface FetchResponse | --- | --- | --- | | [config](./remote-config.fetchresponse.md#fetchresponseconfig) | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines the map of parameters returned as "entries" in the fetch response body.

Only defined for 200 responses. | | [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.

Only defined for 200 and 304 responses. | -| [experiments](./remote-config.fetchresponse.md#fetchresponseexperiments) | [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface)\[\] | A/B Test and Rollout experiment metadata. | +| [experiments](./remote-config.fetchresponse.md#fetchresponseexperiments) | [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface)\[\] | Metadata for A/B testing and Remote Config Rollout experiments. | | [status](./remote-config.fetchresponse.md#fetchresponsestatus) | number | The HTTP status, which is useful for differentiating success responses with data from those without.

The Remote Config client is modeled after the native Fetch interface, so HTTP status is first-class.

Disambiguation: the fetch response returns a legacy "state" value that is redundant with the HTTP status code. The former is normalized into the latter. | | [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. | @@ -56,7 +56,7 @@ eTag?: string; ## FetchResponse.experiments -A/B Test and Rollout experiment metadata. +Metadata for A/B testing and Remote Config Rollout experiments. Only defined for 200 responses. diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index 015e510973a..fc23974ee32 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -82,8 +82,7 @@ export interface FirebaseExperimentDescription { // months. timeToLiveMillis: string; - // A repeated of Remote Config parameter keys that this experiment is - // affecting the value of. + // Which all parameters are affected by this experiment. affectedParameterKeys?: string[]; } @@ -128,7 +127,7 @@ export interface FetchResponse { templateVersion?: number; /** - * A/B Test and Rollout experiment metadata. + * Metadata for A/B testing and Remote Config Rollout experiments. * * @remarks Only defined for 200 responses. */