diff --git a/x-pack/plugins/cloud/public/fullstory.ts b/x-pack/plugins/cloud/public/fullstory.ts index 25d5320a063bd..4f76abf540cae 100644 --- a/x-pack/plugins/cloud/public/fullstory.ts +++ b/x-pack/plugins/cloud/public/fullstory.ts @@ -14,8 +14,11 @@ export interface FullStoryDeps { packageInfo: PackageInfo; } +export type FullstoryUserVars = Record; + export interface FullStoryApi { - identify(userId: string, userVars?: Record): void; + identify(userId: string, userVars?: FullstoryUserVars): void; + setUserVars(userVars?: FullstoryUserVars): void; event(eventName: string, eventProperties: Record): void; } diff --git a/x-pack/plugins/cloud/public/plugin.test.mocks.ts b/x-pack/plugins/cloud/public/plugin.test.mocks.ts index 4eb206d07bf85..b79fb1bc65130 100644 --- a/x-pack/plugins/cloud/public/plugin.test.mocks.ts +++ b/x-pack/plugins/cloud/public/plugin.test.mocks.ts @@ -10,6 +10,7 @@ import type { FullStoryDeps, FullStoryApi, FullStoryService } from './fullstory' export const fullStoryApiMock: jest.Mocked = { event: jest.fn(), + setUserVars: jest.fn(), identify: jest.fn(), }; export const initializeFullStoryMock = jest.fn(() => ({ diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 9b3ddc8e7294e..c63758e52b356 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -11,6 +11,7 @@ import { homePluginMock } from 'src/plugins/home/public/mocks'; import { securityMock } from '../../security/public/mocks'; import { fullStoryApiMock, initializeFullStoryMock } from './plugin.test.mocks'; import { CloudPlugin, CloudConfigType, loadFullStoryUserId } from './plugin'; +import { Observable, Subject } from 'rxjs'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -23,10 +24,12 @@ describe('Cloud Plugin', () => { config = {}, securityEnabled = true, currentUserProps = {}, + currentAppId$ = undefined, }: { config?: Partial; securityEnabled?: boolean; currentUserProps?: Record; + currentAppId$?: Observable; }) => { const initContext = coreMock.createPluginInitializerContext({ id: 'cloudId', @@ -39,9 +42,15 @@ describe('Cloud Plugin', () => { }, ...config, }); + const plugin = new CloudPlugin(initContext); const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + if (currentAppId$) { + coreStart.application.currentAppId$ = currentAppId$; + } + coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]); const securitySetup = securityMock.createSetup(); securitySetup.authc.getCurrentUser.mockResolvedValue( securityMock.createMockAuthenticatedUser(currentUserProps) @@ -78,10 +87,46 @@ describe('Cloud Plugin', () => { }); expect(fullStoryApiMock.identify).toHaveBeenCalledWith( - '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4' + '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4', + { + version_str: 'version', + version_major_int: -1, + version_minor_int: -1, + version_patch_int: -1, + } ); }); + it('calls FS.setUserVars everytime an app changes', async () => { + const currentAppId$ = new Subject(); + const { plugin } = await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' } }, + currentUserProps: { + username: '1234', + }, + currentAppId$, + }); + + expect(fullStoryApiMock.setUserVars).not.toHaveBeenCalled(); + currentAppId$.next('App1'); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + app_id_str: 'App1', + }); + currentAppId$.next(); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + app_id_str: 'unknown', + }); + + currentAppId$.next('App2'); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + app_id_str: 'App2', + }); + + expect(currentAppId$.observers.length).toBe(1); + plugin.stop(); + expect(currentAppId$.observers.length).toBe(0); + }); + it('does not call FS.identify when security is not available', async () => { await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 16c11d569c5f7..2f4e44f16d326 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -12,8 +12,10 @@ import { PluginInitializerContext, HttpStart, IBasePath, + ApplicationStart, } from 'src/core/public'; import { i18n } from '@kbn/i18n'; +import { Subscription } from 'rxjs'; import type { AuthenticatedUser, SecurityPluginSetup, @@ -57,9 +59,15 @@ export interface CloudSetup { isCloudEnabled: boolean; } +interface SetupFullstoryDeps extends CloudSetupDependencies { + application?: Promise; + basePath: IBasePath; +} + export class CloudPlugin implements Plugin { private config!: CloudConfigType; private isCloudEnabled: boolean; + private appSubscription?: Subscription; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); @@ -67,7 +75,10 @@ export class CloudPlugin implements Plugin { } public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { - this.setupFullstory({ basePath: core.http.basePath, security }).catch((e) => + const application = core.getStartServices().then(([coreStart]) => { + return coreStart.application; + }); + this.setupFullstory({ basePath: core.http.basePath, security, application }).catch((e) => // eslint-disable-next-line no-console console.debug(`Error setting up FullStory: ${e.toString()}`) ); @@ -130,6 +141,10 @@ export class CloudPlugin implements Plugin { .catch(() => setLinks(true)); } + public stop() { + this.appSubscription?.unsubscribe(); + } + /** * Determines if the current user should see links back to Cloud. * This isn't a true authorization check, but rather a heuristic to @@ -156,10 +171,7 @@ export class CloudPlugin implements Plugin { return user?.roles.includes('superuser') ?? true; } - private async setupFullstory({ - basePath, - security, - }: CloudSetupDependencies & { basePath: IBasePath }) { + private async setupFullstory({ basePath, security, application }: SetupFullstoryDeps) { const { enabled, org_id: orgId } = this.config.full_story; if (!enabled || !orgId) { return; // do not load any fullstory code in the browser if not enabled @@ -190,7 +202,35 @@ export class CloudPlugin implements Plugin { if (userId) { // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs const hashedId = sha256(userId.toString()); - fullStory.identify(hashedId); + application + ?.then(async () => { + const appStart = await application; + this.appSubscription = appStart.currentAppId$.subscribe((appId) => { + // Update the current application every time it changes + fullStory.setUserVars({ + app_id_str: appId ?? 'unknown', + }); + }); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error( + `[cloud.full_story] Could not retrieve application service due to error: ${e.toString()}`, + e + ); + }); + const kibanaVer = this.initializerContext.env.packageInfo.version; + // TODO: use semver instead + const parsedVer = (kibanaVer.indexOf('.') > -1 ? kibanaVer.split('.') : []).map((s) => + parseInt(s, 10) + ); + // `str` suffix is required for evn vars, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 + fullStory.identify(hashedId, { + version_str: kibanaVer, + version_major_int: parsedVer[0] ?? -1, + version_minor_int: parsedVer[1] ?? -1, + version_patch_int: parsedVer[2] ?? -1, + }); } } catch (e) { // eslint-disable-next-line no-console