mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[EBT] Add Shipper "FullStory" (#129927)
This commit is contained in:
parent
fb81c73699
commit
bed793a6a1
26 changed files with 1128 additions and 308 deletions
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
};
|
||||
}
|
11
packages/elastic-analytics/src/shippers/fullstory/index.ts
Normal file
11
packages/elastic-analytics/src/shippers/fullstory/index.ts
Normal 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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
74
packages/elastic-analytics/src/shippers/fullstory/types.ts
Normal file
74
packages/elastic-analytics/src/shippers/fullstory/types.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -7,3 +7,6 @@
|
|||
*/
|
||||
|
||||
export type { IShipper } from './types';
|
||||
|
||||
export { FullStoryShipper } from './fullstory';
|
||||
export type { FullStorySnippetConfig, FullStoryShipperConfig } from './fullstory';
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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')}`,
|
||||
],
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
});
|
|
@ -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"}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue