mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[EBT] Core Context Providers (#130785)
This commit is contained in:
parent
2c091706c0
commit
8539a912b5
63 changed files with 2033 additions and 448 deletions
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
25
src/core/public/analytics/analytics_service.test.mocks.ts
Normal file
25
src/core/public/analytics/analytics_service.test.mocks.ts
Normal 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,
|
||||
}));
|
93
src/core/public/analytics/analytics_service.test.ts
Normal file
93
src/core/public/analytics/analytics_service.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
22
src/core/public/analytics/get_session_id.test.ts
Normal file
22
src/core/public/analytics/get_session_id.test.ts
Normal 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);
|
||||
});
|
||||
});
|
20
src/core/public/analytics/get_session_id.ts
Normal file
20
src/core/public/analytics/get_session_id.ts
Normal 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;
|
||||
}
|
80
src/core/public/analytics/logger.test.ts
Normal file
80
src/core/public/analytics/logger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
35
src/core/public/fetch_optional_memory_info.test.ts
Normal file
35
src/core/public/fetch_optional_memory_info.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
42
src/core/public/fetch_optional_memory_info.ts
Normal file
42
src/core/public/fetch_optional_memory_info.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
@ -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.' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
82
src/core/server/elasticsearch/get_cluster_info.test.ts
Normal file
82
src/core/server/elasticsearch/get_cluster_info.test.ts
Normal 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);
|
||||
});
|
||||
});
|
35
src/core/server/elasticsearch/get_cluster_info.ts
Normal file
35
src/core/server/elasticsearch/get_cluster_info.ts
Normal 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)
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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' } },
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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('');
|
||||
|
|
|
@ -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';
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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\./
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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$);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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')}`,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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"}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue