diff --git a/packages/elastic-analytics/BUILD.bazel b/packages/elastic-analytics/BUILD.bazel index a73c908c7ea52c7..dcf9d3377254263 100644 --- a/packages/elastic-analytics/BUILD.bazel +++ b/packages/elastic-analytics/BUILD.bazel @@ -36,6 +36,7 @@ NPM_MODULE_EXTRA_FILES = [ # "@npm//name-of-package" # eg. "@npm//lodash" RUNTIME_DEPS = [ + "@npm//moment", "@npm//rxjs", ] @@ -51,6 +52,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "@npm//@types/node", "@npm//@types/jest", + "@npm//moment", "@npm//rxjs", "//packages/kbn-logging:npm_module_types", "//packages/kbn-logging-mocks:npm_module_types", diff --git a/packages/elastic-analytics/src/analytics_client/analytics_client.test.ts b/packages/elastic-analytics/src/analytics_client/analytics_client.test.ts index 05b5f6c40dc7bd4..6b56c72c7045bb0 100644 --- a/packages/elastic-analytics/src/analytics_client/analytics_client.test.ts +++ b/packages/elastic-analytics/src/analytics_client/analytics_client.test.ts @@ -528,6 +528,44 @@ describe('AnalyticsClient', () => { ]); }); + test('The undefined values are not forwarded to the global context', async () => { + const context$ = new Subject<{ a_field?: boolean; b_field: number }>(); + analyticsClient.registerContextProvider({ + name: 'contextProviderA', + schema: { + a_field: { + type: 'boolean', + _meta: { + description: 'a_field description', + optional: true, + }, + }, + b_field: { + type: 'long', + _meta: { + description: 'b_field description', + }, + }, + }, + context$, + }); + + const globalContextPromise = globalContext$.pipe(take(6), toArray()).toPromise(); + context$.next({ b_field: 1 }); + context$.next({ a_field: false, b_field: 1 }); + context$.next({ a_field: true, b_field: 1 }); + context$.next({ b_field: 1 }); + context$.next({ a_field: undefined, b_field: 2 }); + await expect(globalContextPromise).resolves.toEqual([ + {}, // Original empty state + { b_field: 1 }, + { a_field: false, b_field: 1 }, + { a_field: true, b_field: 1 }, + { b_field: 1 }, // a_field is removed because the context provider removed it. + { b_field: 2 }, // a_field is not forwarded because it is `undefined` + ]); + }); + test('Fails to register 2 context providers with the same name', () => { analyticsClient.registerContextProvider({ name: 'contextProviderA', diff --git a/packages/elastic-analytics/src/analytics_client/context_service.ts b/packages/elastic-analytics/src/analytics_client/context_service.ts index cee3e56b389d165..7c3f3c8327eb7fd 100644 --- a/packages/elastic-analytics/src/analytics_client/context_service.ts +++ b/packages/elastic-analytics/src/analytics_client/context_service.ts @@ -69,9 +69,21 @@ export class ContextService { [...this.contextProvidersRegistry.values()].reduce((acc, context) => { return { ...acc, - ...context, + ...this.removeEmptyValues(context), }; }, {} as Partial) ); } + + private removeEmptyValues(context?: Partial) { + if (!context) { + return {}; + } + return Object.keys(context).reduce((acc, key) => { + if (context[key] !== undefined) { + acc[key] = context[key]; + } + return acc; + }, {} as Partial); + } } diff --git a/packages/elastic-analytics/src/events/types.ts b/packages/elastic-analytics/src/events/types.ts index 8523b7791150a56..3edc169346a6e33 100644 --- a/packages/elastic-analytics/src/events/types.ts +++ b/packages/elastic-analytics/src/events/types.ts @@ -8,7 +8,42 @@ import type { ShipperName } from '../analytics_client'; +/** + * Definition of the context that can be appended to the events through the {@link IAnalyticsClient.registerContextProvider}. + */ export interface EventContext { + /** + * The unique user ID. + */ + userId?: string; + /** + * The user's organization ID. + */ + esOrgId?: string; + /** + * The product's version. + */ + version?: string; + /** + * The name of the current page. + * @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. + */ + pageName?: string; + /** + * The current page. + * @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. + */ + page?: string; + /** + * The current application ID. + * @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. + */ + app_id?: string; + /** + * The current entity ID. + * @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. + */ + ent_id?: string; // TODO: Extend with known keys [key: string]: unknown; } diff --git a/packages/elastic-analytics/src/index.ts b/packages/elastic-analytics/src/index.ts index 382974783aeb11d..c22ea702c5be82e 100644 --- a/packages/elastic-analytics/src/index.ts +++ b/packages/elastic-analytics/src/index.ts @@ -36,8 +36,10 @@ export type { // Types for the registerEventType API EventTypeOpts, } from './analytics_client'; + export type { Event, EventContext, EventType, TelemetryCounter } from './events'; export { TelemetryCounterType } from './events'; + export type { RootSchema, SchemaObject, @@ -52,4 +54,6 @@ export type { AllowedSchemaStringTypes, AllowedSchemaTypes, } from './schema'; -export type { IShipper } from './shippers'; + +export type { IShipper, FullStorySnippetConfig, FullStoryShipperConfig } from './shippers'; +export { FullStoryShipper } from './shippers'; diff --git a/packages/elastic-analytics/src/shippers/fullstory/format_payload.test.ts b/packages/elastic-analytics/src/shippers/fullstory/format_payload.test.ts new file mode 100644 index 000000000000000..962241f24f3af74 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/format_payload.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { formatPayload } from './format_payload'; + +describe('formatPayload', () => { + test('appends `_str` to string values', () => { + const payload = { + foo: 'bar', + baz: ['qux'], + }; + + expect(formatPayload(payload)).toEqual({ + foo_str: payload.foo, + baz_strs: payload.baz, + }); + }); + + test('appends `_int` to integer values', () => { + const payload = { + foo: 1, + baz: [100000], + }; + + expect(formatPayload(payload)).toEqual({ + foo_int: payload.foo, + baz_ints: payload.baz, + }); + }); + + test('appends `_real` to integer values', () => { + const payload = { + foo: 1.5, + baz: [100000.5], + }; + + expect(formatPayload(payload)).toEqual({ + foo_real: payload.foo, + baz_reals: payload.baz, + }); + }); + + test('appends `_bool` to booleans values', () => { + const payload = { + foo: true, + baz: [false], + }; + + expect(formatPayload(payload)).toEqual({ + foo_bool: payload.foo, + baz_bools: payload.baz, + }); + }); + + test('appends `_date` to Date values', () => { + const payload = { + foo: new Date(), + baz: [new Date()], + }; + + expect(formatPayload(payload)).toEqual({ + foo_date: payload.foo, + baz_dates: payload.baz, + }); + }); + + test('appends `_date` to moment values', () => { + const payload = { + foo: moment(), + baz: [moment()], + }; + + expect(formatPayload(payload)).toEqual({ + foo_date: payload.foo, + baz_dates: payload.baz, + }); + }); + + test('supports nested values', () => { + const payload = { + nested: { + foo: 'bar', + baz: ['qux'], + }, + }; + + expect(formatPayload(payload)).toEqual({ + nested: { + foo_str: payload.nested.foo, + baz_strs: payload.nested.baz, + }, + }); + }); + + test('does not mutate reserved keys', () => { + const payload = { + uid: 'uid', + displayName: 'displayName', + email: 'email', + acctId: 'acctId', + website: 'website', + pageName: 'pageName', + }; + + expect(formatPayload(payload)).toEqual(payload); + }); + + test('removes undefined values', () => { + const payload = { + foo: undefined, + baz: [undefined], + }; + + expect(formatPayload(payload)).toEqual({}); + }); + + describe('String to Date identification', () => { + test('appends `_date` to ISO string values', () => { + const payload = { + foo: new Date().toISOString(), + baz: [new Date().toISOString()], + }; + + expect(formatPayload(payload)).toEqual({ + foo_date: payload.foo, + baz_dates: payload.baz, + }); + }); + + test('appends `_str` to random string values', () => { + const payload = { + foo: 'test-1', + baz: ['test-1'], + }; + + expect(formatPayload(payload)).toEqual({ + foo_str: payload.foo, + baz_strs: payload.baz, + }); + }); + }); +}); diff --git a/packages/elastic-analytics/src/shippers/fullstory/format_payload.ts b/packages/elastic-analytics/src/shippers/fullstory/format_payload.ts new file mode 100644 index 000000000000000..3d660684e337e20 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/format_payload.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; + +// https://help.fullstory.com/hc/en-us/articles/360020623234#reserved-properties +const FULLSTORY_RESERVED_PROPERTIES = [ + 'uid', + 'displayName', + 'email', + 'acctId', + 'website', + // https://developer.fullstory.com/page-variables + 'pageName', +]; + +export function formatPayload(context: Record): Record { + // format context keys as required for env vars, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 + return Object.fromEntries( + Object.entries(context) + // Discard any undefined values + .map<[string, unknown]>(([key, value]) => { + return Array.isArray(value) + ? [key, value.filter((v) => typeof v !== 'undefined')] + : [key, value]; + }) + .filter( + ([, value]) => typeof value !== 'undefined' && (!Array.isArray(value) || value.length > 0) + ) + // Transform key names according to the FullStory needs + .map(([key, value]) => { + if (FULLSTORY_RESERVED_PROPERTIES.includes(key)) { + return [key, value]; + } + if (isRecord(value)) { + return [key, formatPayload(value)]; + } + const valueType = getFullStoryType(value); + const formattedKey = valueType ? `${key}_${valueType}` : key; + return [formattedKey, value]; + }) + ); +} + +function getFullStoryType(value: unknown) { + // For arrays, make the decision based on the first element + const isArray = Array.isArray(value); + const v = isArray ? value[0] : value; + let type = ''; + switch (typeof v) { + case 'string': + if (moment(v, moment.ISO_8601, true).isValid()) { + type = 'date'; + break; + } + type = 'str'; + break; + case 'number': + type = Number.isInteger(v) ? 'int' : 'real'; + break; + case 'boolean': + type = 'bool'; + break; + case 'object': + if (isDate(v)) { + type = 'date'; + break; + } + default: + throw new Error(`Unsupported type: ${typeof v}`); + } + + // convert to plural form for arrays + return isArray ? `${type}s` : type; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) && !isDate(value); +} + +function isDate(value: unknown): value is Date { + return value instanceof Date || moment.isMoment(value); +} diff --git a/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.mocks.ts b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.mocks.ts new file mode 100644 index 000000000000000..fadd1ffee2ae0fc --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.mocks.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FullStoryApi } from './types'; + +export const fullStoryApiMock: jest.Mocked = { + identify: jest.fn(), + setUserVars: jest.fn(), + setVars: jest.fn(), + consent: jest.fn(), + restart: jest.fn(), + shutdown: jest.fn(), + event: jest.fn(), +}; +jest.doMock('./load_snippet', () => { + return { + loadSnippet: () => fullStoryApiMock, + }; +}); diff --git a/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.test.ts b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.test.ts new file mode 100644 index 000000000000000..c146a216fc5acd2 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { loggerMock } from '@kbn/logging-mocks'; +import { fullStoryApiMock } from './fullstory_shipper.mocks'; +import { FullStoryShipper } from './fullstory_shipper'; + +describe('FullStoryShipper', () => { + let fullstoryShipper: FullStoryShipper; + + beforeEach(() => { + jest.resetAllMocks(); + fullstoryShipper = new FullStoryShipper( + { + debug: true, + fullStoryOrgId: 'test-org-id', + }, + { + logger: loggerMock.create(), + sendTo: 'staging', + isDev: true, + } + ); + }); + + describe('extendContext', () => { + describe('FS.identify', () => { + test('calls `identify` when the userId is provided', () => { + const userId = 'test-user-id'; + fullstoryShipper.extendContext({ userId }); + expect(fullStoryApiMock.identify).toHaveBeenCalledWith(userId); + }); + + test('calls `identify` again only if the userId changes', () => { + const userId = 'test-user-id'; + fullstoryShipper.extendContext({ userId }); + expect(fullStoryApiMock.identify).toHaveBeenCalledTimes(1); + expect(fullStoryApiMock.identify).toHaveBeenCalledWith(userId); + + fullstoryShipper.extendContext({ userId }); + expect(fullStoryApiMock.identify).toHaveBeenCalledTimes(1); // still only called once + + fullstoryShipper.extendContext({ userId: `${userId}-1` }); + expect(fullStoryApiMock.identify).toHaveBeenCalledTimes(2); // called again because the user changed + expect(fullStoryApiMock.identify).toHaveBeenCalledWith(`${userId}-1`); + }); + }); + + describe('FS.setUserVars', () => { + test('calls `setUserVars` when version is provided', () => { + fullstoryShipper.extendContext({ version: '1.2.3' }); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + version_str: '1.2.3', + version_major_int: 1, + version_minor_int: 2, + version_patch_int: 3, + }); + }); + + test('calls `setUserVars` when esOrgId is provided', () => { + fullstoryShipper.extendContext({ esOrgId: 'test-es-org-id' }); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ org_id_str: 'test-es-org-id' }); + }); + + test('merges both: version and esOrgId if both are provided', () => { + fullstoryShipper.extendContext({ version: '1.2.3', esOrgId: 'test-es-org-id' }); + expect(fullStoryApiMock.setUserVars).toHaveBeenCalledWith({ + org_id_str: 'test-es-org-id', + version_str: '1.2.3', + version_major_int: 1, + version_minor_int: 2, + version_patch_int: 3, + }); + }); + }); + + describe('FS.setVars', () => { + test('adds the rest of the context to `setVars`', () => { + const context = { + userId: 'test-user-id', + version: '1.2.3', + esOrgId: 'test-es-org-id', + foo: 'bar', + }; + fullstoryShipper.extendContext(context); + expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { foo_str: 'bar' }); + }); + }); + }); + + describe('optIn', () => { + test('should call consent true and restart when isOptIn: true', () => { + fullstoryShipper.optIn(true); + expect(fullStoryApiMock.consent).toHaveBeenCalledWith(true); + expect(fullStoryApiMock.restart).toHaveBeenCalled(); + }); + + test('should call consent false and shutdown when isOptIn: false', () => { + fullstoryShipper.optIn(false); + expect(fullStoryApiMock.consent).toHaveBeenCalledWith(false); + expect(fullStoryApiMock.shutdown).toHaveBeenCalled(); + }); + }); + + describe('reportEvents', () => { + test('calls the API once per event in the array with the properties transformed', () => { + fullstoryShipper.reportEvents([ + { + event_type: 'test-event-1', + timestamp: '2020-01-01T00:00:00.000Z', + properties: { test: 'test-1' }, + context: { pageName: 'test-page-1' }, + }, + { + event_type: 'test-event-2', + timestamp: '2020-01-01T00:00:00.000Z', + properties: { test: 'test-2' }, + context: { pageName: 'test-page-1' }, + }, + ]); + + expect(fullStoryApiMock.event).toHaveBeenCalledTimes(2); + expect(fullStoryApiMock.event).toHaveBeenCalledWith('test-event-1', { + test_str: 'test-1', + }); + expect(fullStoryApiMock.event).toHaveBeenCalledWith('test-event-2', { + test_str: 'test-2', + }); + }); + }); +}); diff --git a/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.ts b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.ts new file mode 100644 index 000000000000000..f7dbcd2aac023a7 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/fullstory_shipper.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IShipper } from '../types'; +import type { AnalyticsClientInitContext } from '../../analytics_client'; +import type { EventContext, Event } from '../../events'; +import type { FullStoryApi } from './types'; +import type { FullStorySnippetConfig } from './load_snippet'; +import { getParsedVersion } from './get_parsed_version'; +import { formatPayload } from './format_payload'; +import { loadSnippet } from './load_snippet'; + +export type FullStoryShipperConfig = FullStorySnippetConfig; + +export class FullStoryShipper implements IShipper { + public static shipperName = 'FullStory'; + private readonly fullStoryApi: FullStoryApi; + private lastUserId: string | undefined; + + constructor( + config: FullStoryShipperConfig, + private readonly initContext: AnalyticsClientInitContext + ) { + this.fullStoryApi = loadSnippet(config); + } + + 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, version, esOrgId, ...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 (version || esOrgId) { + this.initContext.logger.debug( + `Calling FS.setUserVars with version ${version} and esOrgId ${esOrgId}` + ); + this.fullStoryApi.setUserVars({ + ...(version ? getParsedVersion(version) : {}), + ...(esOrgId ? { org_id_str: esOrgId } : {}), + }); + } + + // Event-level context. At the moment, only the scope `page` is supported by FullStory for webapps. + if (Object.keys(nonUserContext).length) { + this.initContext.logger.debug( + `Calling FS.setVars with context ${JSON.stringify(nonUserContext)}` + ); + this.fullStoryApi.setVars('page', formatPayload(nonUserContext)); + } + } + + public optIn(isOptedIn: boolean): void { + this.initContext.logger.debug(`Setting FS to optIn ${isOptedIn}`); + // FullStory uses 2 different opt-in methods: + // - `consent` is needed to allow collecting information about the components + // declared as "Record with user consent" (https://help.fullstory.com/hc/en-us/articles/360020623574). + // We need to explicitly call `consent` if for the "Record with user content" feature to work. + this.fullStoryApi.consent(isOptedIn); + // - `restart` and `shutdown` fully start/stop the collection of data. + if (isOptedIn) { + this.fullStoryApi.restart(); + } else { + this.fullStoryApi.shutdown(); + } + } + + public reportEvents(events: Event[]): void { + this.initContext.logger.debug(`Reporting ${events.length} events to FS`); + events.forEach((event) => { + // We only read event.properties and discard the rest because the context is already sent in the other APIs. + this.fullStoryApi.event(event.event_type, formatPayload(event.properties)); + }); + } +} diff --git a/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.test.ts b/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.test.ts new file mode 100644 index 000000000000000..b4938dbca3bc47b --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getParsedVersion } from './get_parsed_version'; + +describe('getParsedVersion', () => { + test('parses a version string', () => { + expect(getParsedVersion('1.2.3')).toEqual({ + version_str: '1.2.3', + version_major_int: 1, + version_minor_int: 2, + version_patch_int: 3, + }); + }); + + test('parses a version string with extra label', () => { + expect(getParsedVersion('1.2.3-SNAPSHOT')).toEqual({ + version_str: '1.2.3-SNAPSHOT', + version_major_int: 1, + version_minor_int: 2, + version_patch_int: 3, + }); + }); + + test('does not throw for invalid version', () => { + expect(getParsedVersion('INVALID_VERSION')).toEqual({ + version_str: 'INVALID_VERSION', + version_major_int: NaN, + version_minor_int: NaN, + version_patch_int: NaN, + }); + }); +}); diff --git a/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.ts b/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.ts new file mode 100644 index 000000000000000..873b47a0cde8ab9 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/get_parsed_version.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export function getParsedVersion(version: string): { + version_str: string; + version_major_int: number; + version_minor_int: number; + version_patch_int: number; +} { + const [major, minor, patch] = version.split('.'); + return { + version_str: version, + version_major_int: parseInt(major, 10), + version_minor_int: parseInt(minor, 10), + version_patch_int: parseInt(patch, 10), + }; +} diff --git a/packages/elastic-analytics/src/shippers/fullstory/index.ts b/packages/elastic-analytics/src/shippers/fullstory/index.ts new file mode 100644 index 000000000000000..a9be91c82e9ec43 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { FullStoryShipper } from './fullstory_shipper'; +export type { FullStoryShipperConfig } from './fullstory_shipper'; +export type { FullStorySnippetConfig } from './load_snippet'; diff --git a/packages/elastic-analytics/src/shippers/fullstory/load_snippet.test.ts b/packages/elastic-analytics/src/shippers/fullstory/load_snippet.test.ts new file mode 100644 index 000000000000000..0b920ef2d22c123 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/load_snippet.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { loadSnippet } from './load_snippet'; + +describe('loadSnippet', () => { + beforeAll(() => { + // Define necessary window and document global variables for the tests + Object.defineProperty(global, 'window', { + writable: true, + value: {}, + }); + + Object.defineProperty(global, 'document', { + writable: true, + value: { + createElement: jest.fn().mockReturnValue({}), + getElementsByTagName: jest + .fn() + .mockReturnValue([{ parentNode: { insertBefore: jest.fn() } }]), + }, + }); + + Object.defineProperty(global, '_fs_script', { + writable: true, + value: '', + }); + }); + + it('should return the FullStory API', () => { + const fullStoryApi = loadSnippet({ debug: true, fullStoryOrgId: 'foo' }); + expect(fullStoryApi).toBeDefined(); + expect(fullStoryApi.event).toBeDefined(); + expect(fullStoryApi.consent).toBeDefined(); + expect(fullStoryApi.restart).toBeDefined(); + expect(fullStoryApi.shutdown).toBeDefined(); + expect(fullStoryApi.identify).toBeDefined(); + expect(fullStoryApi.setUserVars).toBeDefined(); + expect(fullStoryApi.setVars).toBeDefined(); + }); +}); diff --git a/packages/elastic-analytics/src/shippers/fullstory/load_snippet.ts b/packages/elastic-analytics/src/shippers/fullstory/load_snippet.ts new file mode 100644 index 000000000000000..c110cd00a4ba0d0 --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/load_snippet.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FullStoryApi } from './types'; + +export interface FullStorySnippetConfig { + /** + * The FullStory account id. + */ + fullStoryOrgId: string; + /** + * The host to send the data to. Used to overcome AdBlockers by using custom DNSs. + * If not specified, it defaults to `fullstory.com`. + */ + host?: string; + /** + * The URL to load the FullStory client from. Falls back to `edge.fullstory.com/s/fs.js` if not specified. + */ + scriptUrl?: string; + /** + * Whether the debug logs should be printed to the console. + */ + debug?: boolean; + /** + * The name of the variable where the API is stored: `window[namespace]`. Defaults to `FS`. + */ + namespace?: string; +} + +export function loadSnippet({ + scriptUrl = 'edge.fullstory.com/s/fs.js', + fullStoryOrgId, + host = 'fullstory.com', + namespace = 'FS', + debug = false, +}: FullStorySnippetConfig): FullStoryApi { + window._fs_debug = debug; + window._fs_host = host; + window._fs_script = scriptUrl; + window._fs_org = fullStoryOrgId; + window._fs_namespace = namespace; + + /* eslint-disable */ + (function(m,n,e,t,l,o,g,y){ + if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].');} return;} + // @ts-expect-error + g=m[e]=function(a,b,s){g.q?g.q.push([a,b,s]):g._api(a,b,s);};g.q=[]; + // @ts-expect-error + o=n.createElement(t);o.async=1;o.crossOrigin='anonymous';o.src=_fs_script; + // @ts-expect-error + y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y); + // @ts-expect-error + g.identify=function(i,v,s){g(l,{uid:i},s);if(v)g(l,v,s)};g.setUserVars=function(v,s){g(l,v,s)};g.event=function(i,v,s){g('event',{n:i,p:v},s)}; + // @ts-expect-error + g.anonymize=function(){g.identify(!!0)}; + // @ts-expect-error + g.shutdown=function(){g("rec",!1)};g.restart=function(){g("rec",!0)}; + // @ts-expect-error + g.log = function(a,b){g("log",[a,b])}; + // @ts-expect-error + g.consent=function(a){g("consent",!arguments.length||a)}; + // @ts-expect-error + g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)}; + // @ts-expect-error + g.clearUserCookie=function(){}; + // @ts-expect-error + g.setVars=function(n, p){g('setVars',[n,p]);}; + // @ts-expect-error + g._w={};y='XMLHttpRequest';g._w[y]=m[y];y='fetch';g._w[y]=m[y]; + // @ts-expect-error + if(m[y])m[y]=function(){return g._w[y].apply(this,arguments)}; + // @ts-expect-error + g._v="1.3.0"; + + return g; + })(window,document,window['_fs_namespace'],'script','user'); + + const fullStoryApi = window[namespace as 'FS']; + + if (!fullStoryApi) { + throw new Error('FullStory snippet failed to load. Check browser logs for more information.'); + } + + return fullStoryApi; +} diff --git a/packages/elastic-analytics/src/shippers/fullstory/types.ts b/packages/elastic-analytics/src/shippers/fullstory/types.ts new file mode 100644 index 000000000000000..6c448c2c4d2e1ea --- /dev/null +++ b/packages/elastic-analytics/src/shippers/fullstory/types.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Definition of the FullStory API. + * Docs are available at https://developer.fullstory.com/. + */ +export interface FullStoryApi { + /** + * Identify a User + * https://developer.fullstory.com/identify + * @param userId + * @param userVars + */ + identify(userId: string, userVars?: Record): void; + + /** + * Set User Variables + * https://developer.fullstory.com/user-variables + * @param userVars + */ + setUserVars(userVars: Record): void; + + /** + * Setting page variables + * https://developer.fullstory.com/page-variables + * @param scope + * @param pageProperties + */ + setVars(scope: 'page', pageProperties: Record): void; + + /** + * Sending custom event data into FullStory + * https://developer.fullstory.com/custom-events + * @param eventName + * @param eventProperties + */ + event(eventName: string, eventProperties: Record): void; + + /** + * Selectively record parts of your site based on explicit user consent + * https://developer.fullstory.com/consent + * @param isOptedIn true if the user has opted in to tracking + */ + consent(isOptedIn: boolean): void; + + /** + * Restart session recording after it has been shutdown + * https://developer.fullstory.com/restart-recording + */ + restart(): void; + + /** + * Stop recording a session + * https://developer.fullstory.com/stop-recording + */ + shutdown(): void; +} + +declare global { + interface Window { + _fs_debug: boolean; + _fs_host: string; + _fs_org: string; + _fs_namespace: string; + _fs_script: string; + FS: FullStoryApi; + } +} diff --git a/packages/elastic-analytics/src/shippers/index.ts b/packages/elastic-analytics/src/shippers/index.ts index 7a4ab7d85b9f202..c75b38b63a499ee 100644 --- a/packages/elastic-analytics/src/shippers/index.ts +++ b/packages/elastic-analytics/src/shippers/index.ts @@ -7,3 +7,6 @@ */ export type { IShipper } from './types'; + +export { FullStoryShipper } from './fullstory'; +export type { FullStorySnippetConfig, FullStoryShipperConfig } from './fullstory'; diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index a758715e62ba80b..af7b5788be65d3d 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -132,7 +132,7 @@ export class TelemetryPlugin implements Plugin { await this.refreshConfig(); + analytics.optIn({ global: { enabled: this.telemetryService!.isOptedIn } }); }); if (home) { @@ -177,6 +178,7 @@ export class TelemetryPlugin implements Plugin { - const { takeNumberOfCounters } = req.query; + const { takeNumberOfCounters, eventType } = req.query; - return res.ok({ body: stats.slice(-takeNumberOfCounters) }); + return res.ok({ + body: stats + .filter((counter) => counter.event_type === eventType) + .slice(-takeNumberOfCounters), + }); } ); diff --git a/test/analytics/tests/analytics_from_the_browser.ts b/test/analytics/tests/analytics_from_the_browser.ts index 9c866204ce18edd..88da2ddcb5bc7cf 100644 --- a/test/analytics/tests/analytics_from_the_browser.ts +++ b/test/analytics/tests/analytics_from_the_browser.ts @@ -23,7 +23,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ): Promise => { return await browser.execute( ({ takeNumberOfCounters }) => - window.__analyticsPluginA__.stats.slice(-takeNumberOfCounters), + window.__analyticsPluginA__.stats + .filter((counter) => counter.event_type === 'test-plugin-lifecycle') + .slice(-takeNumberOfCounters), { takeNumberOfCounters: _takeNumberOfCounters } ); }; @@ -70,6 +72,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(context).to.have.property('user_agent'); expect(context.user_agent).to.be.a('string'); + const reportEventContext = actions[2].meta[1].context; + expect(reportEventContext).to.have.property('user_agent'); + expect(reportEventContext.user_agent).to.be.a('string'); + expect(actions).to.eql([ { action: 'optIn', meta: true }, { action: 'extendContext', meta: context }, @@ -85,7 +91,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ], @@ -103,7 +109,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ]); diff --git a/test/analytics/tests/analytics_from_the_server.ts b/test/analytics/tests/analytics_from_the_server.ts index 8555d91031d27e1..0935e52136e6041 100644 --- a/test/analytics/tests/analytics_from_the_server.ts +++ b/test/analytics/tests/analytics_from_the_server.ts @@ -20,7 +20,7 @@ export default function ({ getService }: FtrProviderContext) { ): Promise => { const resp = await supertest .get(`/internal/analytics_plugin_a/stats`) - .query({ takeNumberOfCounters }) + .query({ takeNumberOfCounters, eventType: 'test-plugin-lifecycle' }) .set('kbn-xsrf', 'xxx') .expect(200); diff --git a/x-pack/plugins/cloud/public/fullstory.ts b/x-pack/plugins/cloud/public/fullstory.ts deleted file mode 100644 index 602b1c4cc63d378..000000000000000 --- a/x-pack/plugins/cloud/public/fullstory.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { sha256 } from 'js-sha256'; // loaded here to reduce page load bundle size when FullStory is disabled -import type { IBasePath, PackageInfo } from '../../../../src/core/public'; - -export interface FullStoryDeps { - basePath: IBasePath; - orgId: string; - packageInfo: PackageInfo; -} - -export type FullstoryUserVars = Record; -export type FullstoryVars = Record; - -export interface FullStoryApi { - identify(userId: string, userVars?: FullstoryUserVars): void; - setVars(pageName: string, vars?: FullstoryVars): void; - setUserVars(userVars?: FullstoryUserVars): void; - event(eventName: string, eventProperties: Record): void; -} - -export interface FullStoryService { - fullStory: FullStoryApi; - sha256: typeof sha256; -} - -export const initializeFullStory = ({ - basePath, - orgId, - packageInfo, -}: FullStoryDeps): FullStoryService => { - // @ts-expect-error - window._fs_debug = false; - // @ts-expect-error - window._fs_host = 'fullstory.com'; - // @ts-expect-error - window._fs_script = basePath.prepend(`/internal/cloud/${packageInfo.buildNum}/fullstory.js`); - // @ts-expect-error - window._fs_org = orgId; - // @ts-expect-error - window._fs_namespace = 'FSKibana'; - - /* eslint-disable */ - (function(m,n,e,t,l,o,g,y){ - if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].');} return;} - // @ts-expect-error - g=m[e]=function(a,b,s){g.q?g.q.push([a,b,s]):g._api(a,b,s);};g.q=[]; - // @ts-expect-error - o=n.createElement(t);o.async=1;o.crossOrigin='anonymous';o.src=_fs_script; - // @ts-expect-error - y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y); - // @ts-expect-error - g.identify=function(i,v,s){g(l,{uid:i},s);if(v)g(l,v,s)};g.setUserVars=function(v,s){g(l,v,s)};g.event=function(i,v,s){g('event',{n:i,p:v},s)}; - // @ts-expect-error - g.anonymize=function(){g.identify(!!0)}; - // @ts-expect-error - g.shutdown=function(){g("rec",!1)};g.restart=function(){g("rec",!0)}; - // @ts-expect-error - g.log = function(a,b){g("log",[a,b])}; - // @ts-expect-error - g.consent=function(a){g("consent",!arguments.length||a)}; - // @ts-expect-error - g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)}; - // @ts-expect-error - g.clearUserCookie=function(){}; - // @ts-expect-error - g.setVars=function(n, p){g('setVars',[n,p]);}; - // @ts-expect-error - g._w={};y='XMLHttpRequest';g._w[y]=m[y];y='fetch';g._w[y]=m[y]; - // @ts-expect-error - if(m[y])m[y]=function(){return g._w[y].apply(this,arguments)}; - // @ts-expect-error - g._v="1.3.0"; - // @ts-expect-error - })(window,document,window['_fs_namespace'],'script','user'); - /* eslint-enable */ - - // @ts-expect-error - const fullStory: FullStoryApi = window.FSKibana; - - return { - fullStory, - sha256, - }; -}; diff --git a/x-pack/plugins/cloud/public/plugin.test.mocks.ts b/x-pack/plugins/cloud/public/plugin.test.mocks.ts deleted file mode 100644 index 1c185d0194912a5..000000000000000 --- a/x-pack/plugins/cloud/public/plugin.test.mocks.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { sha256 } from 'js-sha256'; -import type { FullStoryDeps, FullStoryApi, FullStoryService } from './fullstory'; - -export const fullStoryApiMock: jest.Mocked = { - event: jest.fn(), - setUserVars: jest.fn(), - setVars: jest.fn(), - identify: jest.fn(), -}; -export const initializeFullStoryMock = jest.fn(() => ({ - fullStory: fullStoryApiMock, - sha256, -})); -jest.doMock('./fullstory', () => { - return { initializeFullStory: initializeFullStoryMock }; -}); diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index edbf724e25390eb..ab2b6283ec19ee0 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -9,14 +9,14 @@ import { nextTick } from '@kbn/test-jest-helpers'; import { coreMock } from 'src/core/public/mocks'; 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 { CloudPlugin, CloudConfigType, loadUserId } from './plugin'; import { Observable, Subject } from 'rxjs'; import { KibanaExecutionContext } from 'kibana/public'; +import { first } from 'rxjs/operators'; describe('Cloud Plugin', () => { describe('#setup', () => { - describe('setupFullstory', () => { + describe('setupFullStory', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -65,72 +65,93 @@ describe('Cloud Plugin', () => { ); const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {}); - // Wait for fullstory dynamic import to resolve + // Wait for FullStory dynamic import to resolve await new Promise((r) => setImmediate(r)); - return { initContext, plugin, setup }; + return { initContext, plugin, setup, coreSetup }; }; - it('calls initializeFullStory with correct args when enabled and org_id are set', async () => { - const { initContext } = await setupPlugin({ + test('register the shipper FullStory with correct args when enabled and org_id are set', async () => { + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, currentUserProps: { username: '1234', }, }); - expect(initializeFullStoryMock).toHaveBeenCalled(); - const { basePath, orgId, packageInfo } = initializeFullStoryMock.mock.calls[0][0]; - expect(basePath.prepend).toBeDefined(); - expect(orgId).toEqual('foo'); - expect(packageInfo).toEqual(initContext.env.packageInfo); + expect(coreSetup.analytics.registerShipper).toHaveBeenCalled(); + expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), { + fullStoryOrgId: 'foo', + scriptUrl: '/internal/cloud/100/fullstory.js', + namespace: 'FSKibana', + }); }); - it('calls FS.identify with hashed user ID when security is available', async () => { - await setupPlugin({ + test('register the context provider for the cloud user with hashed user ID when security is available', async () => { + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, currentUserProps: { username: '1234', }, }); - expect(fullStoryApiMock.identify).toHaveBeenCalledWith( - '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041', - { - version_str: 'version', - version_major_int: -1, - version_minor_int: -1, - version_patch_int: -1, - org_id_str: 'cloudId', - } - ); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); + + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; + + await expect(context$.pipe(first()).toPromise()).resolves.toEqual({ + userId: '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041', + }); + + // expect(coreSetup.analytics).toHaveBeenCalledWith( + // '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041', + // { + // version_str: 'version', + // version_major_int: -1, + // version_minor_int: -1, + // version_patch_int: -1, + // org_id_str: 'cloudId', + // } + // ); }); it('user hash includes org id', async () => { - await setupPlugin({ + const { coreSetup: coreSetup1 } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg1' }, currentUserProps: { username: '1234', }, }); - const hashId1 = fullStoryApiMock.identify.mock.calls[0][0]; + const [{ context$: context1$ }] = + coreSetup1.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; - await setupPlugin({ + const hashId1 = await context1$.pipe(first()).toPromise(); + + const { coreSetup: coreSetup2 } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg2' }, currentUserProps: { username: '1234', }, }); - const hashId2 = fullStoryApiMock.identify.mock.calls[1][0]; + const [{ context$: context2$ }] = + coreSetup2.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; + + const hashId2 = await context2$.pipe(first()).toPromise(); expect(hashId1).not.toEqual(hashId2); }); - it('calls FS.setVars everytime an app changes', async () => { + it('emits the execution context provider everytime an app changes', async () => { const currentContext$ = new Subject(); - const { plugin } = await setupPlugin({ + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, currentUserProps: { username: '1234', @@ -138,23 +159,34 @@ describe('Cloud Plugin', () => { currentContext$, }); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'execution_context' + )!; + + let latestContext; + context$.subscribe((context) => { + latestContext = context; + }); + // takes the app name - expect(fullStoryApiMock.setVars).not.toHaveBeenCalled(); + expect(latestContext).toBeUndefined(); currentContext$.next({ name: 'App1', description: '123', }); - expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + await new Promise((r) => setImmediate(r)); + + expect(latestContext).toEqual({ pageName: 'App1', - app_id_str: 'App1', + app_id: 'App1', }); // context clear currentContext$.next({}); - expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { - pageName: 'App1', - app_id_str: 'App1', + expect(latestContext).toEqual({ + pageName: '', + app_id: 'unknown', }); // different app @@ -163,11 +195,11 @@ describe('Cloud Plugin', () => { page: 'page2', id: '123', }); - expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + expect(latestContext).toEqual({ pageName: 'App2:page2', - app_id_str: 'App2', - page_str: 'page2', - ent_id_str: '123', + app_id: 'App2', + page: 'page2', + ent_id: '123', }); // Back to first app @@ -177,25 +209,25 @@ describe('Cloud Plugin', () => { id: '123', }); - expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', { + expect(latestContext).toEqual({ pageName: 'App1:page3', - app_id_str: 'App1', - page_str: 'page3', - ent_id_str: '123', + app_id: 'App1', + page: 'page3', + ent_id: '123', }); - - expect(currentContext$.observers.length).toBe(1); - plugin.stop(); - expect(currentContext$.observers.length).toBe(0); }); - it('does not call FS.identify when security is not available', async () => { - await setupPlugin({ + it('does not register the cloud user id context provider when security is not available', async () => { + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, securityEnabled: false, }); - expect(fullStoryApiMock.identify).not.toHaveBeenCalled(); + expect( + coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + ) + ).toBeUndefined(); }); describe('with memory', () => { @@ -219,58 +251,44 @@ describe('Cloud Plugin', () => { delete window.performance.memory; }); - it('calls FS.event when security is available', async () => { - const { initContext } = await setupPlugin({ + it('reports an event when security is available', async () => { + const { initContext, coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, currentUserProps: { username: '1234', }, }); - expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version_str: initContext.env.packageInfo.version, - memory_js_heap_size_limit_int: 3, - memory_js_heap_size_total_int: 2, - memory_js_heap_size_used_int: 1, + expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: initContext.env.packageInfo.version, + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, }); }); }); - it('calls FS.event when security is not available', async () => { - const { initContext } = await setupPlugin({ + it('reports an event when security is not available', async () => { + const { initContext, coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, securityEnabled: false, }); - expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version_str: initContext.env.packageInfo.version, - }); - }); - - it('calls FS.event when FS.identify throws an error', async () => { - fullStoryApiMock.identify.mockImplementationOnce(() => { - throw new Error(`identify failed!`); - }); - const { initContext } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', - }, - }); - - expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version_str: initContext.env.packageInfo.version, + expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: initContext.env.packageInfo.version, }); }); it('does not call initializeFullStory when enabled=false', async () => { - await setupPlugin({ config: { full_story: { enabled: false, org_id: 'foo' } } }); - expect(initializeFullStoryMock).not.toHaveBeenCalled(); + const { coreSetup } = await setupPlugin({ + config: { full_story: { enabled: false, org_id: 'foo' } }, + }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); }); it('does not call initializeFullStory when org_id is undefined', async () => { - await setupPlugin({ config: { full_story: { enabled: true } } }); - expect(initializeFullStoryMock).not.toHaveBeenCalled(); + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); }); }); @@ -659,7 +677,7 @@ describe('Cloud Plugin', () => { it('returns principal ID when username specified', async () => { expect( - await loadFullStoryUserId({ + await loadUserId({ getCurrentUser: jest.fn().mockResolvedValue({ username: '1234', }), @@ -670,7 +688,7 @@ describe('Cloud Plugin', () => { it('returns undefined if getCurrentUser throws', async () => { expect( - await loadFullStoryUserId({ + await loadUserId({ getCurrentUser: jest.fn().mockRejectedValue(new Error(`Oh no!`)), }) ).toBeUndefined(); @@ -678,7 +696,7 @@ describe('Cloud Plugin', () => { it('returns undefined if getCurrentUser returns undefined', async () => { expect( - await loadFullStoryUserId({ + await loadUserId({ getCurrentUser: jest.fn().mockResolvedValue(undefined), }) ).toBeUndefined(); @@ -686,7 +704,7 @@ describe('Cloud Plugin', () => { it('returns undefined and logs if username undefined', async () => { expect( - await loadFullStoryUserId({ + await loadUserId({ getCurrentUser: jest.fn().mockResolvedValue({ username: undefined, metadata: { foo: 'bar' }, @@ -694,7 +712,7 @@ describe('Cloud Plugin', () => { }) ).toBeUndefined(); expect(consoleMock).toHaveBeenLastCalledWith( - `[cloud.full_story] username not specified. User metadata: {"foo":"bar"}` + `[cloud.analytics] username not specified. User metadata: {"foo":"bar"}` ); }); }); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 89f24971de25c1f..98b0603ff339db5 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -6,7 +6,7 @@ */ import React, { FC } from 'react'; -import { +import type { CoreSetup, CoreStart, Plugin, @@ -14,11 +14,15 @@ import { HttpStart, IBasePath, ExecutionContextStart, + AnalyticsServiceSetup, } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { compact, isUndefined, omitBy } from 'lodash'; +import { BehaviorSubject, from, of, Subscription } from 'rxjs'; +import { filter, map, mergeMap } from 'rxjs/operators'; +import { compact } from 'lodash'; +import { sha256 } from 'js-sha256'; + import type { AuthenticatedUser, SecurityPluginSetup, @@ -83,9 +87,13 @@ export interface CloudSetup { isCloudEnabled: boolean; } -interface SetupFullstoryDeps extends CloudSetupDependencies { - executionContextPromise?: Promise; +interface SetupFullStoryDeps { + analytics: AnalyticsServiceSetup; basePath: IBasePath; +} +interface SetupTelemetryContextDeps extends CloudSetupDependencies { + analytics: AnalyticsServiceSetup; + executionContextPromise: Promise; esOrgId?: string; } @@ -94,7 +102,7 @@ interface SetupChatDeps extends Pick { } export class CloudPlugin implements Plugin { - private config!: CloudConfigType; + private readonly config: CloudConfigType; private isCloudEnabled: boolean; private appSubscription?: Subscription; private chatConfig$ = new BehaviorSubject({ enabled: false }); @@ -109,12 +117,17 @@ export class CloudPlugin implements Plugin { return coreStart.executionContext; }); - this.setupFullstory({ - basePath: core.http.basePath, + this.setupTelemetryContext({ + analytics: core.analytics, security, executionContextPromise, esOrgId: this.config.id, - }).catch((e) => + }).catch((e) => { + // eslint-disable-next-line no-console + console.debug(`Error setting up TelemetryContext: ${e.toString()}`); + }); + + this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) => // eslint-disable-next-line no-console console.debug(`Error setting up FullStory: ${e.toString()}`) ); @@ -230,109 +243,157 @@ export class CloudPlugin implements Plugin { return user?.roles.includes('superuser') ?? true; } - private async setupFullstory({ - basePath, + /** + * If the right config is provided, register the FullStory shipper to the analytics client. + * @param analytics Core's Analytics service's setup contract. + * @param basePath Core's http.basePath helper. + * @private + */ + private async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) { + const { enabled, org_id: fullStoryOrgId } = this.config.full_story; + if (!enabled || !fullStoryOrgId) { + return; // do not load any FullStory code in the browser if not enabled + } + + // Keep this import async so that we do not load any FullStory code into the browser when it is disabled. + const { FullStoryShipper } = await import('@elastic/analytics'); + analytics.registerShipper(FullStoryShipper, { + fullStoryOrgId, + // 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` + ), + namespace: 'FSKibana', + }); + } + + /** + * Set up the Analytics context providers. + * @param analytics Core's Analytics service. The Setup contract. + * @param security The security plugin. + * @param executionContextPromise Core's executionContext's start contract. + * @param esOrgId The Cloud Org ID. + * @private + */ + private async setupTelemetryContext({ + analytics, security, executionContextPromise, esOrgId, - }: SetupFullstoryDeps) { - const { enabled, org_id: fsOrgId } = this.config.full_story; - if (!enabled || !fsOrgId) { - return; // do not load any fullstory code in the browser if not enabled - } + }: SetupTelemetryContextDeps) { + // Some context providers can be moved to other places for better domain isolation. + // Let's use https://github.com/elastic/kibana/issues/125690 for that purpose. + analytics.registerContextProvider({ + name: 'kibana_version', + context$: of({ version: this.initializerContext.env.packageInfo.version }), + schema: { version: { type: 'keyword', _meta: { description: 'The version of Kibana' } } }, + }); - // Keep this import async so that we do not load any FullStory code into the browser when it is disabled. - const fullStoryChunkPromise = import('./fullstory'); - const userIdPromise: Promise = security - ? loadFullStoryUserId({ getCurrentUser: security.authc.getCurrentUser }) - : Promise.resolve(undefined); - - // We need to call FS.identify synchronously after FullStory is initialized, so we must load the user upfront - const [{ initializeFullStory }, userId] = await Promise.all([ - fullStoryChunkPromise, - userIdPromise, - ]); - - const { fullStory, sha256 } = initializeFullStory({ - basePath, - orgId: fsOrgId, - packageInfo: this.initializerContext.env.packageInfo, + analytics.registerContextProvider({ + name: 'cloud_org_id', + context$: of({ esOrgId }), + schema: { + esOrgId: { + type: 'keyword', + _meta: { description: 'The Cloud Organization ID', optional: true }, + }, + }, }); - // Very defensive try/catch to avoid any UnhandledPromiseRejections - try { - // This needs to be called syncronously to be sure that we populate the user ID soon enough to make sessions merging - // across domains work - if (userId) { + // This needs to be called synchronously to be sure that we populate the user ID soon enough to make sessions merging + // across domains work + if (security) { + analytics.registerContextProvider({ + name: 'cloud_user_id', // Join the cloud org id and the user to create a truly unique user id. // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs - const hashedId = sha256(esOrgId ? `${esOrgId}:${userId}` : `${userId}`); - - executionContextPromise - ?.then(async (executionContext) => { - this.appSubscription = executionContext.context$.subscribe((context) => { - const { name, page, id } = context; - // Update the current context every time it changes - fullStory.setVars( - 'page', - omitBy( - { - // Read about the special pageName property - // https://help.fullstory.com/hc/en-us/articles/1500004101581-FS-setVars-API-Sending-custom-page-data-to-FullStory - pageName: `${compact([name, page]).join(':')}`, - app_id_str: name ?? 'unknown', - page_str: page, - ent_id_str: id, - }, - isUndefined - ) - ); - }); - }) - .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, - org_id_str: esOrgId, - }); - } - } catch (e) { - // eslint-disable-next-line no-console - console.error( - `[cloud.full_story] Could not call FS.identify due to error: ${e.toString()}`, - e - ); + context$: from(loadUserId({ getCurrentUser: security.authc.getCurrentUser })).pipe( + filter((userId): userId is string => Boolean(userId)), + map((userId) => ({ + userId: sha256(esOrgId ? `${esOrgId}:${userId}` : `${userId}`), + })) + ), + schema: { + userId: { + type: 'keyword', + _meta: { description: 'The user id scoped as seen by Cloud (hashed)' }, + }, + }, + }); } + const executionContext = await executionContextPromise; + analytics.registerContextProvider({ + name: 'execution_context', + context$: executionContext.context$.pipe( + // Update the current context every time it changes + map(({ name, page, id }) => ({ + pageName: `${compact([name, page]).join(':')}`, + app_id: name ?? 'unknown', + page, + ent_id: id, + })) + ), + schema: { + pageName: { + type: 'keyword', + _meta: { description: 'The name of the current page' }, + }, + page: { + type: 'keyword', + _meta: { description: 'The current page', optional: true }, + }, + app_id: { + type: 'keyword', + _meta: { description: 'The id of the current application' }, + }, + ent_id: { + type: 'keyword', + _meta: { + description: + 'The id of the current entity (dashboard, visualization, canvas, lens, etc)', + optional: true, + }, + }, + }, + }); + + analytics.registerEventType({ + eventType: 'Loaded Kibana', + schema: { + kibana_version: { + type: 'keyword', + _meta: { description: 'The version of Kibana', optional: true }, + }, + memory_js_heap_size_limit: { + type: 'long', + _meta: { description: 'The maximum size of the heap', optional: true }, + }, + memory_js_heap_size_total: { + type: 'long', + _meta: { description: 'The total size of the heap', optional: true }, + }, + memory_js_heap_size_used: { + type: 'long', + _meta: { description: 'The used size of the heap', optional: true }, + }, + }, + }); + // Get performance information from the browser (non standard property // @ts-expect-error 2339 const memory = window.performance.memory; let memoryInfo = {}; if (memory) { memoryInfo = { - memory_js_heap_size_limit_int: memory.jsHeapSizeLimit, - memory_js_heap_size_total_int: memory.totalJSHeapSize, - memory_js_heap_size_used_int: memory.usedJSHeapSize, + memory_js_heap_size_limit: memory.jsHeapSizeLimit, + memory_js_heap_size_total: memory.totalJSHeapSize, + memory_js_heap_size_used: memory.usedJSHeapSize, }; } - // Record an event that Kibana was opened so we can easily search for sessions that use Kibana - fullStory.event('Loaded Kibana', { - // `str` suffix is required, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 - kibana_version_str: this.initializerContext.env.packageInfo.version, + + analytics.reportEvent('Loaded Kibana', { + kibana_version: this.initializerContext.env.packageInfo.version, ...memoryInfo, }); } @@ -376,7 +437,7 @@ export class CloudPlugin implements Plugin { } /** @internal exported for testing */ -export const loadFullStoryUserId = async ({ +export const loadUserId = async ({ getCurrentUser, }: { getCurrentUser: () => Promise; @@ -391,7 +452,7 @@ export const loadFullStoryUserId = async ({ if (!currentUser.username) { // eslint-disable-next-line no-console console.debug( - `[cloud.full_story] username not specified. User metadata: ${JSON.stringify( + `[cloud.analytics] username not specified. User metadata: ${JSON.stringify( currentUser.metadata )}` ); @@ -400,7 +461,7 @@ export const loadFullStoryUserId = async ({ return currentUser.username; } catch (e) { // eslint-disable-next-line no-console - console.error(`[cloud.full_story] Error loading the current user: ${e.toString()}`, e); + console.error(`[cloud.analytics] Error loading the current user: ${e.toString()}`, e); return undefined; } };