[EBT] Add Shipper "FullStory" (#129927)

This commit is contained in:
Alejandro Fernández Haro 2022-04-13 17:34:07 +02:00 committed by GitHub
parent fb81c73699
commit bed793a6a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1128 additions and 308 deletions

View file

@ -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",

View file

@ -8,7 +8,7 @@
// eslint-disable-next-line max-classes-per-file
import type { Observable } from 'rxjs';
import { BehaviorSubject, lastValueFrom, Subject } from 'rxjs';
import { BehaviorSubject, firstValueFrom, lastValueFrom, Subject } from 'rxjs';
import type { MockedLogger } from '@kbn/logging-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { AnalyticsClient } from './analytics_client';
@ -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 = firstValueFrom(globalContext$.pipe(take(6), toArray()));
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',

View file

@ -69,9 +69,21 @@ export class ContextService {
[...this.contextProvidersRegistry.values()].reduce((acc, context) => {
return {
...acc,
...context,
...this.removeEmptyValues(context),
};
}, {} as Partial<EventContext>)
);
}
private removeEmptyValues(context?: Partial<EventContext>) {
if (!context) {
return {};
}
return Object.keys(context).reduce((acc, key) => {
if (context[key] !== undefined) {
acc[key] = context[key];
}
return acc;
}, {} as Partial<EventContext>);
}
}

View file

@ -8,7 +8,34 @@
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.
*/
pageName?: string;
/**
* The current application ID.
*/
applicationId?: string;
/**
* The current entity ID (dashboard ID, visualization ID, etc.).
*/
entityId?: string;
// TODO: Extend with known keys
[key: string]: unknown;
}

View file

@ -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';

View file

@ -0,0 +1,146 @@
/*
* 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 { 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('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({});
});
test('throws if null is provided', () => {
const payload = {
foo: null,
baz: [null],
};
expect(() => formatPayload(payload)).toThrowErrorMatchingInlineSnapshot(
`"Unsupported type: object"`
);
});
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,
});
});
});
});

View file

@ -0,0 +1,84 @@
/*
* 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<string, unknown>): Record<string, unknown> {
// 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: string;
switch (typeof v) {
case 'string':
type = moment(v, moment.ISO_8601, true).isValid() ? 'date' : '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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isDate(value);
}
function isDate(value: unknown): value is Date {
return value instanceof Date;
}

View file

@ -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<FullStoryApi> = {
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,
};
});

View file

@ -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.test.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',
});
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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) {
// Keeping these fields for backwards compatibility.
if (nonUserContext.applicationId) nonUserContext.app_id = nonUserContext.applicationId;
if (nonUserContext.entityId) nonUserContext.ent_id = nonUserContext.entityId;
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));
});
}
}

View file

@ -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,
});
});
});

View file

@ -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),
};
}

View file

@ -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';

View file

@ -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();
});
});

View file

@ -0,0 +1,89 @@
/*
* 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";
})(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;
}

View file

@ -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<string, unknown>): void;
/**
* Set User Variables
* https://developer.fullstory.com/user-variables
* @param userVars
*/
setUserVars(userVars: Record<string, unknown>): void;
/**
* Setting page variables
* https://developer.fullstory.com/page-variables
* @param scope
* @param pageProperties
*/
setVars(scope: 'page', pageProperties: Record<string, unknown>): void;
/**
* Sending custom event data into FullStory
* https://developer.fullstory.com/custom-events
* @param eventName
* @param eventProperties
*/
event(eventName: string, eventProperties: Record<string, unknown>): 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;
}
}

View file

@ -7,3 +7,6 @@
*/
export type { IShipper } from './types';
export { FullStoryShipper } from './fullstory';
export type { FullStorySnippetConfig, FullStoryShipperConfig } from './fullstory';

View file

@ -134,7 +134,7 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
}
public setup(
{ http, notifications, getStartServices }: CoreSetup,
{ analytics, http, notifications, getStartServices }: CoreSetup,
{ screenshotMode, home }: TelemetryPluginSetupDependencies
): TelemetryPluginSetup {
const config = this.config;
@ -155,6 +155,7 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
this.telemetrySender = new TelemetrySender(this.telemetryService, async () => {
await this.refreshConfig();
analytics.optIn({ global: { enabled: this.telemetryService!.isOptedIn } });
});
if (home && !this.config.hidePrivacyStatement) {
@ -179,6 +180,7 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
}
public start({
analytics,
http,
overlays,
application,
@ -211,6 +213,9 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
// Refresh and get telemetry config
const updatedConfig = await this.refreshConfig();
analytics.optIn({ global: { enabled: this.telemetryService!.isOptedIn } });
const telemetryBanner = updatedConfig?.banner;
this.maybeStartTelemetryPoller();

View file

@ -61,13 +61,18 @@ export class AnalyticsPluginAPlugin implements Plugin {
validate: {
query: schema.object({
takeNumberOfCounters: schema.number({ min: 1 }),
eventType: schema.string(),
}),
},
},
async (context, req, res) => {
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),
});
}
);

View file

@ -34,6 +34,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...functionalConfig.get('kbnTestServer'),
serverArgs: [
...functionalConfig.get('kbnTestServer.serverArgs'),
// Disabling telemetry so it doesn't call opt-in before the tests run.
'--telemetry.enabled=false',
`--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_plugin_a')}`,
`--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_ftr_helpers')}`,
],

View file

@ -23,7 +23,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
): Promise<TelemetryCounter[]> => {
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' },
},
]);

View file

@ -20,7 +20,7 @@ export default function ({ getService }: FtrProviderContext) {
): Promise<TelemetryCounter[]> => {
const resp = await supertest
.get(`/internal/analytics_plugin_a/stats`)
.query({ takeNumberOfCounters })
.query({ takeNumberOfCounters, eventType: 'test-plugin-lifecycle' })
.set('kbn-xsrf', 'xxx')
.expect(200);

View file

@ -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<string, any>;
export type FullstoryVars = Record<string, any>;
export interface FullStoryApi {
identify(userId: string, userVars?: FullstoryUserVars): void;
setVars(pageName: string, vars?: FullstoryVars): void;
setUserVars(userVars?: FullstoryUserVars): void;
event(eventName: string, eventProperties: Record<string, any>): 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,
};
};

View file

@ -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<FullStoryApi> = {
event: jest.fn(),
setUserVars: jest.fn(),
setVars: jest.fn(),
identify: jest.fn(),
};
export const initializeFullStoryMock = jest.fn<FullStoryService, [FullStoryDeps]>(() => ({
fullStory: fullStoryApiMock,
sha256,
}));
jest.doMock('./fullstory', () => {
return { initializeFullStory: initializeFullStoryMock };
});

View file

@ -9,14 +9,13 @@ 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 { Observable, Subject } from 'rxjs';
import { CloudPlugin, CloudConfigType, loadUserId } from './plugin';
import { firstValueFrom, Observable, Subject } from 'rxjs';
import { KibanaExecutionContext } from 'kibana/public';
describe('Cloud Plugin', () => {
describe('#setup', () => {
describe('setupFullstory', () => {
describe('setupFullStory', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@ -65,72 +64,82 @@ 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(firstValueFrom(context$)).resolves.toEqual({
userId: '5ef112cfdae3dea57097bc276e275b2816e73ef2a398dc0ffaf5b6b4e3af2041',
});
});
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 firstValueFrom(context1$);
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 firstValueFrom(context2$);
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<KibanaExecutionContext>();
const { plugin } = await setupPlugin({
const { coreSetup } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' } },
currentUserProps: {
username: '1234',
@ -138,23 +147,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',
applicationId: 'App1',
});
// context clear
currentContext$.next({});
expect(fullStoryApiMock.setVars).toHaveBeenCalledWith('page', {
pageName: 'App1',
app_id_str: 'App1',
expect(latestContext).toEqual({
pageName: '',
applicationId: 'unknown',
});
// different app
@ -163,11 +183,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',
applicationId: 'App2',
page: 'page2',
entityId: '123',
});
// Back to first app
@ -177,25 +197,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',
applicationId: 'App1',
page: 'page3',
entityId: '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 +239,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 +665,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 +676,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 +684,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 +692,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 +700,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"}`
);
});
});

View file

@ -6,7 +6,7 @@
*/
import React, { FC } from 'react';
import {
import type {
CoreSetup,
CoreStart,
Plugin,
@ -14,11 +14,14 @@ 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 { exhaustMap, filter, map } from 'rxjs/operators';
import { compact } from 'lodash';
import type {
AuthenticatedUser,
SecurityPluginSetup,
@ -83,9 +86,13 @@ export interface CloudSetup {
isCloudEnabled: boolean;
}
interface SetupFullstoryDeps extends CloudSetupDependencies {
executionContextPromise?: Promise<ExecutionContextStart>;
interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup;
basePath: IBasePath;
}
interface SetupTelemetryContextDeps extends CloudSetupDependencies {
analytics: AnalyticsServiceSetup;
executionContextPromise: Promise<ExecutionContextStart>;
esOrgId?: string;
}
@ -94,7 +101,7 @@ interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
}
export class CloudPlugin implements Plugin<CloudSetup> {
private config!: CloudConfigType;
private readonly config: CloudConfigType;
private isCloudEnabled: boolean;
private appSubscription?: Subscription;
private chatConfig$ = new BehaviorSubject<ChatConfig>({ enabled: false });
@ -109,12 +116,17 @@ export class CloudPlugin implements Plugin<CloudSetup> {
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 +242,158 @@ export class CloudPlugin implements Plugin<CloudSetup> {
return user?.roles.includes('superuser') ?? true;
}
private async setupFullstory({
basePath,
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
/**
* 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 fullStoryChunkPromise = import('./fullstory');
const userIdPromise: Promise<string | undefined> = security
? loadFullStoryUserId({ getCurrentUser: security.authc.getCurrentUser })
: Promise.resolve(undefined);
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',
});
}
// 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,
/**
* 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,
}: 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' } } },
});
// 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) {
// 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}`);
analytics.registerContextProvider({
name: 'cloud_org_id',
context$: of({ esOrgId }),
schema: {
esOrgId: {
type: 'keyword',
_meta: { description: 'The Cloud Organization ID', optional: true },
},
},
});
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
)
);
});
// 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',
context$: from(loadUserId({ getCurrentUser: security.authc.getCurrentUser })).pipe(
filter((userId): userId is string => Boolean(userId)),
exhaustMap(async (userId) => {
const { sha256 } = await import('js-sha256');
// 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
return { userId: sha256(esOrgId ? `${esOrgId}:${userId}` : `${userId}`) };
})
.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
);
),
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(':')}`,
applicationId: name ?? 'unknown',
page,
entityId: id,
}))
),
schema: {
pageName: {
type: 'keyword',
_meta: { description: 'The name of the current page' },
},
page: {
type: 'keyword',
_meta: { description: 'The current page', optional: true },
},
applicationId: {
type: 'keyword',
_meta: { description: 'The id of the current application' },
},
entityId: {
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<CloudSetup> {
}
/** @internal exported for testing */
export const loadFullStoryUserId = async ({
export const loadUserId = async ({
getCurrentUser,
}: {
getCurrentUser: () => Promise<AuthenticatedUser>;
@ -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;
}
};