diff --git a/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.mocks.ts b/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.mocks.ts index fadd1ffee2ae0..7608606c39395 100644 --- a/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.mocks.ts +++ b/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.mocks.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import * as RxJS from 'rxjs'; import type { FullStoryApi } from './types'; export const fullStoryApiMock: jest.Mocked = { @@ -22,3 +23,10 @@ jest.doMock('./load_snippet', () => { loadSnippet: () => fullStoryApiMock, }; }); + +jest.doMock('rxjs', () => { + return { + ...RxJS, + debounceTime: () => RxJS.identity, + }; +}); diff --git a/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.ts b/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.ts index 4e874b405add1..88f53fd7f837d 100644 --- a/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.ts +++ b/packages/analytics/shippers/fullstory/src/fullstory_shipper.test.ts @@ -28,6 +28,10 @@ describe('FullStoryShipper', () => { ); }); + afterEach(() => { + fullstoryShipper.shutdown(); + }); + describe('extendContext', () => { describe('FS.identify', () => { test('calls `identify` when the userId is provided', () => { @@ -119,6 +123,21 @@ describe('FullStoryShipper', () => { labels: { serverless_str: 'test' }, }); }); + + test('emits once only if nothing changes', () => { + const context = { + userId: 'test-user-id', + version: '1.2.3', + cloudId: 'test-es-org-id', + labels: { serverless: 'test' }, + foo: 'bar', + }; + fullstoryShipper.extendContext(context); + fullstoryShipper.extendContext(context); + expect(fullStoryApiMock.setVars).toHaveBeenCalledTimes(1); + fullstoryShipper.extendContext(context); + expect(fullStoryApiMock.setVars).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/analytics/shippers/fullstory/src/fullstory_shipper.ts b/packages/analytics/shippers/fullstory/src/fullstory_shipper.ts index bcb95ae38f1fb..a4ad730f87b2e 100644 --- a/packages/analytics/shippers/fullstory/src/fullstory_shipper.ts +++ b/packages/analytics/shippers/fullstory/src/fullstory_shipper.ts @@ -12,6 +12,7 @@ import type { Event, IShipper, } from '@kbn/analytics-client'; +import { Subject, distinct, debounceTime, map, filter, Subscription } from 'rxjs'; import { get, has } from 'lodash'; import { set } from '@kbn/safer-lodash-set'; import type { FullStoryApi } from './types'; @@ -55,8 +56,18 @@ export interface FullStoryShipperConfig extends FullStorySnippetConfig { * If this setting is provided, it'll only send the event types specified in this list. */ eventTypesAllowlist?: string[]; + pageVarsDebounceTimeMs?: number; } +interface FullStoryUserVars { + userId?: string; + isElasticCloudUser?: boolean; + cloudIsElasticStaffOwned?: boolean; + cloudTrialEndDate?: string; +} + +type FullStoryPageContext = Pick; + /** * FullStory shipper. */ @@ -67,6 +78,9 @@ export class FullStoryShipper implements IShipper { private readonly fullStoryApi: FullStoryApi; private lastUserId: string | undefined; private readonly eventTypesAllowlist?: string[]; + private readonly pageContext$ = new Subject(); + private readonly userContext$ = new Subject(); + private readonly subscriptions = new Subscription(); /** * Creates a new instance of the FullStoryShipper. @@ -77,9 +91,54 @@ export class FullStoryShipper implements IShipper { config: FullStoryShipperConfig, private readonly initContext: AnalyticsClientInitContext ) { - const { eventTypesAllowlist, ...snippetConfig } = config; + const { eventTypesAllowlist, pageVarsDebounceTimeMs = 500, ...snippetConfig } = config; this.fullStoryApi = loadSnippet(snippetConfig); this.eventTypesAllowlist = eventTypesAllowlist; + + this.subscriptions.add( + this.userContext$ + .pipe( + distinct(({ userId, isElasticCloudUser, cloudIsElasticStaffOwned, cloudTrialEndDate }) => + [userId, isElasticCloudUser, cloudIsElasticStaffOwned, cloudTrialEndDate].join('-') + ) + ) + .subscribe((userVars) => this.updateUserVars(userVars)) + ); + + this.subscriptions.add( + this.pageContext$ + .pipe( + map((newContext) => { + // Cherry-picking fields because FS limits the number of fields that can be sent. + // > Note: You can capture up to 20 unique page properties (exclusive of pageName) for any given page + // > and up to 500 unique page properties across all pages. + // https://help.fullstory.com/hc/en-us/articles/1500004101581-FS-setVars-API-Sending-custom-page-data-to-FullStory + return PAGE_VARS_KEYS.reduce((acc, key) => { + if (has(newContext, key)) { + set(acc, key, get(newContext, key)); + } + return acc; + }, {} as Partial & Record); + }), + filter((pageVars) => Object.keys(pageVars).length > 0), + // Wait for anything to actually change. + distinct((pageVars) => { + const sortedKeys = Object.keys(pageVars).sort(); + return sortedKeys.map((key) => pageVars[key]).join('-'); + }), + // We need some debounce time to ensure everything is updated before calling FS because some properties cannot be changed twice for the same URL. + debounceTime(pageVarsDebounceTimeMs) + ) + .subscribe((pageVars) => { + this.initContext.logger.debug( + `Calling FS.setVars with context ${JSON.stringify(pageVars)}` + ); + this.fullStoryApi.setVars('page', { + ...formatPayload(pageVars), + ...(pageVars.version ? getParsedVersion(pageVars.version) : {}), + }); + }) + ); } /** @@ -89,57 +148,11 @@ export class FullStoryShipper implements IShipper { public extendContext(newContext: EventContext): void { this.initContext.logger.debug(`Received context ${JSON.stringify(newContext)}`); - // FullStory requires different APIs for different type of contexts. - const { - userId, - isElasticCloudUser, - cloudIsElasticStaffOwned, - cloudTrialEndDate, - ...nonUserContext - } = newContext; - - // Call it only when the userId changes - if (userId && userId !== this.lastUserId) { - this.initContext.logger.debug(`Calling FS.identify with userId ${userId}`); - // We need to call the API for every new userId (restarting the session). - this.fullStoryApi.identify(userId); - this.lastUserId = userId; - } - - // User-level context - if ( - typeof isElasticCloudUser === 'boolean' || - typeof cloudIsElasticStaffOwned === 'boolean' || - cloudTrialEndDate - ) { - const userVars = { - isElasticCloudUser, - cloudIsElasticStaffOwned, - cloudTrialEndDate, - }; - this.initContext.logger.debug(`Calling FS.setUserVars with ${JSON.stringify(userVars)}`); - this.fullStoryApi.setUserVars(formatPayload(userVars)); - } - - // Cherry-picking fields because FS limits the number of fields that can be sent. - // > Note: You can capture up to 20 unique page properties (exclusive of pageName) for any given page - // > and up to 500 unique page properties across all pages. - // https://help.fullstory.com/hc/en-us/articles/1500004101581-FS-setVars-API-Sending-custom-page-data-to-FullStory - const pageVars = PAGE_VARS_KEYS.reduce((acc, key) => { - if (has(nonUserContext, key)) { - set(acc, key, get(nonUserContext, key)); - } - return acc; - }, {} as Partial> & Record); - + // FullStory requires different APIs for different type of contexts: + // User-level context. + this.userContext$.next(newContext); // Event-level context. At the moment, only the scope `page` is supported by FullStory for webapps. - if (Object.keys(pageVars).length) { - this.initContext.logger.debug(`Calling FS.setVars with context ${JSON.stringify(pageVars)}`); - this.fullStoryApi.setVars('page', { - ...formatPayload(pageVars), - ...(pageVars.version ? getParsedVersion(pageVars.version) : {}), - }); - } + this.pageContext$.next(newContext); } /** @@ -184,9 +197,38 @@ export class FullStoryShipper implements IShipper { /** * Shuts down the shipper. - * It doesn't really do anything inside because this shipper doesn't hold any internal queues. */ public shutdown() { - // No need to do anything here for now. + this.subscriptions.unsubscribe(); + } + + private updateUserVars({ + userId, + isElasticCloudUser, + cloudIsElasticStaffOwned, + cloudTrialEndDate, + }: FullStoryUserVars) { + // Call it only when the userId changes + if (userId && userId !== this.lastUserId) { + this.initContext.logger.debug(`Calling FS.identify with userId ${userId}`); + // We need to call the API for every new userId (restarting the session). + this.fullStoryApi.identify(userId); + this.lastUserId = userId; + } + + // User-level context + if ( + typeof isElasticCloudUser === 'boolean' || + typeof cloudIsElasticStaffOwned === 'boolean' || + cloudTrialEndDate + ) { + const userVars = { + isElasticCloudUser, + cloudIsElasticStaffOwned, + cloudTrialEndDate, + }; + this.initContext.logger.debug(`Calling FS.setUserVars with ${JSON.stringify(userVars)}`); + this.fullStoryApi.setUserVars(formatPayload(userVars)); + } } } diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 1a168e8fe73e7..dc6ff1211eb9e 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -238,6 +238,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud_integrations.full_story.org_id (any)', // No PII. Just the list of event types we want to forward to FullStory. 'xpack.cloud_integrations.full_story.eventTypesAllowlist (array)', + 'xpack.cloud_integrations.full_story.pageVarsDebounceTime (duration)', 'xpack.cloud_integrations.gain_sight.org_id (any)', 'xpack.cloud.id (string)', 'xpack.cloud.organization_url (string)', diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.test.ts index 836cafa8ac7af..b0abbbe568d21 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.test.ts @@ -6,9 +6,9 @@ */ import { coreMock } from '@kbn/core/public/mocks'; -import type { CloudFullStoryConfigType } from '../server/config'; -import { CloudFullStoryPlugin } from './plugin'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; +import { duration } from 'moment'; +import { CloudFullStoryConfig, CloudFullStoryPlugin } from './plugin'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -22,7 +22,7 @@ describe('Cloud Plugin', () => { isCloudEnabled = true, isElasticStaffOwned = false, }: { - config?: Partial; + config?: Partial; isCloudEnabled?: boolean; isElasticStaffOwned?: boolean; }) => { @@ -55,6 +55,20 @@ describe('Cloud Plugin', () => { }); }); + test('register the shipper FullStory with the correct duration', async () => { + const { coreSetup } = await setupPlugin({ + config: { org_id: 'foo', pageVarsDebounceTime: `${duration(500, 'ms')}` }, + }); + + expect(coreSetup.analytics.registerShipper).toHaveBeenCalled(); + expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), { + fullStoryOrgId: 'foo', + pageVarsDebounceTimeMs: 500, + scriptUrl: '/internal/cloud/100/fullstory.js', + namespace: 'FSKibana', + }); + }); + it('does not call initializeFullStory when isCloudEnabled=false', async () => { const { coreSetup } = await setupPlugin({ config: { org_id: 'foo' }, diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.ts index a248b27f93714..7636d38b681e1 100755 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/public/plugin.ts @@ -13,15 +13,17 @@ import type { Plugin, } from '@kbn/core/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; +import { duration } from 'moment'; interface SetupFullStoryDeps { analytics: AnalyticsServiceSetup; basePath: IBasePath; } -interface CloudFullStoryConfig { +export interface CloudFullStoryConfig { org_id?: string; eventTypesAllowlist: string[]; + pageVarsDebounceTime: string; } interface CloudFullStorySetupDeps { @@ -61,7 +63,7 @@ export class CloudFullStoryPlugin implements Plugin { * @private */ private async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) { - const { org_id: fullStoryOrgId, eventTypesAllowlist } = this.config; + const { org_id: fullStoryOrgId, eventTypesAllowlist, pageVarsDebounceTime } = this.config; if (!fullStoryOrgId) { return; // do not load any FullStory code in the browser if not enabled } @@ -71,6 +73,10 @@ export class CloudFullStoryPlugin implements Plugin { analytics.registerShipper(FullStoryShipper, { eventTypesAllowlist, fullStoryOrgId, + // Duration configs get stringified when forwarded to the UI and need reconversion + ...(pageVarsDebounceTime + ? { pageVarsDebounceTimeMs: duration(pageVarsDebounceTime).asMilliseconds() } + : {}), // Load an Elastic-internally audited script. Ideally, it should be hosted on a CDN. scriptUrl: basePath.prepend( `/internal/cloud/${this.initializerContext.env.packageInfo.buildNum}/fullstory.js` diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts index 85822bae819eb..8c6eb180f1db6 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @@ -26,6 +26,7 @@ const configSchema = schema.object({ 'Host Flyout Filter Added', // Worst-case scenario once per second - AT RISK, ], }), + pageVarsDebounceTime: schema.duration({ defaultValue: '500ms' }), }); export type CloudFullStoryConfigType = TypeOf; @@ -34,6 +35,7 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { org_id: true, eventTypesAllowlist: true, + pageVarsDebounceTime: true, }, schema: configSchema, deprecations: () => [