mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
FullStory: use debounce before updating Page Vars (#171450)
This commit is contained in:
parent
f4e0d83598
commit
55aebfdd2e
7 changed files with 150 additions and 58 deletions
|
@ -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> = {
|
||||
|
@ -22,3 +23,10 @@ jest.doMock('./load_snippet', () => {
|
|||
loadSnippet: () => fullStoryApiMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('rxjs', () => {
|
||||
return {
|
||||
...RxJS,
|
||||
debounceTime: () => RxJS.identity,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<EventContext, typeof PAGE_VARS_KEYS[number]>;
|
||||
|
||||
/**
|
||||
* 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<EventContext>();
|
||||
private readonly userContext$ = new Subject<FullStoryUserVars>();
|
||||
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<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) : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)',
|
||||
|
|
|
@ -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<CloudFullStoryConfigType>;
|
||||
config?: Partial<CloudFullStoryConfig>;
|
||||
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' },
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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>;
|
||||
|
@ -34,6 +35,7 @@ export const config: PluginConfigDescriptor<CloudFullStoryConfigType> = {
|
|||
exposeToBrowser: {
|
||||
org_id: true,
|
||||
eventTypesAllowlist: true,
|
||||
pageVarsDebounceTime: true,
|
||||
},
|
||||
schema: configSchema,
|
||||
deprecations: () => [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue