[EBT] Core Context Providers (#130785)

This commit is contained in:
Alejandro Fernández Haro 2022-05-06 10:42:05 +02:00 committed by GitHub
parent 2c091706c0
commit 8539a912b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 2033 additions and 448 deletions

View file

@ -421,6 +421,48 @@ describe('schema types', () => {
};
expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain
});
test('it should expect support readonly arrays', () => {
let valueType: SchemaValue<ReadonlyArray<{ a_value: string }>> = {
type: 'array',
items: {
properties: {
a_value: {
type: 'keyword',
_meta: {
description: 'Some description',
},
},
},
},
};
valueType = {
type: 'array',
items: {
properties: {
a_value: {
type: 'keyword',
_meta: {
description: 'Some description',
optional: false,
},
},
},
_meta: {
description: 'Description at the object level',
},
},
};
// @ts-expect-error because it's missing the items definition
valueType = { type: 'array' };
// @ts-expect-error because it's missing the items definition
valueType = { type: 'array', items: {} };
// @ts-expect-error because it's missing the items' properties definition
valueType = { type: 'array', items: { properties: {} } };
expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain
});
});
});

View file

@ -64,7 +64,7 @@ export type SchemaValue<Value> =
? // If the Value is unknown (TS can't infer the type), allow any type of schema
SchemaArray<unknown, Value> | SchemaObject<Value> | SchemaChildValue<Value>
: // Otherwise, try to infer the type and enforce the schema
NonNullable<Value> extends Array<infer U>
NonNullable<Value> extends Array<infer U> | ReadonlyArray<infer U>
? SchemaArray<U, Value>
: NonNullable<Value> extends object
? SchemaObject<Value>

View file

@ -0,0 +1,25 @@
/*
* 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 { AnalyticsClient } from '@kbn/analytics-client';
import { Subject } from 'rxjs';
export const analyticsClientMock: jest.Mocked<AnalyticsClient> = {
optIn: jest.fn(),
reportEvent: jest.fn(),
registerEventType: jest.fn(),
registerContextProvider: jest.fn(),
removeContextProvider: jest.fn(),
registerShipper: jest.fn(),
telemetryCounter$: new Subject(),
shutdown: jest.fn(),
};
jest.doMock('@kbn/analytics-client', () => ({
createAnalytics: () => analyticsClientMock,
}));

View file

@ -0,0 +1,93 @@
/*
* 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 { firstValueFrom, Observable } from 'rxjs';
import { analyticsClientMock } from './analytics_service.test.mocks';
import { coreMock, injectedMetadataServiceMock } from '../mocks';
import { AnalyticsService } from './analytics_service';
describe('AnalyticsService', () => {
let analyticsService: AnalyticsService;
beforeEach(() => {
jest.clearAllMocks();
analyticsService = new AnalyticsService(coreMock.createCoreContext());
});
test('should register some context providers on creation', async () => {
expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(3);
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$)
).resolves.toMatchInlineSnapshot(`
Object {
"branch": "branch",
"buildNum": 100,
"buildSha": "buildSha",
"isDev": true,
"isDistributable": false,
"version": "version",
}
`);
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[1][0].context$)
).resolves.toEqual({ session_id: expect.any(String) });
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[2][0].context$)
).resolves.toEqual({
preferred_language: 'en-US',
preferred_languages: ['en-US', 'en'],
user_agent: expect.any(String),
});
});
test('setup should expose all the register APIs, reportEvent and opt-in', () => {
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
expect(analyticsService.setup({ injectedMetadata })).toStrictEqual({
registerShipper: expect.any(Function),
registerContextProvider: expect.any(Function),
removeContextProvider: expect.any(Function),
registerEventType: expect.any(Function),
reportEvent: expect.any(Function),
optIn: expect.any(Function),
telemetryCounter$: expect.any(Observable),
});
});
test('setup should register the elasticsearch info context provider (undefined)', async () => {
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
analyticsService.setup({ injectedMetadata });
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$)
).resolves.toMatchInlineSnapshot(`undefined`);
});
test('setup should register the elasticsearch info context provider (with info)', async () => {
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
injectedMetadata.getElasticsearchInfo.mockReturnValue({
cluster_name: 'cluster_name',
cluster_uuid: 'cluster_uuid',
cluster_version: 'version',
});
analyticsService.setup({ injectedMetadata });
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$)
).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster_name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "version",
}
`);
});
test('setup should expose only the APIs report and opt-in', () => {
expect(analyticsService.start()).toStrictEqual({
reportEvent: expect.any(Function),
optIn: expect.any(Function),
telemetryCounter$: expect.any(Observable),
});
});
});

View file

@ -8,7 +8,10 @@
import type { AnalyticsClient } from '@kbn/analytics-client';
import { createAnalytics } from '@kbn/analytics-client';
import { of } from 'rxjs';
import { InjectedMetadataSetup } from '../injected_metadata';
import { CoreContext } from '../core_system';
import { getSessionId } from './get_session_id';
import { createLogger } from './logger';
/**
@ -27,6 +30,11 @@ export type AnalyticsServiceStart = Pick<
'optIn' | 'reportEvent' | 'telemetryCounter$'
>;
/** @internal */
export interface AnalyticsServiceSetupDeps {
injectedMetadata: InjectedMetadataSetup;
}
export class AnalyticsService {
private readonly analyticsClient: AnalyticsClient;
@ -38,9 +46,18 @@ export class AnalyticsService {
// For now, we are relying on whether it's a distributable or running from source.
sendTo: core.env.packageInfo.dist ? 'production' : 'staging',
});
this.registerBuildInfoAnalyticsContext(core);
// We may eventually move the following to the client's package since they are not Kibana-specific
// and can benefit other consumers of the client.
this.registerSessionIdContext();
this.registerBrowserInfoAnalyticsContext();
}
public setup(): AnalyticsServiceSetup {
public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup {
this.registerElasticsearchInfoContext(injectedMetadata);
return {
optIn: this.analyticsClient.optIn,
registerContextProvider: this.analyticsClient.registerContextProvider,
@ -51,6 +68,7 @@ export class AnalyticsService {
telemetryCounter$: this.analyticsClient.telemetryCounter$,
};
}
public start(): AnalyticsServiceStart {
return {
optIn: this.analyticsClient.optIn,
@ -58,7 +76,119 @@ export class AnalyticsService {
telemetryCounter$: this.analyticsClient.telemetryCounter$,
};
}
public stop() {
this.analyticsClient.shutdown();
}
/**
* Enriches the events with a session_id, so we can correlate them and understand funnels.
* @private
*/
private registerSessionIdContext() {
this.analyticsClient.registerContextProvider({
name: 'session-id',
context$: of({ session_id: getSessionId() }),
schema: {
session_id: {
type: 'keyword',
_meta: { description: 'Unique session ID for every browser session' },
},
},
});
}
/**
* Enriches the event with the build information.
* @param core The core context.
* @private
*/
private registerBuildInfoAnalyticsContext(core: CoreContext) {
this.analyticsClient.registerContextProvider({
name: 'build info',
context$: of({
isDev: core.env.mode.dev,
isDistributable: core.env.packageInfo.dist,
version: core.env.packageInfo.version,
branch: core.env.packageInfo.branch,
buildNum: core.env.packageInfo.buildNum,
buildSha: core.env.packageInfo.buildSha,
}),
schema: {
isDev: {
type: 'boolean',
_meta: { description: 'Is it running in development mode?' },
},
isDistributable: {
type: 'boolean',
_meta: { description: 'Is it running from a distributable?' },
},
version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } },
branch: {
type: 'keyword',
_meta: { description: 'Branch of source running Kibana from.' },
},
buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } },
buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } },
},
});
}
/**
* Enriches events with the current Browser's information
* @private
*/
private registerBrowserInfoAnalyticsContext() {
this.analyticsClient.registerContextProvider({
name: 'browser info',
context$: of({
user_agent: navigator.userAgent,
preferred_language: navigator.language,
preferred_languages: navigator.languages,
}),
schema: {
user_agent: {
type: 'keyword',
_meta: { description: 'User agent of the browser.' },
},
preferred_language: {
type: 'keyword',
_meta: { description: 'Preferred language of the browser.' },
},
preferred_languages: {
type: 'array',
items: {
type: 'keyword',
_meta: { description: 'List of the preferred languages of the browser.' },
},
},
},
});
}
/**
* Enriches the events with the Elasticsearch info (cluster name, uuid and version).
* @param injectedMetadata The injected metadata service.
* @private
*/
private registerElasticsearchInfoContext(injectedMetadata: InjectedMetadataSetup) {
this.analyticsClient.registerContextProvider({
name: 'elasticsearch info',
context$: of(injectedMetadata.getElasticsearchInfo()),
schema: {
cluster_name: {
type: 'keyword',
_meta: { description: 'The Cluster Name', optional: true },
},
cluster_uuid: {
type: 'keyword',
_meta: { description: 'The Cluster UUID', optional: true },
},
cluster_version: {
type: 'keyword',
_meta: { description: 'The Cluster version', optional: true },
},
},
});
}
}

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.
*/
import { getSessionId } from './get_session_id';
describe('getSessionId', () => {
test('should return a session id', () => {
const sessionId = getSessionId();
expect(sessionId).toStrictEqual(expect.any(String));
});
test('calling it twice should return the same value', () => {
const sessionId1 = getSessionId();
const sessionId2 = getSessionId();
expect(sessionId2).toStrictEqual(sessionId1);
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 { v4 } from 'uuid';
/**
* Returns a session ID for the current user.
* We are storing it to the sessionStorage. This means it remains the same through refreshes,
* but it is not persisted when closing the browser/tab or manually navigating to another URL.
*/
export function getSessionId(): string {
const sessionId = sessionStorage.getItem('sessionId') ?? v4();
sessionStorage.setItem('sessionId', sessionId);
return sessionId;
}

View file

@ -0,0 +1,80 @@
/*
* 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 { LogRecord } from '@kbn/logging';
import { createLogger } from './logger';
describe('createLogger', () => {
// Calling `.mockImplementation` on all of them to avoid jest logging the console usage
const logErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const logWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const logInfoSpy = jest.spyOn(console, 'info').mockImplementation();
const logDebugSpy = jest.spyOn(console, 'debug').mockImplementation();
const logTraceSpy = jest.spyOn(console, 'trace').mockImplementation();
const logLogSpy = jest.spyOn(console, 'log').mockImplementation();
beforeEach(() => {
jest.clearAllMocks();
});
test('should create a logger', () => {
const logger = createLogger(false);
expect(logger).toStrictEqual(
expect.objectContaining({
fatal: expect.any(Function),
error: expect.any(Function),
warn: expect.any(Function),
info: expect.any(Function),
debug: expect.any(Function),
trace: expect.any(Function),
log: expect.any(Function),
get: expect.any(Function),
})
);
});
test('when isDev === false, it should not log anything', () => {
const logger = createLogger(false);
logger.fatal('fatal');
expect(logErrorSpy).not.toHaveBeenCalled();
logger.error('error');
expect(logErrorSpy).not.toHaveBeenCalled();
logger.warn('warn');
expect(logWarnSpy).not.toHaveBeenCalled();
logger.info('info');
expect(logInfoSpy).not.toHaveBeenCalled();
logger.debug('debug');
expect(logDebugSpy).not.toHaveBeenCalled();
logger.trace('trace');
expect(logTraceSpy).not.toHaveBeenCalled();
logger.log({} as LogRecord);
expect(logLogSpy).not.toHaveBeenCalled();
logger.get().warn('warn');
expect(logWarnSpy).not.toHaveBeenCalled();
});
test('when isDev === true, it should log everything', () => {
const logger = createLogger(true);
logger.fatal('fatal');
expect(logErrorSpy).toHaveBeenCalledTimes(1);
logger.error('error');
expect(logErrorSpy).toHaveBeenCalledTimes(2); // fatal + error
logger.warn('warn');
expect(logWarnSpy).toHaveBeenCalledTimes(1);
logger.info('info');
expect(logInfoSpy).toHaveBeenCalledTimes(1);
logger.debug('debug');
expect(logDebugSpy).toHaveBeenCalledTimes(1);
logger.trace('trace');
expect(logTraceSpy).toHaveBeenCalledTimes(1);
logger.log({} as LogRecord);
expect(logLogSpy).toHaveBeenCalledTimes(1);
logger.get().warn('warn');
expect(logWarnSpy).toHaveBeenCalledTimes(2);
});
});

View file

@ -21,6 +21,20 @@ import { renderingServiceMock } from './rendering/rendering_service.mock';
import { integrationsServiceMock } from './integrations/integrations_service.mock';
import { coreAppMock } from './core_app/core_app.mock';
import { themeServiceMock } from './theme/theme_service.mock';
import { analyticsServiceMock } from './analytics/analytics_service.mock';
export const analyticsServiceStartMock = analyticsServiceMock.createAnalyticsServiceStart();
export const MockAnalyticsService = analyticsServiceMock.create();
MockAnalyticsService.start.mockReturnValue(analyticsServiceStartMock);
export const AnalyticsServiceConstructor = jest.fn().mockReturnValue(MockAnalyticsService);
jest.doMock('./analytics', () => ({
AnalyticsService: AnalyticsServiceConstructor,
}));
export const fetchOptionalMemoryInfoMock = jest.fn();
jest.doMock('./fetch_optional_memory_info', () => ({
fetchOptionalMemoryInfo: fetchOptionalMemoryInfoMock,
}));
export const MockInjectedMetadataService = injectedMetadataServiceMock.create();
export const InjectedMetadataServiceConstructor = jest

View file

@ -34,6 +34,10 @@ import {
MockCoreApp,
MockThemeService,
ThemeServiceConstructor,
AnalyticsServiceConstructor,
MockAnalyticsService,
analyticsServiceStartMock,
fetchOptionalMemoryInfoMock,
} from './core_system.test.mocks';
import { CoreSystem } from './core_system';
@ -56,6 +60,7 @@ const defaultCoreSystemParams = {
},
packageInfo: {
dist: false,
version: '1.2.3',
},
},
version: 'version',
@ -90,6 +95,7 @@ describe('constructor', () => {
expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1);
expect(CoreAppConstructor).toHaveBeenCalledTimes(1);
expect(ThemeServiceConstructor).toHaveBeenCalledTimes(1);
expect(AnalyticsServiceConstructor).toHaveBeenCalledTimes(1);
});
it('passes injectedMetadata param to InjectedMetadataService', () => {
@ -146,6 +152,11 @@ describe('#setup()', () => {
return core.setup();
}
it('calls analytics#setup()', async () => {
await setupCore();
expect(MockAnalyticsService.setup).toHaveBeenCalledTimes(1);
});
it('calls application#setup()', async () => {
await setupCore();
expect(MockApplicationService.setup).toHaveBeenCalledTimes(1);
@ -222,6 +233,36 @@ describe('#start()', () => {
);
});
it('reports the event Loaded Kibana', async () => {
await startCore();
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', {
kibana_version: '1.2.3',
});
});
it('reports the event Loaded Kibana (with memory)', async () => {
fetchOptionalMemoryInfoMock.mockReturnValue({
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
});
await startCore();
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', {
kibana_version: '1.2.3',
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
});
});
it('calls analytics#start()', async () => {
await startCore();
expect(MockAnalyticsService.start).toHaveBeenCalledTimes(1);
});
it('calls application#start()', async () => {
await startCore();
expect(MockApplicationService.start).toHaveBeenCalledTimes(1);

View file

@ -32,7 +32,9 @@ import { ThemeService } from './theme';
import { CoreApp } from './core_app';
import type { InternalApplicationSetup, InternalApplicationStart } from './application/types';
import { ExecutionContextService } from './execution_context';
import type { AnalyticsServiceSetup } from './analytics';
import { AnalyticsService } from './analytics';
import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info';
interface Params {
rootDomElement: HTMLElement;
@ -148,9 +150,10 @@ export class CoreSystem {
await this.integrations.setup();
this.docLinks.setup();
const analytics = this.analytics.setup();
const analytics = this.analytics.setup({ injectedMetadata });
this.registerLoadedKibanaEventType(analytics);
const executionContext = this.executionContext.setup();
const executionContext = this.executionContext.setup({ analytics });
const http = this.http.setup({
injectedMetadata,
fatalErrors: this.fatalErrorsSetup,
@ -273,6 +276,11 @@ export class CoreSystem {
targetDomElement: coreUiTargetDomElement,
});
analytics.reportEvent('Loaded Kibana', {
kibana_version: this.coreContext.env.packageInfo.version,
...fetchOptionalMemoryInfo(),
});
return {
application,
executionContext,
@ -303,4 +311,28 @@ export class CoreSystem {
this.analytics.stop();
this.rootDomElement.textContent = '';
}
private registerLoadedKibanaEventType(analytics: AnalyticsServiceSetup) {
analytics.registerEventType({
eventType: 'Loaded Kibana',
schema: {
kibana_version: {
type: 'keyword',
_meta: { description: 'The version of Kibana' },
},
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 },
},
},
});
}
}

View file

@ -5,23 +5,45 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { ExecutionContextService, ExecutionContextSetup } from './execution_context_service';
import type { AnalyticsServiceSetup } from '../analytics';
import { analyticsServiceMock } from '../analytics/analytics_service.mock';
describe('ExecutionContextService', () => {
let execContext: ExecutionContextSetup;
let curApp$: BehaviorSubject<string>;
let execService: ExecutionContextService;
let analytics: jest.Mocked<AnalyticsServiceSetup>;
beforeEach(() => {
analytics = analyticsServiceMock.createAnalyticsServiceSetup();
execService = new ExecutionContextService();
execContext = execService.setup();
execContext = execService.setup({ analytics });
curApp$ = new BehaviorSubject('app1');
execContext = execService.start({
curApp$,
});
});
it('should extend the analytics context', async () => {
expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1);
const context$ = analytics.registerContextProvider.mock.calls[0][0].context$;
execContext.set({
type: 'ghf',
description: 'first set',
});
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"applicationId": "app1",
"entityId": undefined,
"page": undefined,
"pageName": "ghf:app1",
}
`);
});
it('app name updates automatically and clears everything else', () => {
execContext.set({
type: 'ghf',

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { isEqual, isUndefined, omitBy } from 'lodash';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { compact, isEqual, isUndefined, omitBy } from 'lodash';
import { BehaviorSubject, Observable, Subscription, map } from 'rxjs';
import { AnalyticsServiceSetup } from '../analytics';
import { CoreService, KibanaExecutionContext } from '../../types';
// Should be exported from elastic/apm-rum
@ -55,6 +56,10 @@ export interface ExecutionContextSetup {
*/
export type ExecutionContextStart = ExecutionContextSetup;
export interface SetupDeps {
analytics: AnalyticsServiceSetup;
}
export interface StartDeps {
curApp$: Observable<string | undefined>;
}
@ -68,7 +73,9 @@ export class ExecutionContextService
private subscription: Subscription = new Subscription();
private contract?: ExecutionContextSetup;
public setup() {
public setup({ analytics }: SetupDeps) {
this.enrichAnalyticsContext(analytics);
this.contract = {
context$: this.context$.asObservable(),
clear: () => {
@ -134,4 +141,45 @@ export class ExecutionContextService
...context,
};
}
/**
* Sets the analytics context provider based on the execution context details.
* @param analytics The analytics service
* @private
*/
private enrichAnalyticsContext(analytics: AnalyticsServiceSetup) {
analytics.registerContextProvider({
name: 'execution_context',
context$: this.context$.pipe(
map(({ type, name, page, id }) => ({
pageName: `${compact([type, name, page]).join(':')}`,
applicationId: name ?? type ?? '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,
},
},
},
});
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 { fetchOptionalMemoryInfo } from './fetch_optional_memory_info';
describe('fetchOptionalMemoryInfo', () => {
test('should return undefined if no memory info is available', () => {
expect(fetchOptionalMemoryInfo()).toBeUndefined();
});
test('should return the memory info when available', () => {
// @ts-expect-error 2339
window.performance.memory = {
get jsHeapSizeLimit() {
return 3;
},
get totalJSHeapSize() {
return 2;
},
get usedJSHeapSize() {
return 1;
},
};
expect(fetchOptionalMemoryInfo()).toEqual({
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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.
*/
/**
* `Performance.memory` output.
* https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory
*/
export interface BrowserPerformanceMemoryInfo {
/**
* The maximum size of the heap, in bytes, that is available to the context.
*/
memory_js_heap_size_limit: number;
/**
* The total allocated heap size, in bytes.
*/
memory_js_heap_size_total: number;
/**
* The currently active segment of JS heap, in bytes.
*/
memory_js_heap_size_used: number;
}
/**
* Get performance information from the browser (non-standard property).
* @remarks Only available in Google Chrome and MS Edge for now.
*/
export function fetchOptionalMemoryInfo(): BrowserPerformanceMemoryInfo | undefined {
// @ts-expect-error 2339
const memory = window.performance.memory;
if (memory) {
return {
memory_js_heap_size_limit: memory.jsHeapSizeLimit,
memory_js_heap_size_total: memory.totalJSHeapSize,
memory_js_heap_size_used: memory.usedJSHeapSize,
};
}
}

View file

@ -16,6 +16,7 @@ const createSetupContractMock = () => {
getPublicBaseUrl: jest.fn(),
getKibanaVersion: jest.fn(),
getKibanaBranch: jest.fn(),
getElasticsearchInfo: jest.fn(),
getCspConfig: jest.fn(),
getExternalUrlConfig: jest.fn(),
getAnonymousStatusPage: jest.fn(),

View file

@ -9,6 +9,36 @@
import { DiscoveredPlugin } from '../../server';
import { InjectedMetadataService } from './injected_metadata_service';
describe('setup.getElasticsearchInfo()', () => {
it('returns elasticsearch info from injectedMetadata', () => {
const setup = new InjectedMetadataService({
injectedMetadata: {
clusterInfo: {
cluster_uuid: 'foo',
cluster_name: 'cluster_name',
cluster_version: 'version',
},
},
} as any).setup();
expect(setup.getElasticsearchInfo()).toEqual({
cluster_uuid: 'foo',
cluster_name: 'cluster_name',
cluster_version: 'version',
});
});
it('returns elasticsearch info as undefined if not present in the injectedMetadata', () => {
const setup = new InjectedMetadataService({
injectedMetadata: {
clusterInfo: {},
},
} as any).setup();
expect(setup.getElasticsearchInfo()).toEqual({});
});
});
describe('setup.getKibanaBuildNumber()', () => {
it('returns buildNumber from injectedMetadata', () => {
const setup = new InjectedMetadataService({

View file

@ -27,6 +27,12 @@ export interface InjectedPluginMetadata {
};
}
export interface InjectedMetadataClusterInfo {
cluster_uuid?: string;
cluster_name?: string;
cluster_version?: string;
}
/** @internal */
export interface InjectedMetadataParams {
injectedMetadata: {
@ -36,6 +42,7 @@ export interface InjectedMetadataParams {
basePath: string;
serverBasePath: string;
publicBaseUrl: string;
clusterInfo: InjectedMetadataClusterInfo;
category?: AppCategory;
csp: {
warnLegacyBrowsers: boolean;
@ -143,6 +150,10 @@ export class InjectedMetadataService {
getTheme: () => {
return this.state.theme;
},
getElasticsearchInfo: () => {
return this.state.clusterInfo;
},
};
}
}
@ -169,6 +180,7 @@ export interface InjectedMetadataSetup {
darkMode: boolean;
version: ThemeVersion;
};
getElasticsearchInfo: () => InjectedMetadataClusterInfo;
/**
* An array of frontend plugins in topological order.
*/

View file

@ -1590,6 +1590,6 @@ export interface UserProvidedValues<T = any> {
// Warnings were encountered during analysis:
//
// src/core/public/core_system.ts:192:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts
// src/core/public/core_system.ts:195:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts
```

View file

@ -8,6 +8,7 @@
import type { AnalyticsClient } from '@kbn/analytics-client';
import { createAnalytics } from '@kbn/analytics-client';
import { of } from 'rxjs';
import type { CoreContext } from '../core_context';
/**
@ -43,6 +44,8 @@ export class AnalyticsService {
// For now, we are relying on whether it's a distributable or running from source.
sendTo: core.env.packageInfo.dist ? 'production' : 'staging',
});
this.registerBuildInfoAnalyticsContext(core);
}
public preboot(): AnalyticsServicePreboot {
@ -74,7 +77,44 @@ export class AnalyticsService {
telemetryCounter$: this.analyticsClient.telemetryCounter$,
};
}
public stop() {
this.analyticsClient.shutdown();
}
/**
* Enriches the event with the build information.
* @param core The core context.
* @private
*/
private registerBuildInfoAnalyticsContext(core: CoreContext) {
this.analyticsClient.registerContextProvider({
name: 'build info',
context$: of({
isDev: core.env.mode.dev,
isDistributable: core.env.packageInfo.dist,
version: core.env.packageInfo.version,
branch: core.env.packageInfo.branch,
buildNum: core.env.packageInfo.buildNum,
buildSha: core.env.packageInfo.buildSha,
}),
schema: {
isDev: {
type: 'boolean',
_meta: { description: 'Is it running in development mode?' },
},
isDistributable: {
type: 'boolean',
_meta: { description: 'Is it running from a distributable?' },
},
version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } },
branch: {
type: 'keyword',
_meta: { description: 'Branch of source running Kibana from.' },
},
buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } },
buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } },
},
});
}
}

View file

@ -25,6 +25,7 @@ import {
} from './types';
import { NodesVersionCompatibility } from './version_check/ensure_es_version';
import { ServiceStatus, ServiceStatusLevels } from '../status';
import type { ClusterInfo } from './get_cluster_info';
type MockedElasticSearchServicePreboot = jest.Mocked<ElasticsearchServicePreboot>;
@ -89,6 +90,11 @@ const createInternalSetupContractMock = () => {
warningNodes: [],
kibanaVersion: '8.0.0',
}),
clusterInfo$: new BehaviorSubject<ClusterInfo>({
cluster_uuid: 'cluster-uuid',
cluster_name: 'cluster-name',
cluster_version: '8.0.0',
}),
status$: new BehaviorSubject<ServiceStatus<ElasticsearchStatusMeta>>({
level: ServiceStatusLevels.available,
summary: 'Elasticsearch is available',

View file

@ -34,6 +34,7 @@ import { elasticsearchClientMock } from './client/mocks';
import { duration } from 'moment';
import { isValidConnection as isValidConnectionMock } from './is_valid_connection';
import { pollEsNodesVersion as pollEsNodesVersionMocked } from './version_check/ensure_es_version';
import { analyticsServiceMock } from '../analytics/analytics_service.mock';
const { pollEsNodesVersion: pollEsNodesVersionActual } = jest.requireActual(
'./version_check/ensure_es_version'
@ -53,6 +54,7 @@ let setupDeps: SetupDeps;
beforeEach(() => {
setupDeps = {
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
http: httpServiceMock.createInternalSetupContract(),
executionContext: executionContextServiceMock.createInternalSetupContract(),
};

View file

@ -9,6 +9,8 @@
import { firstValueFrom, Observable, Subject } from 'rxjs';
import { map, shareReplay, takeUntil } from 'rxjs/operators';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
import { AnalyticsServiceSetup } from '../analytics';
import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
@ -29,8 +31,10 @@ import { isValidConnection } from './is_valid_connection';
import { isInlineScriptingEnabled } from './is_scripting_enabled';
import type { UnauthorizedErrorHandler } from './client/retry_unauthorized';
import { mergeConfig } from './merge_config';
import { getClusterInfo$ } from './get_cluster_info';
export interface SetupDeps {
analytics: AnalyticsServiceSetup;
http: InternalHttpServiceSetup;
executionContext: InternalExecutionContextSetup;
}
@ -92,10 +96,14 @@ export class ElasticsearchService
this.esNodesCompatibility$ = esNodesCompatibility$;
const clusterInfo$ = getClusterInfo$(this.client.asInternalUser);
registerAnalyticsContextProvider(deps.analytics, clusterInfo$);
return {
legacy: {
config$: this.config$,
},
clusterInfo$,
esNodesCompatibility$,
status$: calculateStatus$(esNodesCompatibility$),
setUnauthorizedErrorHandler: (handler) => {

View file

@ -0,0 +1,82 @@
/*
* 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 { elasticsearchClientMock } from './client/mocks';
import { firstValueFrom } from 'rxjs';
import { getClusterInfo$ } from './get_cluster_info';
describe('getClusterInfo', () => {
let internalClient: ReturnType<typeof elasticsearchClientMock.createInternalClient>;
const infoResponse = {
cluster_name: 'cluster-name',
cluster_uuid: 'cluster_uuid',
name: 'name',
tagline: 'tagline',
version: {
number: '1.2.3',
lucene_version: '1.2.3',
build_date: 'DateString',
build_flavor: 'string',
build_hash: 'string',
build_snapshot: true,
build_type: 'string',
minimum_index_compatibility_version: '1.2.3',
minimum_wire_compatibility_version: '1.2.3',
},
};
beforeEach(() => {
internalClient = elasticsearchClientMock.createInternalClient();
});
test('it provides the context', async () => {
internalClient.info.mockResolvedValue(infoResponse);
const context$ = getClusterInfo$(internalClient);
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "1.2.3",
}
`);
});
test('it retries if it fails to fetch the cluster info', async () => {
internalClient.info.mockRejectedValueOnce(new Error('Failed to fetch cluster info'));
internalClient.info.mockResolvedValue(infoResponse);
const context$ = getClusterInfo$(internalClient);
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "1.2.3",
}
`);
expect(internalClient.info).toHaveBeenCalledTimes(2);
});
test('multiple subscribers do not trigger more ES requests', async () => {
internalClient.info.mockResolvedValue(infoResponse);
const context$ = getClusterInfo$(internalClient);
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "1.2.3",
}
`);
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "1.2.3",
}
`);
expect(internalClient.info).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 { Observable } from 'rxjs';
import { defer, map, retry, shareReplay } from 'rxjs';
import type { ElasticsearchClient } from './client';
/** @private */
export interface ClusterInfo {
cluster_name: string;
cluster_uuid: string;
cluster_version: string;
}
/**
* Returns the cluster info from the Elasticsearch cluster.
* @param internalClient Elasticsearch client
* @private
*/
export function getClusterInfo$(internalClient: ElasticsearchClient): Observable<ClusterInfo> {
return defer(() => internalClient.info()).pipe(
map((info) => ({
cluster_name: info.cluster_name,
cluster_uuid: info.cluster_uuid,
cluster_version: info.version.number,
})),
retry({ delay: 1000 }),
shareReplay(1)
);
}

View file

@ -0,0 +1,35 @@
/*
* 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 { firstValueFrom, of } from 'rxjs';
import type { AnalyticsServiceSetup } from '../analytics';
import { analyticsServiceMock } from '../analytics/analytics_service.mock';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
describe('registerAnalyticsContextProvider', () => {
let analyticsMock: jest.Mocked<AnalyticsServiceSetup>;
beforeEach(() => {
analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup();
});
test('it provides the context', async () => {
registerAnalyticsContextProvider(
analyticsMock,
of({ cluster_name: 'cluster-name', cluster_uuid: 'cluster_uuid', cluster_version: '1.2.3' })
);
const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0];
await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "1.2.3",
}
`);
});
});

View file

@ -0,0 +1,32 @@
/*
* 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 { Observable } from 'rxjs';
import type { AnalyticsServiceSetup } from '../analytics';
import type { ClusterInfo } from './get_cluster_info';
/**
* Registers the Analytics context provider to enrich events with the cluster info.
* @param analytics Analytics service.
* @param context$ Observable emitting the cluster info.
* @private
*/
export function registerAnalyticsContextProvider(
analytics: AnalyticsServiceSetup,
context$: Observable<ClusterInfo>
) {
analytics.registerContextProvider({
name: 'elasticsearch info',
context$,
schema: {
cluster_name: { type: 'keyword', _meta: { description: 'The Cluster Name' } },
cluster_uuid: { type: 'keyword', _meta: { description: 'The Cluster UUID' } },
cluster_version: { type: 'keyword', _meta: { description: 'The Cluster version' } },
},
});
}

View file

@ -14,6 +14,7 @@ import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from
import { NodesVersionCompatibility } from './version_check/ensure_es_version';
import { ServiceStatus } from '../status';
import type { UnauthorizedErrorHandler } from './client/retry_unauthorized';
import { ClusterInfo } from './get_cluster_info';
/**
* @public
@ -97,6 +98,7 @@ export type InternalElasticsearchServicePreboot = ElasticsearchServicePreboot;
/** @internal */
export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceSetup {
clusterInfo$: Observable<ClusterInfo>;
esNodesCompatibility$: Observable<NodesVersionCompatibility>;
status$: Observable<ServiceStatus<ElasticsearchStatusMeta>>;
}

View file

@ -13,10 +13,12 @@ import { resolveInstanceUuid } from './resolve_uuid';
import { createDataFolder } from './create_data_folder';
import { writePidFile } from './write_pid_file';
import { CoreContext } from '../core_context';
import type { AnalyticsServicePreboot } from '../analytics';
import { configServiceMock } from '../config/mocks';
import { loggingSystemMock } from '../logging/logging_system.mock';
import { mockCoreContext } from '../core_context.mock';
import { analyticsServiceMock } from '../analytics/analytics_service.mock';
jest.mock('./resolve_uuid', () => ({
resolveInstanceUuid: jest.fn().mockResolvedValue('SOME_UUID'),
@ -63,11 +65,13 @@ describe('UuidService', () => {
let configService: ReturnType<typeof configServiceMock.create>;
let coreContext: CoreContext;
let service: EnvironmentService;
let analytics: AnalyticsServicePreboot;
beforeEach(async () => {
logger = loggingSystemMock.create();
configService = getConfigService();
coreContext = mockCoreContext.create({ logger, configService });
analytics = analyticsServiceMock.createAnalyticsServicePreboot();
service = new EnvironmentService(coreContext);
});
@ -78,7 +82,7 @@ describe('UuidService', () => {
describe('#preboot()', () => {
it('calls resolveInstanceUuid with correct parameters', async () => {
await service.preboot();
await service.preboot({ analytics });
expect(resolveInstanceUuid).toHaveBeenCalledTimes(1);
expect(resolveInstanceUuid).toHaveBeenCalledWith({
@ -89,7 +93,7 @@ describe('UuidService', () => {
});
it('calls createDataFolder with correct parameters', async () => {
await service.preboot();
await service.preboot({ analytics });
expect(createDataFolder).toHaveBeenCalledTimes(1);
expect(createDataFolder).toHaveBeenCalledWith({
@ -99,7 +103,7 @@ describe('UuidService', () => {
});
it('calls writePidFile with correct parameters', async () => {
await service.preboot();
await service.preboot({ analytics });
expect(writePidFile).toHaveBeenCalledTimes(1);
expect(writePidFile).toHaveBeenCalledWith({
@ -109,14 +113,14 @@ describe('UuidService', () => {
});
it('returns the uuid resolved from resolveInstanceUuid', async () => {
const preboot = await service.preboot();
const preboot = await service.preboot({ analytics });
expect(preboot.instanceUuid).toEqual('SOME_UUID');
});
describe('process warnings', () => {
it('logs warnings coming from the process', async () => {
await service.preboot();
await service.preboot({ analytics });
const warning = new Error('something went wrong');
process.emit('warning', warning);
@ -126,7 +130,7 @@ describe('UuidService', () => {
});
it('does not log deprecation warnings', async () => {
await service.preboot();
await service.preboot({ analytics });
const warning = new Error('something went wrong');
warning.name = 'DeprecationWarning';
@ -139,7 +143,7 @@ describe('UuidService', () => {
// TODO: From Nodejs v16 emitting an unhandledRejection will kill the process
describe.skip('unhandledRejection warnings', () => {
it('logs warn for an unhandeld promise rejected with an Error', async () => {
await service.preboot();
await service.preboot({ analytics });
const err = new Error('something went wrong');
process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err)));
@ -151,7 +155,7 @@ describe('UuidService', () => {
});
it('logs warn for an unhandeld promise rejected with a string', async () => {
await service.preboot();
await service.preboot({ analytics });
const err = 'something went wrong';
process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err)));
@ -166,7 +170,7 @@ describe('UuidService', () => {
describe('#setup()', () => {
it('returns the uuid resolved from resolveInstanceUuid', async () => {
await expect(service.preboot()).resolves.toEqual({ instanceUuid: 'SOME_UUID' });
await expect(service.preboot({ analytics })).resolves.toEqual({ instanceUuid: 'SOME_UUID' });
expect(service.setup()).toEqual({ instanceUuid: 'SOME_UUID' });
});
});

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { PathConfigType, config as pathConfigDef } from '@kbn/utils';
import type { AnalyticsServicePreboot } from '../analytics';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { IConfigService } from '../config';
@ -17,6 +18,16 @@ import { resolveInstanceUuid } from './resolve_uuid';
import { createDataFolder } from './create_data_folder';
import { writePidFile } from './write_pid_file';
/**
* @internal
*/
export interface PrebootDeps {
/**
* {@link AnalyticsServicePreboot}
*/
analytics: AnalyticsServicePreboot;
}
/**
* @internal
*/
@ -45,7 +56,7 @@ export class EnvironmentService {
this.configService = core.configService;
}
public async preboot() {
public async preboot({ analytics }: PrebootDeps) {
// IMPORTANT: This code is based on the assumption that none of the configuration values used
// here is supposed to change during preboot phase and it's safe to read them only once.
const [pathConfig, serverConfig, pidConfig] = await Promise.all([
@ -77,6 +88,24 @@ export class EnvironmentService {
logger: this.log,
});
analytics.registerContextProvider({
name: 'kibana info',
context$: of({
kibana_uuid: this.uuid,
pid: process.pid,
}),
schema: {
kibana_uuid: {
type: 'keyword',
_meta: { description: 'Kibana instance UUID' },
},
pid: {
type: 'long',
_meta: { description: 'Process ID' },
},
},
});
return {
instanceUuid: this.uuid,
};

View file

@ -45,8 +45,9 @@ export type HttpServiceSetupMock = jest.Mocked<
createRouter: jest.MockedFunction<() => RouterMock>;
};
export type InternalHttpServiceSetupMock = jest.Mocked<
Omit<InternalHttpServiceSetup, 'basePath' | 'createRouter' | 'authRequestHeaders'>
Omit<InternalHttpServiceSetup, 'basePath' | 'createRouter' | 'authRequestHeaders' | 'auth'>
> & {
auth: AuthMocked;
basePath: BasePathMocked;
createRouter: jest.MockedFunction<(path: string) => RouterMock>;
authRequestHeaders: jest.Mocked<IAuthHeadersStorage>;

View file

@ -7,6 +7,7 @@
*/
import { mockCoreContext } from '../../core_context.mock';
import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock';
import { httpServiceMock } from '../../http/http_service.mock';
import { pluginServiceMock } from '../../plugins/plugins_service.mock';
import { statusServiceMock } from '../../status/status_service.mock';
@ -15,6 +16,7 @@ const context = mockCoreContext.create();
const httpPreboot = httpServiceMock.createInternalPrebootContract();
const httpSetup = httpServiceMock.createInternalSetupContract();
const status = statusServiceMock.createInternalSetupContract();
const elasticsearch = elasticsearchServiceMock.createInternalSetup();
export const mockRenderingServiceParams = context;
export const mockRenderingPrebootDeps = {
@ -22,6 +24,7 @@ export const mockRenderingPrebootDeps = {
uiPlugins: pluginServiceMock.createUiPlugins(),
};
export const mockRenderingSetupDeps = {
elasticsearch,
http: httpSetup,
uiPlugins: pluginServiceMock.createUiPlugins(),
status,

View file

@ -6,6 +6,7 @@ Object {
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -61,6 +62,7 @@ Object {
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -120,6 +122,7 @@ Object {
"basePath": "",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -169,12 +172,69 @@ Object {
}
`;
exports[`RenderingService preboot() render() renders "core" page for unauthenticated requests 1`] = `
Object {
"anonymousStatusPage": false,
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
"env": Object {
"mode": Object {
"dev": Any<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"externalUrl": Object {
"policy": Array [
Object {
"allow": true,
},
],
},
"i18n": Object {
"translationsUrl": "/mock-server-basepath/translations/en.json",
},
"legacyMetadata": Object {
"uiSettings": Object {
"defaults": Object {
"registered": Object {
"name": "title",
},
},
"user": Object {},
},
},
"publicBaseUrl": "http://myhost.com/mock-server-basepath",
"serverBasePath": "/mock-server-basepath",
"theme": Object {
"darkMode": "theme:darkMode",
"version": "v8",
},
"uiPlugins": Array [],
"vars": Object {},
"version": Any<String>,
}
`;
exports[`RenderingService preboot() render() renders "core" with excluded user settings 1`] = `
Object {
"anonymousStatusPage": false,
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -230,6 +290,7 @@ Object {
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -285,6 +346,11 @@ Object {
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster-uuid",
"cluster_version": "8.0.0",
},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -344,6 +410,11 @@ Object {
"basePath": "",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster-uuid",
"cluster_version": "8.0.0",
},
"csp": Object {
"warnLegacyBrowsers": true,
},
@ -393,12 +464,73 @@ Object {
}
`;
exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = `
exports[`RenderingService setup() render() renders "core" page for unauthenticated requests 1`] = `
Object {
"anonymousStatusPage": false,
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
"env": Object {
"mode": Object {
"dev": Any<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"externalUrl": Object {
"policy": Array [
Object {
"allow": true,
},
],
},
"i18n": Object {
"translationsUrl": "/mock-server-basepath/translations/en.json",
},
"legacyMetadata": Object {
"uiSettings": Object {
"defaults": Object {
"registered": Object {
"name": "title",
},
},
"user": Object {},
},
},
"publicBaseUrl": "http://myhost.com/mock-server-basepath",
"serverBasePath": "/mock-server-basepath",
"theme": Object {
"darkMode": "theme:darkMode",
"version": "v8",
},
"uiPlugins": Array [],
"vars": Object {},
"version": Any<String>,
}
`;
exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = `
Object {
"anonymousStatusPage": false,
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {
"cluster_name": "cluster-name",
"cluster_uuid": "cluster-uuid",
"cluster_version": "8.0.0",
},
"csp": Object {
"warnLegacyBrowsers": true,
},

View file

@ -25,6 +25,7 @@ import {
} from './__mocks__/params';
import { InternalRenderingServicePreboot, InternalRenderingServiceSetup } from './types';
import { RenderingService } from './rendering_service';
import { AuthStatus } from '../http/auth_state_storage';
const INJECTED_METADATA = {
version: expect.any(String),
@ -75,6 +76,23 @@ function renderTestCases(
expect(data).toMatchSnapshot(INJECTED_METADATA);
});
it('renders "core" page for unauthenticated requests', async () => {
mockRenderingSetupDeps.http.auth.get.mockReturnValueOnce({
status: AuthStatus.unauthenticated,
state: {},
});
const [render] = await getRender();
const content = await render(
createKibanaRequest({ auth: { isAuthenticated: false } }),
uiSettings
);
const dom = load(content);
const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""');
expect(data).toMatchSnapshot(INJECTED_METADATA);
});
it('renders "core" page for blank basepath', async () => {
const [render, deps] = await getRender();
deps.http.basePath.get.mockReturnValueOnce('');

View file

@ -8,10 +8,11 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { take } from 'rxjs/operators';
import { catchError, take, timeout } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import type { ThemeVersion } from '@kbn/ui-shared-deps-npm';
import { firstValueFrom, of } from 'rxjs';
import type { UiPlugins } from '../plugins';
import { CoreContext } from '../core_context';
import { Template } from './views';
@ -25,11 +26,13 @@ import {
} from './types';
import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap';
import { getSettingValue, getStylesheetPaths } from './render_utils';
import { KibanaRequest } from '../http';
import type { HttpAuth, KibanaRequest } from '../http';
import { IUiSettingsClient } from '../ui_settings';
import { filterUiPlugins } from './filter_ui_plugins';
type RenderOptions = (RenderingPrebootDeps & { status?: never }) | RenderingSetupDeps;
type RenderOptions =
| (RenderingPrebootDeps & { status?: never; elasticsearch?: never })
| RenderingSetupDeps;
/** @internal */
export class RenderingService {
@ -57,6 +60,7 @@ export class RenderingService {
}
public async setup({
elasticsearch,
http,
status,
uiPlugins,
@ -72,12 +76,12 @@ export class RenderingService {
});
return {
render: this.render.bind(this, { http, uiPlugins, status }),
render: this.render.bind(this, { elasticsearch, http, uiPlugins, status }),
};
}
private async render(
{ http, uiPlugins, status }: RenderOptions,
{ elasticsearch, http, uiPlugins, status }: RenderOptions,
request: KibanaRequest,
uiSettings: IUiSettingsClient,
{ isAnonymousPage = false, vars, includeExposedConfigKeys }: IRenderOptions = {}
@ -94,6 +98,21 @@ export class RenderingService {
user: isAnonymousPage ? {} : await uiSettings.getUserProvided(),
};
let clusterInfo = {};
try {
// Only provide the clusterInfo if the request is authenticated and the elasticsearch service is available.
if (isAuthenticated(http.auth, request) && elasticsearch) {
clusterInfo = await firstValueFrom(
elasticsearch.clusterInfo$.pipe(
timeout(50), // If not available, just return undefined
catchError(() => of({}))
)
);
}
} catch (err) {
// swallow error
}
const darkMode = getSettingValue('theme:darkMode', settings, Boolean);
const themeVersion: ThemeVersion = 'v8';
@ -123,6 +142,7 @@ export class RenderingService {
serverBasePath,
publicBaseUrl,
env,
clusterInfo,
anonymousStatusPage: status?.isStatusPageAnonymous() ?? false,
i18n: {
translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`,
@ -164,3 +184,9 @@ const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => {
exposedConfigKeys: {},
}) as { browserConfig: Record<string, unknown>; exposedConfigKeys: Record<string, string> };
};
const isAuthenticated = (auth: HttpAuth, request: KibanaRequest) => {
const { status: authStatus } = auth.get(request);
// status is 'unknown' when auth is disabled. we just need to not be `unauthenticated` here.
return authStatus !== 'unauthenticated';
};

View file

@ -9,6 +9,7 @@
import { i18n } from '@kbn/i18n';
import type { ThemeVersion } from '@kbn/ui-shared-deps-npm';
import { InternalElasticsearchServiceSetup } from '../elasticsearch';
import { EnvironmentMode, PackageInfo } from '../config';
import { ICspConfig } from '../csp';
import { InternalHttpServicePreboot, InternalHttpServiceSetup, KibanaRequest } from '../http';
@ -38,6 +39,11 @@ export interface InjectedMetadata {
basePath: string;
serverBasePath: string;
publicBaseUrl?: string;
clusterInfo: {
cluster_uuid?: string;
cluster_name?: string;
cluster_version?: string;
};
env: {
mode: EnvironmentMode;
packageInfo: PackageInfo;
@ -74,6 +80,7 @@ export interface RenderingPrebootDeps {
/** @internal */
export interface RenderingSetupDeps {
elasticsearch: InternalElasticsearchServiceSetup;
http: InternalHttpServiceSetup;
status: InternalStatusServiceSetup;
uiPlugins: UiPlugins;

View file

@ -114,7 +114,7 @@ describe('unsupported_cluster_routing_allocation', () => {
await root.setup();
await expect(root.start()).rejects.toThrowError(
/Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./
/Unable to complete saved object migrations for the \[\.kibana.*\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./
);
await retryAsync(
@ -149,7 +149,7 @@ describe('unsupported_cluster_routing_allocation', () => {
await root.setup();
await expect(root.start()).rejects.toThrowError(
/Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./
/Unable to complete saved object migrations for the \[\.kibana.*\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./
);
});
});

View file

@ -56,10 +56,25 @@ import { config as executionContextConfig } from './execution_context';
import { PrebootCoreRouteHandlerContext } from './preboot_core_route_handler_context';
import { PrebootService } from './preboot';
import { DiscoveredPlugins } from './plugins';
import { AnalyticsService } from './analytics';
import { AnalyticsService, AnalyticsServiceSetup } from './analytics';
const coreId = Symbol('core');
const rootConfigPath = '';
const KIBANA_STARTED_EVENT = 'kibana_started';
/** @internal */
interface UptimePerStep {
start: number;
end: number;
}
/** @internal */
interface UptimeSteps {
constructor: UptimePerStep;
preboot: UptimePerStep;
setup: UptimePerStep;
start: UptimePerStep;
}
export class Server {
public readonly configService: ConfigService;
@ -94,11 +109,15 @@ export class Server {
private discoveredPlugins?: DiscoveredPlugins;
private readonly logger: LoggerFactory;
private readonly uptimePerStep: Partial<UptimeSteps> = {};
constructor(
rawConfigProvider: RawConfigurationProvider,
public readonly env: Env,
private readonly loggingSystem: ILoggingSystem
) {
const constructorStartUptime = process.uptime();
this.logger = this.loggingSystem.asLoggerFactory();
this.log = this.logger.get('server');
this.configService = new ConfigService(rawConfigProvider, env, this.logger);
@ -129,15 +148,18 @@ export class Server {
this.savedObjectsStartPromise = new Promise((resolve) => {
this.resolveSavedObjectsStartPromise = resolve;
});
this.uptimePerStep.constructor = { start: constructorStartUptime, end: process.uptime() };
}
public async preboot() {
this.log.debug('prebooting server');
const prebootStartUptime = process.uptime();
const prebootTransaction = apm.startTransaction('server-preboot', 'kibana-platform');
const analyticsPreboot = this.analytics.preboot();
const environmentPreboot = await this.environment.preboot();
const environmentPreboot = await this.environment.preboot({ analytics: analyticsPreboot });
// Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph.
this.discoveredPlugins = await this.plugins.discover({ environment: environmentPreboot });
@ -187,15 +209,19 @@ export class Server {
this.coreApp.preboot(corePreboot, uiPlugins);
prebootTransaction?.end();
this.uptimePerStep.preboot = { start: prebootStartUptime, end: process.uptime() };
return corePreboot;
}
public async setup() {
this.log.debug('setting up server');
const setupStartUptime = process.uptime();
const setupTransaction = apm.startTransaction('server-setup', 'kibana-platform');
const analyticsSetup = this.analytics.setup();
this.registerKibanaStartedEventType(analyticsSetup);
const environmentSetup = this.environment.setup();
// Configuration could have changed after preboot.
@ -223,6 +249,7 @@ export class Server {
const capabilitiesSetup = this.capabilities.setup({ http: httpSetup });
const elasticsearchServiceSetup = await this.elasticsearch.setup({
analytics: analyticsSetup,
http: httpSetup,
executionContext: executionContextSetup,
});
@ -249,6 +276,7 @@ export class Server {
});
const statusSetup = await this.status.setup({
analytics: analyticsSetup,
elasticsearch: elasticsearchServiceSetup,
pluginDependencies: pluginTree.asNames,
savedObjects: savedObjectsSetup,
@ -259,6 +287,7 @@ export class Server {
});
const renderingSetup = await this.rendering.setup({
elasticsearch: elasticsearchServiceSetup,
http: httpSetup,
status: statusSetup,
uiPlugins,
@ -299,11 +328,13 @@ export class Server {
this.coreApp.setup(coreSetup, uiPlugins);
setupTransaction?.end();
this.uptimePerStep.setup = { start: setupStartUptime, end: process.uptime() };
return coreSetup;
}
public async start() {
this.log.debug('starting server');
const startStartUptime = process.uptime();
const startTransaction = apm.startTransaction('server-start', 'kibana-platform');
const analyticsStart = this.analytics.start();
@ -352,6 +383,9 @@ export class Server {
startTransaction?.end();
this.uptimePerStep.start = { start: startStartUptime, end: process.uptime() };
analyticsStart.reportEvent(KIBANA_STARTED_EVENT, { uptime_per_step: this.uptimePerStep });
return this.coreStart;
}
@ -405,4 +439,92 @@ export class Server {
this.configService.setSchema(descriptor.path, descriptor.schema);
}
}
private registerKibanaStartedEventType(analyticsSetup: AnalyticsServiceSetup) {
analyticsSetup.registerEventType<{ uptime_per_step: UptimeSteps }>({
eventType: KIBANA_STARTED_EVENT,
schema: {
uptime_per_step: {
properties: {
constructor: {
properties: {
start: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until the constructor was called',
},
},
end: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until the constructor finished',
},
},
},
},
preboot: {
properties: {
start: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `preboot` was called',
},
},
end: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `preboot` finished',
},
},
},
},
setup: {
properties: {
start: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `setup` was called',
},
},
end: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `setup` finished',
},
},
},
},
start: {
properties: {
start: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `start` was called',
},
},
end: {
type: 'float',
_meta: {
description:
'Number of seconds the Node.js process has been running until `start` finished',
},
},
},
},
},
_meta: {
description:
'Number of seconds the Node.js process has been running until each phase of the server execution is called and finished.',
},
},
},
});
}
}

View file

@ -6,11 +6,16 @@
* Side Public License, v 1.
*/
import { of, BehaviorSubject } from 'rxjs';
import { of, BehaviorSubject, firstValueFrom } from 'rxjs';
import { ServiceStatus, ServiceStatusLevels, CoreStatus } from './types';
import {
ServiceStatus,
ServiceStatusLevels,
CoreStatus,
InternalStatusServiceSetup,
} from './types';
import { StatusService } from './status_service';
import { first } from 'rxjs/operators';
import { first, take, toArray } from 'rxjs/operators';
import { mockCoreContext } from '../core_context.mock';
import { ServiceStatusLevelSnapshotSerializer } from './test_utils';
import { environmentServiceMock } from '../environment/environment_service.mock';
@ -19,6 +24,8 @@ import { mockRouter, RouterMock } from '../http/router/router.mock';
import { metricsServiceMock } from '../metrics/metrics_service.mock';
import { configServiceMock } from '../config/mocks';
import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock';
import { analyticsServiceMock } from '../analytics/analytics_service.mock';
import { AnalyticsServiceSetup } from '..';
expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer);
@ -47,6 +54,7 @@ describe('StatusService', () => {
type SetupDeps = Parameters<StatusService['setup']>[0];
const setupDeps = (overrides: Partial<SetupDeps>): SetupDeps => {
return {
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
elasticsearch: {
status$: of(available),
},
@ -535,5 +543,50 @@ describe('StatusService', () => {
);
});
});
describe('analytics', () => {
let analyticsMock: jest.Mocked<AnalyticsServiceSetup>;
let setup: InternalStatusServiceSetup;
beforeEach(async () => {
analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup();
setup = await service.setup(setupDeps({ analytics: analyticsMock }));
});
test('registers a context provider', async () => {
expect(analyticsMock.registerContextProvider).toHaveBeenCalledTimes(1);
const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0];
await expect(firstValueFrom(context$.pipe(take(2), toArray()))).resolves
.toMatchInlineSnapshot(`
Array [
Object {
"overall_status_level": "initializing",
"overall_status_summary": "Kibana is starting up",
},
Object {
"overall_status_level": "available",
"overall_status_summary": "All services are available",
},
]
`);
});
test('registers and reports an event', async () => {
expect(analyticsMock.registerEventType).toHaveBeenCalledTimes(1);
expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(0);
// wait for an emission of overall$
await firstValueFrom(setup.overall$);
expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsMock.reportEvent.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"core-overall_status_changed",
Object {
"overall_status_level": "available",
"overall_status_summary": "All services are available",
},
]
`);
});
});
});
});

View file

@ -6,10 +6,21 @@
* Side Public License, v 1.
*/
import { Observable, combineLatest, Subscription, Subject, firstValueFrom } from 'rxjs';
import { map, distinctUntilChanged, shareReplay, debounceTime } from 'rxjs/operators';
import {
Observable,
combineLatest,
Subscription,
Subject,
firstValueFrom,
tap,
BehaviorSubject,
} from 'rxjs';
import { map, distinctUntilChanged, shareReplay, debounceTime, takeUntil } from 'rxjs/operators';
import { isDeepStrictEqual } from 'util';
import type { RootSchema } from '@kbn/analytics-client';
import { AnalyticsServiceSetup } from '../analytics';
import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
import { Logger, LogMeta } from '../logging';
@ -32,7 +43,13 @@ interface StatusLogMeta extends LogMeta {
kibana: { status: ServiceStatus };
}
interface StatusAnalyticsPayload {
overall_status_level: string;
overall_status_summary: string;
}
export interface SetupDeps {
analytics: AnalyticsServiceSetup;
elasticsearch: Pick<InternalElasticsearchServiceSetup, 'status$'>;
environment: InternalEnvironmentServiceSetup;
pluginDependencies: ReadonlyMap<PluginName, PluginName[]>;
@ -57,6 +74,7 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
}
public async setup({
analytics,
elasticsearch,
pluginDependencies,
http,
@ -88,6 +106,8 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
shareReplay(1)
);
this.setupAnalyticsContextAndEvents(analytics);
const coreOverall$ = core$.pipe(
// Prevent many emissions at once from dependency status resolution from making this too noisy
debounceTime(25),
@ -192,4 +212,40 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
shareReplay(1)
);
}
private setupAnalyticsContextAndEvents(analytics: AnalyticsServiceSetup) {
// Set an initial "initializing" status, so we can attach it to early events.
const context$ = new BehaviorSubject<StatusAnalyticsPayload>({
overall_status_level: 'initializing',
overall_status_summary: 'Kibana is starting up',
});
// The schema is the same for the context and the events.
const schema: RootSchema<StatusAnalyticsPayload> = {
overall_status_level: {
type: 'keyword',
_meta: { description: 'The current availability level of the service.' },
},
overall_status_summary: {
type: 'text',
_meta: { description: 'A high-level summary of the service status.' },
},
};
const overallStatusChangedEventName = 'core-overall_status_changed';
analytics.registerEventType({ eventType: overallStatusChangedEventName, schema });
analytics.registerContextProvider({ name: 'status info', context$, schema });
this.overall$!.pipe(
takeUntil(this.stop$),
map(({ level, summary }) => ({
overall_status_level: level.toString(),
overall_status_summary: summary,
})),
// Emit the event before spreading the status to the context.
// This way we see from the context the previous status and the current one.
tap((statusPayload) => analytics.reportEvent(overallStatusChangedEventName, statusPayload))
).subscribe(context$);
}
}

View file

@ -14,7 +14,7 @@
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type KibanaExecutionContext = {
/**
* Kibana application initated an operation.
* Kibana application initiated an operation.
* */
readonly type?: string; // 'visualization' | 'actions' | 'server' | ..;
/** public name of an application or a user-facing feature */

View file

@ -7,14 +7,21 @@
*/
import { Subject } from 'rxjs';
import type { AnalyticsClientInitContext } from '@kbn/analytics-client';
import type { Event, IShipper } from '@kbn/core/public';
export class CustomShipper implements IShipper {
public static shipperName = 'FTR-helpers-shipper';
constructor(private readonly events$: Subject<Event>) {}
constructor(
private readonly events$: Subject<Event>,
private readonly initContext: AnalyticsClientInitContext
) {}
public reportEvents(events: Event[]) {
this.initContext.logger.info(
`Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}`
);
events.forEach((event) => {
this.events$.next(event);
});

View file

@ -7,14 +7,21 @@
*/
import { Subject } from 'rxjs';
import type { AnalyticsClientInitContext } from '@kbn/analytics-client';
import type { IShipper, Event } from '@kbn/core/server';
export class CustomShipper implements IShipper {
public static shipperName = 'FTR-helpers-shipper';
constructor(private readonly events$: Subject<Event>) {}
constructor(
private readonly events$: Subject<Event>,
private readonly initContext: AnalyticsClientInitContext
) {}
public reportEvents(events: Event[]) {
this.initContext.logger.info(
`Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}`
);
events.forEach((event) => {
this.events$.next(event);
});

View file

@ -34,7 +34,7 @@ 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.
// 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

@ -12,24 +12,27 @@ import '@kbn/analytics-ftr-helpers-plugin/public/types';
export function KibanaEBTServerProvider({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const setOptIn = async (optIn: boolean) => {
await supertest
.post(`/internal/analytics_ftr_helpers/opt_in`)
.set('kbn-xsrf', 'xxx')
.query({ consent: optIn })
.expect(200);
};
return {
/**
* Change the opt-in state of the Kibana EBT client.
* @param optIn `true` to opt-in, `false` to opt-out.
*/
setOptIn: async (optIn: boolean) => {
await supertest
.post(`/internal/analytics_ftr_helpers/opt_in`)
.set('kbn-xsrf', 'xxx')
.query({ consent: optIn })
.expect(200);
},
setOptIn,
/**
* Returns the last events of the specified types.
* @param numberOfEvents - number of events to return
* @param eventTypes (Optional) array of event types to return
*/
getLastEvents: async (takeNumberOfEvents: number, eventTypes: string[] = []) => {
await setOptIn(true);
const resp = await supertest
.get(`/internal/analytics_ftr_helpers/events`)
.query({ takeNumberOfEvents, eventTypes: JSON.stringify(eventTypes) })
@ -45,6 +48,10 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC
const { common } = getPageObjects(['common']);
const browser = getService('browser');
const setOptIn = async (optIn: boolean) => {
await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn);
};
return {
/**
* Change the opt-in state of the Kibana EBT client.
@ -52,7 +59,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC
*/
setOptIn: async (optIn: boolean) => {
await common.navigateToApp('home');
await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn);
await setOptIn(optIn);
},
/**
* Returns the last events of the specified types.
@ -60,6 +67,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC
* @param eventTypes (Optional) array of event types to return
*/
getLastEvents: async (numberOfEvents: number, eventTypes: string[] = []) => {
await setOptIn(true);
const events = await browser.execute(
({ eventTypes: _eventTypes, numberOfEvents: _numberOfEvents }) =>
window.__analytics_ftr_helpers__.getLastEvents(_numberOfEvents, _eventTypes),

View file

@ -72,6 +72,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(context).to.have.property('user_agent');
expect(context.user_agent).to.be.a('string');
// Some context providers emit very early. We are OK with that.
const initialContext = actions[2].meta[0].context;
const reportEventContext = actions[2].meta[1].context;
expect(reportEventContext).to.have.property('user_agent');
expect(reportEventContext.user_agent).to.be.a('string');
@ -85,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
{
timestamp: actions[2].meta[0].timestamp,
event_type: 'test-plugin-lifecycle',
context: {},
context: initialContext,
properties: { plugin: 'analyticsPluginA', step: 'setup' },
},
{
@ -103,7 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
{
timestamp: actions[2].meta[0].timestamp,
event_type: 'test-plugin-lifecycle',
context: {},
context: initialContext,
properties: { plugin: 'analyticsPluginA', step: 'setup' },
},
{

View file

@ -63,11 +63,19 @@ export default function ({ getService }: FtrProviderContext) {
await ebtServerHelper.setOptIn(true);
const actions = await getActions(3);
// Validating the remote PID because that's the only field that it's added by the FTR plugin.
const context = actions[1].meta;
expect(context).to.have.property('pid');
expect(context.pid).to.be.a('number');
// Some context providers emit very early. We are OK with that.
const initialContext = actions[2].meta[0].context;
const reportEventContext = actions[2].meta[1].context;
expect(context).to.have.property('pid');
expect(context.pid).to.be.a('number');
expect(actions).to.eql([
{ action: 'optIn', meta: true },
{ action: 'extendContext', meta: context },
@ -77,13 +85,13 @@ export default function ({ getService }: FtrProviderContext) {
{
timestamp: actions[2].meta[0].timestamp,
event_type: 'test-plugin-lifecycle',
context: {},
context: initialContext,
properties: { plugin: 'analyticsPluginA', step: 'setup' },
},
{
timestamp: actions[2].meta[1].timestamp,
event_type: 'test-plugin-lifecycle',
context,
context: reportEventContext,
properties: { plugin: 'analyticsPluginA', step: 'start' },
},
],
@ -96,13 +104,13 @@ export default function ({ getService }: FtrProviderContext) {
{
timestamp: actions[2].meta[0].timestamp,
event_type: 'test-plugin-lifecycle',
context: {},
context: initialContext,
properties: { plugin: 'analyticsPluginA', step: 'setup' },
},
{
timestamp: actions[2].meta[1].timestamp,
event_type: 'test-plugin-lifecycle',
context,
context: reportEventContext,
properties: { plugin: 'analyticsPluginA', step: 'start' },
},
]);

View file

@ -0,0 +1,93 @@
/*
* 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 expect from '@kbn/expect';
import { Event } from '@kbn/core/public';
import { FtrProviderContext } from '../../../services';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const deployment = getService('deployment');
const ebtUIHelper = getService('kibana_ebt_ui');
const { common } = getPageObjects(['common']);
describe('Core Context Providers', () => {
let event: Event;
before(async () => {
await common.navigateToApp('home');
[event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']); // Get the loaded Kibana event
});
it('should have the properties provided by the "cluster info" context provider', () => {
expect(event.context).to.have.property('cluster_uuid');
expect(event.context.cluster_uuid).to.be.a('string');
expect(event.context).to.have.property('cluster_name');
expect(event.context.cluster_name).to.be.a('string');
expect(event.context).to.have.property('cluster_version');
expect(event.context.cluster_version).to.be.a('string');
});
it('should have the properties provided by the "build info" context provider', () => {
expect(event.context).to.have.property('isDev');
expect(event.context.isDev).to.be.a('boolean');
expect(event.context).to.have.property('isDistributable');
expect(event.context.isDistributable).to.be.a('boolean');
expect(event.context).to.have.property('version');
expect(event.context.version).to.be.a('string');
expect(event.context).to.have.property('branch');
expect(event.context.branch).to.be.a('string');
expect(event.context).to.have.property('buildNum');
expect(event.context.buildNum).to.be.a('number');
expect(event.context).to.have.property('buildSha');
expect(event.context.buildSha).to.be.a('string');
});
it('should have the properties provided by the "session-id" context provider', () => {
expect(event.context).to.have.property('session_id');
expect(event.context.session_id).to.be.a('string');
});
it('should have the properties provided by the "browser info" context provider', () => {
expect(event.context).to.have.property('user_agent');
expect(event.context.user_agent).to.be.a('string');
expect(event.context).to.have.property('preferred_language');
expect(event.context.preferred_language).to.be.a('string');
expect(event.context).to.have.property('preferred_languages');
expect(event.context.preferred_languages).to.be.an('array');
(event.context.preferred_languages as unknown[]).forEach((lang) =>
expect(lang).to.be.a('string')
);
});
it('should have the properties provided by the "execution_context" context provider', () => {
expect(event.context).to.have.property('pageName');
expect(event.context.pageName).to.be.a('string');
expect(event.context).to.have.property('applicationId');
expect(event.context.applicationId).to.be.a('string');
expect(event.context).not.to.have.property('entityId'); // In the Home app it's not available.
expect(event.context).not.to.have.property('page'); // In the Home app it's not available.
});
it('should have the properties provided by the "license info" context provider', () => {
expect(event.context).to.have.property('license_id');
expect(event.context.license_id).to.be.a('string');
expect(event.context).to.have.property('license_status');
expect(event.context.license_status).to.be.a('string');
expect(event.context).to.have.property('license_type');
expect(event.context.license_type).to.be.a('string');
});
it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => {
if (await deployment.isCloud()) {
expect(event.context).to.have.property('cloudId');
expect(event.context.cloudId).to.be.a('string');
} else {
expect(event.context).not.to.have.property('cloudId');
}
});
});
}

View file

@ -8,13 +8,10 @@
import { FtrProviderContext } from '../../../services';
export default function ({ getService }: FtrProviderContext) {
export default function ({ loadTestFile }: FtrProviderContext) {
describe('from the browser', () => {
beforeEach(async () => {
await getService('kibana_ebt_ui').setOptIn(true);
});
// Add tests for UI-instrumented events here:
// loadTestFile(require.resolve('./some_event'));
loadTestFile(require.resolve('./loaded_kibana'));
loadTestFile(require.resolve('./core_context_providers'));
});
}

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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../services';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const ebtUIHelper = getService('kibana_ebt_ui');
const { common } = getPageObjects(['common']);
const browser = getService('browser');
describe('Loaded Kibana', () => {
beforeEach(async () => {
await common.navigateToApp('home');
});
it('should emit the "Loaded Kibana" event', async () => {
const [event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']);
expect(event.event_type).to.eql('Loaded Kibana');
expect(event.properties).to.have.property('kibana_version');
expect(event.properties.kibana_version).to.be.a('string');
if (browser.isChromium) {
expect(event.properties).to.have.property('memory_js_heap_size_limit');
expect(event.properties.memory_js_heap_size_limit).to.be.a('number');
expect(event.properties).to.have.property('memory_js_heap_size_total');
expect(event.properties.memory_js_heap_size_total).to.be.a('number');
expect(event.properties).to.have.property('memory_js_heap_size_used');
expect(event.properties.memory_js_heap_size_used).to.be.a('number');
}
});
});
}

View file

@ -0,0 +1,80 @@
/*
* 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 expect from '@kbn/expect';
import { Event } from '@kbn/core/public';
import { FtrProviderContext } from '../../../services';
export default function ({ getService }: FtrProviderContext) {
const deployment = getService('deployment');
const ebtServerHelper = getService('kibana_ebt_server');
describe('Core Context Providers', () => {
let event: Event;
before(async () => {
// Wait for the 2nd "status_changed" event. At that point all the context providers should be set up.
[, event] = await ebtServerHelper.getLastEvents(2, ['core-overall_status_changed']);
});
it('should have the properties provided by the "kibana info" context provider', () => {
expect(event.context).to.have.property('kibana_uuid');
expect(event.context.kibana_uuid).to.be.a('string');
expect(event.context).to.have.property('pid');
expect(event.context.pid).to.be.a('number');
});
it('should have the properties provided by the "build info" context provider', () => {
expect(event.context).to.have.property('isDev');
expect(event.context.isDev).to.be.a('boolean');
expect(event.context).to.have.property('isDistributable');
expect(event.context.isDistributable).to.be.a('boolean');
expect(event.context).to.have.property('version');
expect(event.context.version).to.be.a('string');
expect(event.context).to.have.property('branch');
expect(event.context.branch).to.be.a('string');
expect(event.context).to.have.property('buildNum');
expect(event.context.buildNum).to.be.a('number');
expect(event.context).to.have.property('buildSha');
expect(event.context.buildSha).to.be.a('string');
});
it('should have the properties provided by the "cluster info" context provider', () => {
expect(event.context).to.have.property('cluster_uuid');
expect(event.context.cluster_uuid).to.be.a('string');
expect(event.context).to.have.property('cluster_name');
expect(event.context.cluster_name).to.be.a('string');
expect(event.context).to.have.property('cluster_version');
expect(event.context.cluster_version).to.be.a('string');
});
it('should have the properties provided by the "status info" context provider', () => {
expect(event.context).to.have.property('overall_status_level');
expect(event.context.overall_status_level).to.be.a('string');
expect(event.context).to.have.property('overall_status_summary');
expect(event.context.overall_status_summary).to.be.a('string');
});
it('should have the properties provided by the "license info" context provider', () => {
expect(event.context).to.have.property('license_id');
expect(event.context.license_id).to.be.a('string');
expect(event.context).to.have.property('license_status');
expect(event.context.license_status).to.be.a('string');
expect(event.context).to.have.property('license_type');
expect(event.context.license_type).to.be.a('string');
});
it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => {
if (await deployment.isCloud()) {
expect(event.context).to.have.property('cloudId');
expect(event.context.cloudId).to.be.a('string');
} else {
expect(event.context).not.to.have.property('cloudId');
}
});
});
}

View file

@ -0,0 +1,51 @@
/*
* 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 expect from '@kbn/expect';
import { Event } from '@kbn/analytics-client';
import { FtrProviderContext } from '../../../services';
export default function ({ getService }: FtrProviderContext) {
const ebtServerHelper = getService('kibana_ebt_server');
describe('core-overall_status_changed', () => {
let initialEvent: Event;
let secondEvent: Event;
before(async () => {
[initialEvent, secondEvent] = await ebtServerHelper.getLastEvents(2, [
'core-overall_status_changed',
]);
});
it('should emit the initial "degraded" event with the context set to `initializing`', () => {
expect(initialEvent.event_type).to.eql('core-overall_status_changed');
expect(initialEvent.context).to.have.property('overall_status_level', 'initializing');
expect(initialEvent.context).to.have.property(
'overall_status_summary',
'Kibana is starting up'
);
expect(initialEvent.properties).to.have.property('overall_status_level', 'degraded');
expect(initialEvent.properties.overall_status_summary).to.be.a('string');
});
it('should emit the 2nd event as `available` with the context set to the previous values', () => {
expect(secondEvent.event_type).to.eql('core-overall_status_changed');
expect(secondEvent.context).to.have.property(
'overall_status_level',
initialEvent.properties.overall_status_level
);
expect(secondEvent.context).to.have.property(
'overall_status_summary',
initialEvent.properties.overall_status_summary
);
expect(secondEvent.properties.overall_status_level).to.be.a('string'); // Ideally we would test it as `available`, but we can't do that as it may result flaky for many side effects in the CI.
expect(secondEvent.properties.overall_status_summary).to.be.a('string');
});
});
}

View file

@ -8,13 +8,11 @@
import { FtrProviderContext } from '../../../services';
export default function ({ getService }: FtrProviderContext) {
export default function ({ loadTestFile }: FtrProviderContext) {
describe('from the server', () => {
beforeEach(async () => {
await getService('kibana_ebt_server').setOptIn(true);
});
// Add tests for UI-instrumented events here:
// loadTestFile(require.resolve('./some_event'));
// Add tests for Server-instrumented events here:
loadTestFile(require.resolve('./core_context_providers'));
loadTestFile(require.resolve('./kibana_started'));
loadTestFile(require.resolve('./core_overall_status_changed'));
});
}

View file

@ -0,0 +1,29 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../services';
export default function ({ getService }: FtrProviderContext) {
const ebtServerHelper = getService('kibana_ebt_server');
describe('kibana_started', () => {
it('should emit the "kibana_started" event', async () => {
const [event] = await ebtServerHelper.getLastEvents(1, ['kibana_started']);
expect(event.event_type).to.eql('kibana_started');
expect(event.properties.uptime_per_step.constructor.start).to.be.a('number');
expect(event.properties.uptime_per_step.constructor.end).to.be.a('number');
expect(event.properties.uptime_per_step.preboot.start).to.be.a('number');
expect(event.properties.uptime_per_step.preboot.end).to.be.a('number');
expect(event.properties.uptime_per_step.setup.start).to.be.a('number');
expect(event.properties.uptime_per_step.setup.end).to.be.a('number');
expect(event.properties.uptime_per_step.start.start).to.be.a('number');
expect(event.properties.uptime_per_step.start.end).to.be.a('number');
});
});
}

View file

@ -0,0 +1,30 @@
/*
* 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 { firstValueFrom } from 'rxjs';
import { registerCloudDeploymentIdAnalyticsContext } from './register_cloud_deployment_id_analytics_context';
describe('registerCloudDeploymentIdAnalyticsContext', () => {
let analytics: { registerContextProvider: jest.Mock };
beforeEach(() => {
analytics = {
registerContextProvider: jest.fn(),
};
});
test('it does not register the context provider if cloudId not provided', () => {
registerCloudDeploymentIdAnalyticsContext(analytics);
expect(analytics.registerContextProvider).not.toHaveBeenCalled();
});
test('it registers the context provider and emits the cloudId', async () => {
registerCloudDeploymentIdAnalyticsContext(analytics, 'cloud_id');
expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1);
const [{ context$ }] = analytics.registerContextProvider.mock.calls[0];
await expect(firstValueFrom(context$)).resolves.toEqual({ cloudId: 'cloud_id' });
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 type { AnalyticsClient } from '@kbn/analytics-client';
import { of } from 'rxjs';
export function registerCloudDeploymentIdAnalyticsContext(
analytics: Pick<AnalyticsClient, 'registerContextProvider'>,
cloudId?: string
) {
if (!cloudId) {
return;
}
analytics.registerContextProvider({
name: 'Cloud Deployment ID',
context$: of({ cloudId }),
schema: {
cloudId: {
type: 'keyword',
_meta: { description: 'The Cloud Deployment ID' },
},
},
});
}

View file

@ -9,9 +9,8 @@ import { nextTick } from '@kbn/test-jest-helpers';
import { coreMock } from '@kbn/core/public/mocks';
import { homePluginMock } from '@kbn/home-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { CloudPlugin, CloudConfigType, loadUserId } from './plugin';
import { firstValueFrom, Observable, Subject } from 'rxjs';
import { KibanaExecutionContext } from '@kbn/core/public';
import { CloudPlugin, CloudConfigType } from './plugin';
import { firstValueFrom } from 'rxjs';
describe('Cloud Plugin', () => {
describe('#setup', () => {
@ -20,17 +19,7 @@ describe('Cloud Plugin', () => {
jest.clearAllMocks();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
currentContext$ = undefined,
}: {
config?: Partial<CloudConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any>;
currentContext$?: Observable<KibanaExecutionContext>;
}) => {
const setupPlugin = async ({ config = {} }: { config?: Partial<CloudConfigType> }) => {
const initContext = coreMock.createPluginInitializerContext({
id: 'cloudId',
base_url: 'https://cloud.elastic.co',
@ -49,21 +38,9 @@ describe('Cloud Plugin', () => {
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (currentContext$) {
coreStart.executionContext.context$ = currentContext$;
}
const setup = plugin.setup(coreSetup, {});
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const securitySetup = securityMock.createSetup();
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
// Wait for FullStory dynamic import to resolve
await new Promise((r) => setImmediate(r));
@ -73,9 +50,6 @@ describe('Cloud Plugin', () => {
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(coreSetup.analytics.registerShipper).toHaveBeenCalled();
@ -86,9 +60,67 @@ describe('Cloud Plugin', () => {
});
});
it('does not call initializeFullStory when enabled=false', async () => {
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 () => {
const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } });
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
});
});
describe('setupTelemetryContext', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
}: {
config?: Partial<CloudConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any> | Error;
}) => {
const initContext = coreMock.createPluginInitializerContext({
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...config,
});
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const securitySetup = securityMock.createSetup();
if (currentUserProps instanceof Error) {
securitySetup.authc.getCurrentUser.mockRejectedValue(currentUserProps);
} else {
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
}
const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
return { initContext, plugin, setup, coreSetup };
};
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' } },
config: { id: 'cloudId' },
currentUserProps: {
username: '1234',
},
@ -105,9 +137,9 @@ describe('Cloud Plugin', () => {
});
});
it('user hash includes org id', async () => {
it('user hash includes cloud id', async () => {
const { coreSetup: coreSetup1 } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg1' },
config: { id: 'esOrg1' },
currentUserProps: {
username: '1234',
},
@ -137,146 +169,37 @@ describe('Cloud Plugin', () => {
expect(hashId1).not.toEqual(hashId2);
});
it('emits the execution context provider everytime an app changes', async () => {
const currentContext$ = new Subject<KibanaExecutionContext>();
test('user hash does not include cloudId when not provided', async () => {
const { coreSetup } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' } },
config: {},
currentUserProps: {
username: '1234',
},
currentContext$,
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'execution_context'
([{ name }]) => name === 'cloud_user_id'
)!;
let latestContext;
context$.subscribe((context) => {
latestContext = context;
});
// takes the app name
expect(latestContext).toBeUndefined();
currentContext$.next({
name: 'App1',
description: '123',
});
await new Promise((r) => setImmediate(r));
expect(latestContext).toEqual({
pageName: 'App1',
applicationId: 'App1',
});
// context clear
currentContext$.next({});
expect(latestContext).toEqual({
pageName: '',
applicationId: 'unknown',
});
// different app
currentContext$.next({
name: 'App2',
page: 'page2',
id: '123',
});
expect(latestContext).toEqual({
pageName: 'App2:page2',
applicationId: 'App2',
page: 'page2',
entityId: '123',
});
// Back to first app
currentContext$.next({
name: 'App1',
page: 'page3',
id: '123',
});
expect(latestContext).toEqual({
pageName: 'App1:page3',
applicationId: 'App1',
page: 'page3',
entityId: '123',
await expect(firstValueFrom(context$)).resolves.toEqual({
userId: '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4',
});
});
it('does not register the cloud user id context provider when security is not available', async () => {
test('user hash is undefined when failed to fetch a user', async () => {
const { coreSetup } = await setupPlugin({
config: { full_story: { enabled: true, org_id: 'foo' } },
securityEnabled: false,
currentUserProps: new Error('failed to fetch a user'),
});
expect(
coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)
).toBeUndefined();
});
expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled();
describe('with memory', () => {
beforeAll(() => {
// @ts-expect-error 2339
window.performance.memory = {
get jsHeapSizeLimit() {
return 3;
},
get totalJSHeapSize() {
return 2;
},
get usedJSHeapSize() {
return 1;
},
};
});
const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find(
([{ name }]) => name === 'cloud_user_id'
)!;
afterAll(() => {
// @ts-expect-error 2339
delete window.performance.memory;
});
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(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('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(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', {
kibana_version: initContext.env.packageInfo.version,
});
});
it('does not call initializeFullStory when enabled=false', async () => {
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 () => {
const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } });
expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled();
await expect(firstValueFrom(context$)).resolves.toEqual({ userId: undefined });
});
});
@ -652,56 +575,4 @@ describe('Cloud Plugin', () => {
expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled();
});
});
describe('loadFullStoryUserId', () => {
let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>;
beforeEach(() => {
consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
consoleMock.mockRestore();
});
it('returns principal ID when username specified', async () => {
expect(
await loadUserId({
getCurrentUser: jest.fn().mockResolvedValue({
username: '1234',
}),
})
).toEqual('1234');
expect(consoleMock).not.toHaveBeenCalled();
});
it('returns undefined if getCurrentUser throws', async () => {
expect(
await loadUserId({
getCurrentUser: jest.fn().mockRejectedValue(new Error(`Oh no!`)),
})
).toBeUndefined();
});
it('returns undefined if getCurrentUser returns undefined', async () => {
expect(
await loadUserId({
getCurrentUser: jest.fn().mockResolvedValue(undefined),
})
).toBeUndefined();
});
it('returns undefined and logs if username undefined', async () => {
expect(
await loadUserId({
getCurrentUser: jest.fn().mockResolvedValue({
username: undefined,
metadata: { foo: 'bar' },
}),
})
).toBeUndefined();
expect(consoleMock).toHaveBeenLastCalledWith(
`[cloud.analytics] username not specified. User metadata: {"foo":"bar"}`
);
});
});
});

View file

@ -13,21 +13,16 @@ import type {
PluginInitializerContext,
HttpStart,
IBasePath,
ExecutionContextStart,
AnalyticsServiceSetup,
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, from, of, Subscription } from 'rxjs';
import { exhaustMap, filter, map } from 'rxjs/operators';
import { compact } from 'lodash';
import { BehaviorSubject, catchError, from, map, of } from 'rxjs';
import type {
AuthenticatedUser,
SecurityPluginSetup,
SecurityPluginStart,
} from '@kbn/security-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { Sha256 } from '@kbn/core/public/utils';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import {
ELASTIC_SUPPORT_LINK,
@ -91,11 +86,6 @@ interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup;
basePath: IBasePath;
}
interface SetupTelemetryContextDeps extends CloudSetupDependencies {
analytics: AnalyticsServiceSetup;
executionContextPromise: Promise<ExecutionContextStart>;
cloudId?: string;
}
interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
http: CoreSetup['http'];
@ -104,7 +94,6 @@ interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly config: CloudConfigType;
private isCloudEnabled: boolean;
private appSubscription?: Subscription;
private chatConfig$ = new BehaviorSubject<ChatConfig>({ enabled: false });
constructor(private readonly initializerContext: PluginInitializerContext) {
@ -113,19 +102,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
}
public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) {
const executionContextPromise = core.getStartServices().then(([coreStart]) => {
return coreStart.executionContext;
});
this.setupTelemetryContext({
analytics: core.analytics,
security,
executionContextPromise,
cloudId: this.config.id,
}).catch((e) => {
// eslint-disable-next-line no-console
console.debug(`Error setting up TelemetryContext: ${e.toString()}`);
});
this.setupTelemetryContext(core.analytics, security, this.config.id);
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) =>
// eslint-disable-next-line no-console
@ -213,9 +190,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
};
}
public stop() {
this.appSubscription?.unsubscribe();
}
public stop() {}
/**
* Determines if the current user should see links back to Cloud.
@ -272,48 +247,25 @@ export class CloudPlugin implements Plugin<CloudSetup> {
* 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.
* @param cloudId The Cloud Org ID.
* @private
*/
private async setupTelemetryContext({
analytics,
security,
executionContextPromise,
cloudId,
}: 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' } } },
});
private setupTelemetryContext(
analytics: AnalyticsServiceSetup,
security?: Pick<SecurityPluginSetup, 'authc'>,
cloudId?: string
) {
registerCloudDeploymentIdAnalyticsContext(analytics, cloudId);
analytics.registerContextProvider({
name: 'cloud_org_id',
context$: of({ cloudId }),
schema: {
cloudId: {
type: 'keyword',
_meta: { description: 'The Cloud ID', optional: true },
},
},
});
// 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(cloudId ? `${cloudId}:${userId}` : `${userId}`) };
})
context$: from(security.authc.getCurrentUser()).pipe(
map((user) => user.username),
// 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
map((userId) => ({ userId: sha256(cloudId ? `${cloudId}:${userId}` : `${userId}`) })),
catchError(() => of({ userId: undefined }))
),
schema: {
userId: {
@ -323,81 +275,6 @@ export class CloudPlugin implements Plugin<CloudSetup> {
},
});
}
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: memory.jsHeapSizeLimit,
memory_js_heap_size_total: memory.totalJSHeapSize,
memory_js_heap_size_used: memory.usedJSHeapSize,
};
}
analytics.reportEvent('Loaded Kibana', {
kibana_version: this.initializerContext.env.packageInfo.version,
...memoryInfo,
});
}
private async setupChat({ http, security }: SetupChatDeps) {
@ -438,32 +315,6 @@ export class CloudPlugin implements Plugin<CloudSetup> {
}
}
/** @internal exported for testing */
export const loadUserId = async ({
getCurrentUser,
}: {
getCurrentUser: () => Promise<AuthenticatedUser>;
}) => {
try {
const currentUser = await getCurrentUser().catch(() => undefined);
if (!currentUser) {
return undefined;
}
// Log very defensively here so we can debug this easily if it breaks
if (!currentUser.username) {
// eslint-disable-next-line no-console
console.debug(
`[cloud.analytics] username not specified. User metadata: ${JSON.stringify(
currentUser.metadata
)}`
);
}
return currentUser.username;
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[cloud.analytics] Error loading the current user: ${e.toString()}`, e);
return undefined;
}
};
function sha256(str: string) {
return new Sha256().update(str, 'utf8').digest('hex');
}

View file

@ -8,6 +8,7 @@
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
@ -35,7 +36,7 @@ export interface CloudSetup {
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly logger: Logger;
private readonly config: CloudConfigType;
private isDev: boolean;
private readonly isDev: boolean;
constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
@ -46,6 +47,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup): CloudSetup {
this.logger.debug('Setting up Cloud plugin');
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
if (this.config.full_story.enabled) {

View file

@ -0,0 +1,51 @@
/*
* 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 { firstValueFrom, ReplaySubject, Subject } from 'rxjs';
import type { ILicense } from './types';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
describe('registerAnalyticsContextProvider', () => {
const analyticsClientMock = {
registerContextProvider: jest.fn(),
};
let license$: Subject<ILicense>;
beforeEach(() => {
jest.clearAllMocks();
license$ = new ReplaySubject<ILicense>(1);
registerAnalyticsContextProvider(analyticsClientMock, license$);
});
test('should register the analytics context provider', () => {
expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(1);
});
test('emits a context value the moment license emits', async () => {
license$.next({
uid: 'uid',
status: 'active',
isActive: true,
type: 'basic',
signature: 'signature',
isAvailable: true,
toJSON: jest.fn(),
getUnavailableReason: jest.fn(),
hasAtLeast: jest.fn(),
check: jest.fn(),
getFeature: jest.fn(),
});
await expect(
firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$)
).resolves.toEqual({
license_id: 'uid',
license_status: 'active',
license_type: 'basic',
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 type { Observable } from 'rxjs';
import { map } from 'rxjs';
import type { AnalyticsClient } from '@kbn/analytics-client';
import type { ILicense } from './types';
export function registerAnalyticsContextProvider(
// Using `AnalyticsClient` from the package to be able to implement this method in the `common` dir.
analytics: Pick<AnalyticsClient, 'registerContextProvider'>,
license$: Observable<ILicense>
) {
analytics.registerContextProvider({
name: 'license info',
context$: license$.pipe(
map((license) => ({
license_id: license.uid,
license_status: license.status,
license_type: license.type,
}))
),
schema: {
license_id: {
type: 'keyword',
_meta: { description: 'The license ID', optional: true },
},
license_status: {
type: 'keyword',
_meta: { description: 'The license Status (active/invalid/expired)', optional: true },
},
license_type: {
type: 'keyword',
_meta: {
description: 'The license Type (basic/standard/gold/platinum/enterprise/trial)',
optional: true,
},
},
},
});
}

View file

@ -15,6 +15,7 @@ import { License } from '../common/license';
import { mountExpiredBanner } from './expired_banner';
import { FeatureUsageService } from './services';
import type { PublicLicenseJSON } from '../common/types';
import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider';
export const licensingSessionStorageKey = 'xpack.licensing';
@ -82,6 +83,8 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
this.getSaved()
);
registerAnalyticsContextProvider(core.analytics, license$);
this.internalSubscription = license$.subscribe((license) => {
if (license.isAvailable) {
this.prevSignature = license.signature;

View file

@ -21,6 +21,7 @@ import {
IClusterClient,
} from '@kbn/core/server';
import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider';
import {
ILicense,
PublicLicense,
@ -120,6 +121,8 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
pollingFrequency.asMilliseconds()
);
registerAnalyticsContextProvider(core.analytics, license$);
core.status.set(getPluginStatus$(license$, this.stop$.asObservable()));
core.http.registerRouteHandlerContext(