Skip to content

Commit

Permalink
FullStory: use debounce before updating Page Vars (elastic#171450)
Browse files Browse the repository at this point in the history
  • Loading branch information
afharo authored Nov 21, 2023
1 parent f4e0d83 commit 55aebfd
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import * as RxJS from 'rxjs';
import type { FullStoryApi } from './types';

export const fullStoryApiMock: jest.Mocked<FullStoryApi> = {
Expand All @@ -22,3 +23,10 @@ jest.doMock('./load_snippet', () => {
loadSnippet: () => fullStoryApiMock,
};
});

jest.doMock('rxjs', () => {
return {
...RxJS,
debounceTime: () => RxJS.identity,
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ describe('FullStoryShipper', () => {
);
});

afterEach(() => {
fullstoryShipper.shutdown();
});

describe('extendContext', () => {
describe('FS.identify', () => {
test('calls `identify` when the userId is provided', () => {
Expand Down Expand Up @@ -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);
});
});
});

Expand Down
148 changes: 95 additions & 53 deletions packages/analytics/shippers/fullstory/src/fullstory_shipper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<EventContext, typeof PAGE_VARS_KEYS[number]>;

/**
* FullStory shipper.
*/
Expand All @@ -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<EventContext>();
private readonly userContext$ = new Subject<FullStoryUserVars>();
private readonly subscriptions = new Subscription();

/**
* Creates a new instance of the FullStoryShipper.
Expand All @@ -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<FullStoryPageContext> & Record<string, unknown>);
}),
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) : {}),
});
})
);
}

/**
Expand All @@ -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<Pick<EventContext, typeof PAGE_VARS_KEYS[number]>> & Record<string, unknown>);

// 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);
}

/**
Expand Down Expand Up @@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -22,7 +22,7 @@ describe('Cloud Plugin', () => {
isCloudEnabled = true,
isElasticStaffOwned = false,
}: {
config?: Partial<CloudFullStoryConfigType>;
config?: Partial<CloudFullStoryConfig>;
isCloudEnabled?: boolean;
isElasticStaffOwned?: boolean;
}) => {
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof configSchema>;
Expand All @@ -34,6 +35,7 @@ export const config: PluginConfigDescriptor<CloudFullStoryConfigType> = {
exposeToBrowser: {
org_id: true,
eventTypesAllowlist: true,
pageVarsDebounceTime: true,
},
schema: configSchema,
deprecations: () => [
Expand Down

0 comments on commit 55aebfd

Please sign in to comment.