Skip to content

Commit 6abe529

Browse files
authored
feat(rc): Web support for ABT & Rollouts (#9293)
Provide experiment in details in fetch response and integrate with Google Analytics to create experiment related user properties
1 parent 0800a8b commit 6abe529

File tree

18 files changed

+479
-20
lines changed

18 files changed

+479
-20
lines changed

.changeset/wild-snakes-bathe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@firebase/remote-config': minor
3+
'firebase': minor
4+
---
5+
6+
Web support for ABT and Rollouts

common/api-review/remote-config.api.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function fetchConfig(remoteConfig: RemoteConfig): Promise<void>;
4141
export interface FetchResponse {
4242
config?: FirebaseRemoteConfigObject;
4343
eTag?: string;
44+
experiments?: FirebaseExperimentDescription[];
4445
status: number;
4546
templateVersion?: number;
4647
}
@@ -51,6 +52,22 @@ export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle';
5152
// @public
5253
export type FetchType = 'BASE' | 'REALTIME';
5354

55+
// @public
56+
export interface FirebaseExperimentDescription {
57+
// (undocumented)
58+
affectedParameterKeys?: string[];
59+
// (undocumented)
60+
experimentId: string;
61+
// (undocumented)
62+
experimentStartTime: string;
63+
// (undocumented)
64+
timeToLiveMillis: string;
65+
// (undocumented)
66+
triggerTimeoutMillis: string;
67+
// (undocumented)
68+
variantId: string;
69+
}
70+
5471
// @public
5572
export interface FirebaseRemoteConfigObject {
5673
// (undocumented)

docs-devsite/_toc.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,8 @@ toc:
661661
path: /docs/reference/js/remote-config.customsignals.md
662662
- title: FetchResponse
663663
path: /docs/reference/js/remote-config.fetchresponse.md
664+
- title: FirebaseExperimentDescription
665+
path: /docs/reference/js/remote-config.firebaseexperimentdescription.md
664666
- title: FirebaseRemoteConfigObject
665667
path: /docs/reference/js/remote-config.firebaseremoteconfigobject.md
666668
- title: RemoteConfig

docs-devsite/remote-config.fetchresponse.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface FetchResponse
2626
| --- | --- | --- |
2727
| [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. |
2828
| [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.<p>Only defined for 200 and 304 responses. |
29+
| [experiments](./remote-config.fetchresponse.md#fetchresponseexperiments) | [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface)<!-- -->\[\] | Metadata for A/B testing and Remote Config Rollout experiments. |
2930
| [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. |
3031
| [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. |
3132

@@ -53,6 +54,18 @@ Defines the ETag response header value.
5354
eTag?: string;
5455
```
5556

57+
## FetchResponse.experiments
58+
59+
Metadata for A/B testing and Remote Config Rollout experiments.
60+
61+
Only defined for 200 responses.
62+
63+
<b>Signature:</b>
64+
65+
```typescript
66+
experiments?: FirebaseExperimentDescription[];
67+
```
68+
5669
## FetchResponse.status
5770

5871
The HTTP status, which is useful for differentiating success responses with data from those without.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
Project: /docs/reference/js/_project.yaml
2+
Book: /docs/reference/_book.yaml
3+
page_type: reference
4+
5+
{% comment %}
6+
DO NOT EDIT THIS FILE!
7+
This is generated by the JS SDK team, and any local changes will be
8+
overwritten. Changes should be made in the source code at
9+
https://github.com/firebase/firebase-js-sdk
10+
{% endcomment %}
11+
12+
# FirebaseExperimentDescription interface
13+
Defines experiment and variant attached to a config parameter.
14+
15+
<b>Signature:</b>
16+
17+
```typescript
18+
export interface FirebaseExperimentDescription
19+
```
20+
21+
## Properties
22+
23+
| Property | Type | Description |
24+
| --- | --- | --- |
25+
| [affectedParameterKeys](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionaffectedparameterkeys) | string\[\] | |
26+
| [experimentId](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionexperimentid) | string | |
27+
| [experimentStartTime](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionexperimentstarttime) | string | |
28+
| [timeToLiveMillis](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptiontimetolivemillis) | string | |
29+
| [triggerTimeoutMillis](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptiontriggertimeoutmillis) | string | |
30+
| [variantId](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescriptionvariantid) | string | |
31+
32+
## FirebaseExperimentDescription.affectedParameterKeys
33+
34+
<b>Signature:</b>
35+
36+
```typescript
37+
affectedParameterKeys?: string[];
38+
```
39+
40+
## FirebaseExperimentDescription.experimentId
41+
42+
<b>Signature:</b>
43+
44+
```typescript
45+
experimentId: string;
46+
```
47+
48+
## FirebaseExperimentDescription.experimentStartTime
49+
50+
<b>Signature:</b>
51+
52+
```typescript
53+
experimentStartTime: string;
54+
```
55+
56+
## FirebaseExperimentDescription.timeToLiveMillis
57+
58+
<b>Signature:</b>
59+
60+
```typescript
61+
timeToLiveMillis: string;
62+
```
63+
64+
## FirebaseExperimentDescription.triggerTimeoutMillis
65+
66+
<b>Signature:</b>
67+
68+
```typescript
69+
triggerTimeoutMillis: string;
70+
```
71+
72+
## FirebaseExperimentDescription.variantId
73+
74+
<b>Signature:</b>
75+
76+
```typescript
77+
variantId: string;
78+
```

docs-devsite/remote-config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm
4242
| [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. |
4343
| [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> |
4444
| [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. |
45+
| [FirebaseExperimentDescription](./remote-config.firebaseexperimentdescription.md#firebaseexperimentdescription_interface) | Defines experiment and variant attached to a config parameter. |
4546
| [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines a self-descriptive reference for config key-value pairs. |
4647
| [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The Firebase Remote Config service interface. |
4748
| [RemoteConfigOptions](./remote-config.remoteconfigoptions.md#remoteconfigoptions_interface) | Options for Remote Config initialization. |
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import { Storage } from '../storage/storage';
18+
import { FirebaseExperimentDescription } from '../public_types';
19+
import { Provider } from '@firebase/component';
20+
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';
21+
import { Logger } from '@firebase/logger';
22+
import { RemoteConfig } from '../remote_config';
23+
import { ERROR_FACTORY, ErrorCode } from '../errors';
24+
25+
export class Experiment {
26+
private storage: Storage;
27+
private logger: Logger;
28+
private analyticsProvider: Provider<FirebaseAnalyticsInternalName>;
29+
30+
constructor(rc: RemoteConfig) {
31+
this.storage = rc._storage;
32+
this.logger = rc._logger;
33+
this.analyticsProvider = rc._analyticsProvider;
34+
}
35+
36+
async updateActiveExperiments(
37+
latestExperiments: FirebaseExperimentDescription[]
38+
): Promise<void> {
39+
const currentActiveExperiments =
40+
(await this.storage.getActiveExperiments()) || new Set<string>();
41+
const experimentInfoMap = this.createExperimentInfoMap(latestExperiments);
42+
this.addActiveExperiments(experimentInfoMap);
43+
this.removeInactiveExperiments(currentActiveExperiments, experimentInfoMap);
44+
return this.storage.setActiveExperiments(new Set(experimentInfoMap.keys()));
45+
}
46+
47+
private createExperimentInfoMap(
48+
latestExperiments: FirebaseExperimentDescription[]
49+
): Map<string, FirebaseExperimentDescription> {
50+
const experimentInfoMap = new Map<string, FirebaseExperimentDescription>();
51+
for (const experiment of latestExperiments) {
52+
experimentInfoMap.set(experiment.experimentId, experiment);
53+
}
54+
return experimentInfoMap;
55+
}
56+
57+
private addActiveExperiments(
58+
experimentInfoMap: Map<string, FirebaseExperimentDescription>
59+
): void {
60+
const customProperty: Record<string, string | null> = {};
61+
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
62+
customProperty[`firebase${experimentId}`] = experimentInfo.variantId;
63+
}
64+
this.addExperimentToAnalytics(customProperty);
65+
}
66+
67+
private removeInactiveExperiments(
68+
currentActiveExperiments: Set<string>,
69+
experimentInfoMap: Map<string, FirebaseExperimentDescription>
70+
): void {
71+
const customProperty: Record<string, string | null> = {};
72+
for (const experimentId of currentActiveExperiments) {
73+
if (!experimentInfoMap.has(experimentId)) {
74+
customProperty[`firebase${experimentId}`] = null;
75+
}
76+
}
77+
this.addExperimentToAnalytics(customProperty);
78+
}
79+
80+
private addExperimentToAnalytics(
81+
customProperty: Record<string, string | null>
82+
): void {
83+
if (Object.keys(customProperty).length === 0) {
84+
return;
85+
}
86+
try {
87+
const analytics = this.analyticsProvider.getImmediate({ optional: true });
88+
if (analytics) {
89+
analytics.setUserProperties(customProperty);
90+
analytics.logEvent(`set_firebase_experiment_state`);
91+
} else {
92+
this.logger.warn(
93+
`Analytics import failed. Verify if you have imported Firebase Analytics in your app code.`
94+
);
95+
}
96+
} catch (error) {
97+
throw ERROR_FACTORY.create(ErrorCode.ANALYTICS_UNAVAILABLE, {
98+
originalErrorMessage: (error as Error)?.message
99+
});
100+
}
101+
}
102+
}

packages/remote-config/src/api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ERROR_FACTORY, ErrorCode, hasErrorCode } from './errors';
3636
import { RemoteConfig as RemoteConfigImpl } from './remote_config';
3737
import { Value as ValueImpl } from './value';
3838
import { LogLevel as FirebaseLogLevel } from '@firebase/logger';
39+
import { Experiment } from './abt/experiment';
3940

4041
/**
4142
*
@@ -110,12 +111,19 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
110111
// config.
111112
return false;
112113
}
114+
const experiment = new Experiment(rc);
115+
const updateActiveExperiments = lastSuccessfulFetchResponse.experiments
116+
? experiment.updateActiveExperiments(
117+
lastSuccessfulFetchResponse.experiments
118+
)
119+
: Promise.resolve();
113120
await Promise.all([
114121
rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
115122
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag),
116123
rc._storage.setActiveConfigTemplateVersion(
117124
lastSuccessfulFetchResponse.templateVersion
118-
)
125+
),
126+
updateActiveExperiments
119127
]);
120128
return true;
121129
}

packages/remote-config/src/client/rest_client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
import {
1919
CustomSignals,
2020
FetchResponse,
21-
FirebaseRemoteConfigObject
21+
FirebaseRemoteConfigObject,
22+
FirebaseExperimentDescription
2223
} from '../public_types';
2324
import {
2425
RemoteConfigFetchClient,
@@ -143,6 +144,7 @@ export class RestClient implements RemoteConfigFetchClient {
143144
let config: FirebaseRemoteConfigObject | undefined;
144145
let state: string | undefined;
145146
let templateVersion: number | undefined;
147+
let experiments: FirebaseExperimentDescription[] | undefined;
146148

147149
// JSON parsing throws SyntaxError if the response body isn't a JSON string.
148150
// Requesting application/json and checking for a 200 ensures there's JSON data.
@@ -158,6 +160,7 @@ export class RestClient implements RemoteConfigFetchClient {
158160
config = responseBody['entries'];
159161
state = responseBody['state'];
160162
templateVersion = responseBody['templateVersion'];
163+
experiments = responseBody['experimentDescriptions'];
161164
}
162165

163166
// Normalizes based on legacy state.
@@ -168,6 +171,7 @@ export class RestClient implements RemoteConfigFetchClient {
168171
} else if (state === 'NO_TEMPLATE' || state === 'EMPTY_CONFIG') {
169172
// These cases can be fixed remotely, so normalize to safe value.
170173
config = {};
174+
experiments = [];
171175
}
172176

173177
// Normalize to exception-based control flow for non-success cases.
@@ -180,6 +184,6 @@ export class RestClient implements RemoteConfigFetchClient {
180184
});
181185
}
182186

183-
return { status, eTag: responseEtag, config, templateVersion };
187+
return { status, eTag: responseEtag, config, templateVersion, experiments };
184188
}
185189
}

packages/remote-config/src/errors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export const enum ErrorCode {
3737
CONFIG_UPDATE_STREAM_ERROR = 'stream-error',
3838
CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable',
3939
CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid',
40-
CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched'
40+
CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched',
41+
ANALYTICS_UNAVAILABLE = 'analytics-unavailable'
4142
}
4243

4344
const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
@@ -84,7 +85,9 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
8485
[ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]:
8586
'The stream invalidation message was unparsable: {$originalErrorMessage}',
8687
[ErrorCode.CONFIG_UPDATE_NOT_FETCHED]:
87-
'Unable to fetch the latest config: {$originalErrorMessage}'
88+
'Unable to fetch the latest config: {$originalErrorMessage}',
89+
[ErrorCode.ANALYTICS_UNAVAILABLE]:
90+
'Connection to Firebase Analytics failed: {$originalErrorMessage}'
8891
};
8992

9093
// Note this is effectively a type system binding a code to params. This approach overlaps with the
@@ -108,6 +111,7 @@ interface ErrorParams {
108111
[ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string };
109112
[ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string };
110113
[ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string };
114+
[ErrorCode.ANALYTICS_UNAVAILABLE]: { originalErrorMessage: string };
111115
}
112116

113117
export const ERROR_FACTORY = new ErrorFactory<ErrorCode, ErrorParams>(

0 commit comments

Comments
 (0)