diff --git a/.changeset/silent-islands-fix.md b/.changeset/silent-islands-fix.md new file mode 100644 index 00000000000..b7403ef1284 --- /dev/null +++ b/.changeset/silent-islands-fix.md @@ -0,0 +1,6 @@ +--- +'@firebase/analytics': minor +'firebase': minor +--- + +Add method `getGoogleAnalyticsClientId()` to retrieve an unique identifier for a web client. This allows users to log purchase and other events from their backends using Google Analytics 4 Measurement Protocol and to have those events be connected to actions taken on the client within their Firebase web app. `getGoogleAnalyticsClientId()` will simplify this event recording process. diff --git a/common/api-review/analytics.api.md b/common/api-review/analytics.api.md index af301eebfa0..7b6e9cfb182 100644 --- a/common/api-review/analytics.api.md +++ b/common/api-review/analytics.api.md @@ -133,6 +133,9 @@ export interface EventParams { // @public export function getAnalytics(app?: FirebaseApp): Analytics; +// @public +export function getGoogleAnalyticsClientId(analyticsInstance: Analytics): Promise; + // @public export interface GtagConfigParams { // (undocumented) diff --git a/docs-devsite/analytics.md b/docs-devsite/analytics.md index bd6e4b69351..a79e6f005d9 100644 --- a/docs-devsite/analytics.md +++ b/docs-devsite/analytics.md @@ -20,6 +20,7 @@ Firebase Analytics | [getAnalytics(app)](./analytics.md#getanalytics) | Returns an [Analytics](./analytics.analytics.md#analytics_interface) instance for the given app. | | [initializeAnalytics(app, options)](./analytics.md#initializeanalytics) | Returns an [Analytics](./analytics.analytics.md#analytics_interface) instance for the given app. | | function(analyticsInstance...) | +| [getGoogleAnalyticsClientId(analyticsInstance)](./analytics.md#getgoogleanalyticsclientid) | Retrieves a unique Google Analytics identifier for the web client. See [client\_id](https://developers.google.com/analytics/devguides/collection/ga4/reference/config#client_id). | | [logEvent(analyticsInstance, eventName, eventParams, options)](./analytics.md#logevent) | Sends a Google Analytics event with given eventParams. This method automatically associates this logged event with this Firebase web app instance on this device.List of recommended event parameters can be found in [the GA4 reference documentation](https://developers.google.com/gtagjs/reference/ga4-events). | | [logEvent(analyticsInstance, eventName, eventParams, options)](./analytics.md#logevent) | Sends a Google Analytics event with given eventParams. This method automatically associates this logged event with this Firebase web app instance on this device.List of recommended event parameters can be found in [the GA4 reference documentation](https://developers.google.com/gtagjs/reference/ga4-events). | | [logEvent(analyticsInstance, eventName, eventParams, options)](./analytics.md#logevent) | Sends a Google Analytics event with given eventParams. This method automatically associates this logged event with this Firebase web app instance on this device.See [Track Screenviews](https://firebase.google.com/docs/analytics/screenviews). | @@ -121,6 +122,26 @@ export declare function initializeAnalytics(app: FirebaseApp, options?: Analytic [Analytics](./analytics.analytics.md#analytics_interface) +## getGoogleAnalyticsClientId() + +Retrieves a unique Google Analytics identifier for the web client. See [client\_id](https://developers.google.com/analytics/devguides/collection/ga4/reference/config#client_id). + +Signature: + +```typescript +export declare function getGoogleAnalyticsClientId(analyticsInstance: Analytics): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| analyticsInstance | [Analytics](./analytics.analytics.md#analytics_interface) | | + +Returns: + +Promise<string> + ## logEvent() Sends a Google Analytics event with given `eventParams`. This method automatically associates this logged event with this Firebase web app instance on this device. diff --git a/packages/analytics/src/api.ts b/packages/analytics/src/api.ts index 3e3119fcb78..f44cbad2de9 100644 --- a/packages/analytics/src/api.ts +++ b/packages/analytics/src/api.ts @@ -50,7 +50,8 @@ import { setUserProperties as internalSetUserProperties, setAnalyticsCollectionEnabled as internalSetAnalyticsCollectionEnabled, _setConsentDefaultForInit, - _setDefaultEventParametersForInit + _setDefaultEventParametersForInit, + internalGetGoogleAnalyticsClientId } from './functions'; import { ERROR_FACTORY, AnalyticsError } from './errors'; @@ -167,6 +168,24 @@ export function setCurrentScreen( ).catch(e => logger.error(e)); } +/** + * Retrieves a unique Google Analytics identifier for the web client. + * See {@link https://developers.google.com/analytics/devguides/collection/ga4/reference/config#client_id | client_id}. + * + * @public + * + * @param app - The {@link @firebase/app#FirebaseApp} to use. + */ +export async function getGoogleAnalyticsClientId( + analyticsInstance: Analytics +): Promise { + analyticsInstance = getModularInstance(analyticsInstance); + return internalGetGoogleAnalyticsClientId( + wrappedGtagFunction, + initializationPromisesMap[analyticsInstance.app.options.appId!] + ); +} + /** * Use gtag `config` command to set `user_id`. * diff --git a/packages/analytics/src/constants.ts b/packages/analytics/src/constants.ts index 7f3da181288..e95e76e0ace 100644 --- a/packages/analytics/src/constants.ts +++ b/packages/analytics/src/constants.ts @@ -35,5 +35,6 @@ export const enum GtagCommand { EVENT = 'event', SET = 'set', CONFIG = 'config', - CONSENT = 'consent' + CONSENT = 'consent', + GET = 'get' } diff --git a/packages/analytics/src/errors.ts b/packages/analytics/src/errors.ts index eacda193573..278fa7ba137 100644 --- a/packages/analytics/src/errors.ts +++ b/packages/analytics/src/errors.ts @@ -28,6 +28,7 @@ export const enum AnalyticsError { CONFIG_FETCH_FAILED = 'config-fetch-failed', NO_API_KEY = 'no-api-key', NO_APP_ID = 'no-app-id', + NO_CLIENT_ID = 'no-client-id', INVALID_GTAG_RESOURCE = 'invalid-gtag-resource' } @@ -66,6 +67,7 @@ const ERRORS: ErrorMap = { [AnalyticsError.NO_APP_ID]: 'The "appId" field is empty in the local Firebase config. Firebase Analytics requires this field to' + 'contain a valid app ID.', + [AnalyticsError.NO_CLIENT_ID]: 'The "client_id" field is empty.', [AnalyticsError.INVALID_GTAG_RESOURCE]: 'Trusted Types detected an invalid gtag resource: {$gtagURL}.' }; diff --git a/packages/analytics/src/functions.test.ts b/packages/analytics/src/functions.test.ts index 4deca1a9401..1e4ee4a569f 100644 --- a/packages/analytics/src/functions.test.ts +++ b/packages/analytics/src/functions.test.ts @@ -27,10 +27,13 @@ import { defaultEventParametersForInit, _setDefaultEventParametersForInit, _setConsentDefaultForInit, - defaultConsentSettingsForInit + defaultConsentSettingsForInit, + internalGetGoogleAnalyticsClientId } from './functions'; import { GtagCommand } from './constants'; import { ConsentSettings } from './public-types'; +import { Gtag } from './types'; +import { AnalyticsError } from './errors'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeInitializationPromise = Promise.resolve(fakeMeasurementId); @@ -238,4 +241,34 @@ describe('FirebaseAnalytics methods', () => { ...additionalParams }); }); + it('internalGetGoogleAnalyticsClientId() rejects when no client_id is available', async () => { + await expect( + internalGetGoogleAnalyticsClientId( + function fakeWrappedGtag( + unused1: unknown, + unused2: unknown, + unused3: unknown, + callBackStub: (clientId: string) => {} + ): void { + callBackStub(''); + } as Gtag, + fakeInitializationPromise + ) + ).to.be.rejectedWith(AnalyticsError.NO_CLIENT_ID); + }); + it('internalGetGoogleAnalyticsClientId() returns client_id when available', async () => { + const CLIENT_ID = 'clientId1234'; + const id = await internalGetGoogleAnalyticsClientId( + function fakeWrappedGtag( + unused1: unknown, + unused2: unknown, + unused3: unknown, + callBackStub: (clientId: string) => {} + ): void { + callBackStub(CLIENT_ID); + } as Gtag, + fakeInitializationPromise + ); + expect(id).to.equal(CLIENT_ID); + }); }); diff --git a/packages/analytics/src/functions.ts b/packages/analytics/src/functions.ts index 0b3b15f68c1..dd5062aa548 100644 --- a/packages/analytics/src/functions.ts +++ b/packages/analytics/src/functions.ts @@ -24,6 +24,7 @@ import { } from './public-types'; import { Gtag } from './types'; import { GtagCommand } from './constants'; +import { AnalyticsError, ERROR_FACTORY } from './errors'; /** * Event parameters to set on 'gtag' during initialization. @@ -137,6 +138,32 @@ export async function setUserProperties( } } +/** + * Retrieves a unique Google Analytics identifier for the web client. + * See {@link https://developers.google.com/analytics/devguides/collection/ga4/reference/config#client_id | client_id}. + * + * @param gtagFunction Wrapped gtag function that waits for fid to be set before sending an event + */ +export async function internalGetGoogleAnalyticsClientId( + gtagFunction: Gtag, + initializationPromise: Promise +): Promise { + const measurementId = await initializationPromise; + return new Promise((resolve, reject) => { + gtagFunction( + GtagCommand.GET, + measurementId, + 'client_id', + (clientId: string) => { + if (!clientId) { + reject(ERROR_FACTORY.create(AnalyticsError.NO_CLIENT_ID)); + } + resolve(clientId); + } + ); + }); +} + /** * Set whether collection is enabled for this ID. * diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index 9d79c6b3a4a..b35a3f6867b 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -332,6 +332,37 @@ describe('Gtag wrapping functions', () => { expect((window['dataLayer'] as DataLayer).length).to.equal(1); }); + it('new window.gtag function does not wait when sending "get" calls', async () => { + wrapOrCreateGtag( + { [fakeAppId]: Promise.resolve(fakeMeasurementId) }, + fakeDynamicConfigPromises, + {}, + 'dataLayer', + 'gtag' + ); + window['dataLayer'] = []; + (window['gtag'] as Gtag)( + GtagCommand.GET, + fakeMeasurementId, + 'client_id', + clientId => console.log(clientId) + ); + expect((window['dataLayer'] as DataLayer).length).to.equal(1); + }); + + it('new window.gtag function does not wait when sending an unknown command', async () => { + wrapOrCreateGtag( + { [fakeAppId]: Promise.resolve(fakeMeasurementId) }, + fakeDynamicConfigPromises, + {}, + 'dataLayer', + 'gtag' + ); + window['dataLayer'] = []; + (window['gtag'] as Gtag)('new-command-from-gtag-team', fakeMeasurementId); + expect((window['dataLayer'] as DataLayer).length).to.equal(1); + }); + it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { const initPromise1 = new Deferred(); wrapOrCreateGtag( diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index e926c14a725..7f9582900c2 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -277,37 +277,49 @@ function wrapGtag( * @param gtagParams Params if event is EVENT/CONFIG. */ async function gtagWrapper( - command: 'config' | 'set' | 'event' | 'consent', - idOrNameOrParams: string | ControlParams, - gtagParams?: GtagConfigOrEventParams | ConsentSettings + command: 'config' | 'set' | 'event' | 'consent' | 'get' | string, + ...args: unknown[] ): Promise { try { // If event, check that relevant initialization promises have completed. if (command === GtagCommand.EVENT) { + const [measurementId, gtagParams] = args; // If EVENT, second arg must be measurementId. await gtagOnEvent( gtagCore, initializationPromisesMap, dynamicConfigPromisesList, - idOrNameOrParams as string, + measurementId as string, gtagParams as GtagConfigOrEventParams ); } else if (command === GtagCommand.CONFIG) { + const [measurementId, gtagParams] = args; // If CONFIG, second arg must be measurementId. await gtagOnConfig( gtagCore, initializationPromisesMap, dynamicConfigPromisesList, measurementIdToAppId, - idOrNameOrParams as string, + measurementId as string, gtagParams as GtagConfigOrEventParams ); } else if (command === GtagCommand.CONSENT) { - // If CONFIG, second arg must be measurementId. + const [gtagParams] = args; gtagCore(GtagCommand.CONSENT, 'update', gtagParams as ConsentSettings); - } else { + } else if (command === GtagCommand.GET) { + const [measurementId, fieldName, callback] = args; + gtagCore( + GtagCommand.GET, + measurementId as string, + fieldName as string, + callback as (...args: unknown[]) => void + ); + } else if (command === GtagCommand.SET) { + const [customParams] = args; // If SET, second arg must be params. - gtagCore(GtagCommand.SET, idOrNameOrParams as CustomParams); + gtagCore(GtagCommand.SET, customParams as CustomParams); + } else { + gtagCore(command, ...args); } } catch (e) { logger.error(e); diff --git a/packages/analytics/src/types.ts b/packages/analytics/src/types.ts index 9ec0a9e79ab..9d3db8112f0 100644 --- a/packages/analytics/src/types.ts +++ b/packages/analytics/src/types.ts @@ -73,6 +73,13 @@ export interface Gtag { subCommand: 'default' | 'update', consentSettings: ConsentSettings ): void; + ( + command: 'get', + measurementId: string, + fieldName: string, + callback: (...args: unknown[]) => void + ): void; + (command: string, ...args: unknown[]): void; } export type DataLayer = IArguments[];