Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2ec305d
feat: Process experiment metadata in RC fetch response
May 30, 2025
5eb6b8d
feat: Add ABT support for remote config
Jun 6, 2025
08c8863
feat: Integrate firebase internal analytics with ABT
Jun 6, 2025
4af3eb9
Merge branch 'web-experiment' into web-exp-fetch
Jul 19, 2025
b6f2ac9
Merge branch 'web-exp-fetch' into web-exp-abt
Jul 19, 2025
55db6e0
Merge branch 'web-exp-abt' into web-exp-ga
Jul 19, 2025
24848c4
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
455b8e3
[Fix] Update experiments after checking fetch response
Jul 19, 2025
e2024d7
Merge branch 'web-exp-abt' into web-exp-ga
Jul 19, 2025
bec6e56
feat: Process experiment metadata in RC fetch response
May 30, 2025
ee703b9
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
7c67f85
Add result of running yarn docgen:all
Sep 25, 2025
06398f6
feat: Process experiment metadata in RC fetch response
May 30, 2025
fd049e2
feat: Add ABT support for remote config
Jun 6, 2025
638cc2c
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
900eff5
Merge conflict fix
Sep 25, 2025
432ac24
Yarn format fix
Sep 25, 2025
ccc71e1
Fix merge conflicts
Sep 25, 2025
5836eaf
merge web-exp-abt
Sep 25, 2025
6be23df
Integrate ABT with Firebase analytics to add experiment as UP
Sep 25, 2025
66b104b
Fix yarn format errors
Sep 25, 2025
b289636
Address review comments
Sep 25, 2025
d3e0838
Fix yarn format failures
Sep 25, 2025
aa7751e
yarn docgen changes added
Sep 25, 2025
19c0fd6
Export firebaseExperimentDescription
Sep 26, 2025
b3f5fa1
Merge branch 'web-exp-fetch' into web-exp-abt
Sep 26, 2025
27cb4b2
Merge branch 'web-exp-abt' into web-exp-ga
Sep 26, 2025
d09a338
Merge branch 'web-experiment' into web-exp-ga
Sep 29, 2025
4c19690
Address review comments
Sep 29, 2025
4eba42d
Address review comments
Oct 1, 2025
bbc8f4d
Add unit tests
Oct 1, 2025
b53e512
Add error handling
Oct 1, 2025
76fd2ad
Remove log
Oct 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions common/api-review/remote-config.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function fetchConfig(remoteConfig: RemoteConfig): Promise<void>;
export interface FetchResponse {
config?: FirebaseRemoteConfigObject;
eTag?: string;
experiments?: FirebaseExperimentDescription[];
status: number;
templateVersion?: number;
}
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs-devsite/_toc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs-devsite/remote-config.fetchresponse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<p>Only defined for 200 responses. |
| [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.<p>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.<p>The Remote Config client is modeled after the native <code>Fetch</code> interface, so HTTP status is first-class.<p>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. |

Expand Down Expand Up @@ -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.

<b>Signature:</b>

```typescript
experiments?: FirebaseExperimentDescription[];
```

## FetchResponse.status

The HTTP status, which is useful for differentiating success responses with data from those without.
Expand Down
78 changes: 78 additions & 0 deletions docs-devsite/remote-config.firebaseexperimentdescription.md
Original file line number Diff line number Diff line change
@@ -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.

<b>Signature:</b>

```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

<b>Signature:</b>

```typescript
affectedParameterKeys?: string[];
```

## FirebaseExperimentDescription.experimentId

<b>Signature:</b>

```typescript
experimentId: string;
```

## FirebaseExperimentDescription.experimentStartTime

<b>Signature:</b>

```typescript
experimentStartTime: string;
```

## FirebaseExperimentDescription.timeToLiveMillis

<b>Signature:</b>

```typescript
timeToLiveMillis: string;
```

## FirebaseExperimentDescription.triggerTimeoutMillis

<b>Signature:</b>

```typescript
triggerTimeoutMillis: string;
```

## FirebaseExperimentDescription.variantId

<b>Signature:</b>

```typescript
variantId: string;
```
1 change: 1 addition & 0 deletions docs-devsite/remote-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>complete</code> 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.<p>The values in CustomSignals must be one of the following types:<ul> <li><code>string</code> <li><code>number</code> <li><code>null</code> </ul> |
| [FetchResponse](./remote-config.fetchresponse.md#fetchresponse_interface) | Defines a successful response (200 or 304).<p>Modeled after the native <code>Response</code> 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. |
Expand Down
90 changes: 90 additions & 0 deletions packages/remote-config/src/abt/experiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* @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';
import { Provider } from '@firebase/component';
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';

export class Experiment {
constructor(
private readonly storage: Storage,
private readonly analyticsProvider: Provider<FirebaseAnalyticsInternalName>
) {}

async updateActiveExperiments(
latestExperiments: FirebaseExperimentDescription[]
): Promise<void> {
const currentActiveExperiments =
(await this.storage.getActiveExperiments()) || new Set<string>();
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<string, FirebaseExperimentDescription> {
const experimentInfoMap = new Map<string, FirebaseExperimentDescription>();
for (const experiment of latestExperiments) {
experimentInfoMap.set(experiment.experimentId, experiment);
}
return experimentInfoMap;
}

private addActiveExperiments(
currentActiveExperiments: Set<string>,
experimentInfoMap: Map<string, FirebaseExperimentDescription>
): void {
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
if (!currentActiveExperiments.has(experimentId)) {
void this.addExperimentToAnalytics(
experimentId,
experimentInfo.variantId
);
}
}
}

private removeInactiveExperiments(
currentActiveExperiments: Set<string>,
experimentInfoMap: Map<string, FirebaseExperimentDescription>
): void {
for (const experimentId of currentActiveExperiments) {
if (!experimentInfoMap.has(experimentId)) {
void this.removeExperimentFromAnalytics(experimentId);
}
}
}

private async addExperimentToAnalytics(
experimentId: string,
variantId: string | null
): Promise<void> {
const analytics = await this.analyticsProvider.get();
const customProperty = {
[experimentId]: variantId
};
analytics.setUserProperties({ properties: customProperty });
}

private async removeExperimentFromAnalytics(
experimentId: string
): Promise<void> {
void this.addExperimentToAnalytics(experimentId, null);
}
}
9 changes: 8 additions & 1 deletion packages/remote-config/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
*
Expand Down Expand Up @@ -110,12 +111,18 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
// config.
return false;
}
const experiment = new Experiment(rc._storage, rc._analyticsProvider);
await Promise.all([
rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag),
rc._storage.setActiveConfigTemplateVersion(
lastSuccessfulFetchResponse.templateVersion
)
),
lastSuccessfulFetchResponse.experiments &&
experiment.updateActiveExperiments(
lastSuccessfulFetchResponse.experiments
),
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag)
]);
return true;
}
Expand Down
8 changes: 6 additions & 2 deletions packages/remote-config/src/client/rest_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
import {
CustomSignals,
FetchResponse,
FirebaseRemoteConfigObject
FirebaseRemoteConfigObject,
FirebaseExperimentDescription
} from '../public_types';
import {
RemoteConfigFetchClient,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -180,6 +184,6 @@ export class RestClient implements RemoteConfigFetchClient {
});
}

return { status, eTag: responseEtag, config, templateVersion };
return { status, eTag: responseEtag, config, templateVersion, experiments };
}
}
36 changes: 34 additions & 2 deletions packages/remote-config/src/public_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<experiment_id>
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).
*
Expand Down Expand Up @@ -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[];
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/remote-config/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,7 +128,8 @@ export function registerRemoteConfig(): void {
storageCache,
storage,
logger,
realtimeHandler
realtimeHandler,
analyticsProvider
);

// Starts warming cache.
Expand Down
Loading
Loading