Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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)<!-- -->\[\] | 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.<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

Metadata for A/B testing and Remote Config Rollout experiments.

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
104 changes: 104 additions & 0 deletions packages/remote-config/src/abt/experiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @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';
import { Logger } from '@firebase/logger';
import { RemoteConfig } from '../remote_config';
import { ERROR_FACTORY, ErrorCode } from '../errors';

export class Experiment {
private storage: Storage;
private logger: Logger;
private analyticsProvider: Provider<FirebaseAnalyticsInternalName>;

constructor(rc: RemoteConfig) {
this.storage = rc._storage;
this.logger = rc._logger;
this.analyticsProvider = rc._analyticsProvider;
}

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 {
const customProperty: Record<string, string | null> = {};
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
if (!currentActiveExperiments.has(experimentId)) {
customProperty[experimentId] = experimentInfo.variantId;
}
}
this.addExperimentToAnalytics(customProperty);
}

private removeInactiveExperiments(
currentActiveExperiments: Set<string>,
experimentInfoMap: Map<string, FirebaseExperimentDescription>
): void {
const customProperty: Record<string, string | null> = {};
for (const experimentId of currentActiveExperiments) {
if (!experimentInfoMap.has(experimentId)) {
customProperty[experimentId] = null;
}
}
this.addExperimentToAnalytics(customProperty);
}

private addExperimentToAnalytics(
customProperty: Record<string, string | null>
): void {
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
});
}
}
}
10 changes: 9 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,19 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
// config.
return false;
}
const experiment = new Experiment(rc);
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;
}
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 };
}
}
8 changes: 6 additions & 2 deletions packages/remote-config/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {
Expand Down Expand Up @@ -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
Expand All @@ -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<ErrorCode, ErrorParams>(
Expand Down
Loading
Loading