add flexible EBT Performance Metric Schema (#136395)

This commit is contained in:
Liza Katz 2022-08-03 13:21:24 +01:00 committed by GitHub
parent 041bd90826
commit af45ef831e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1222 additions and 212 deletions

View file

@ -249,6 +249,7 @@
"@kbn/crypto-browser": "link:bazel-bin/packages/kbn-crypto-browser",
"@kbn/datemath": "link:bazel-bin/packages/kbn-datemath",
"@kbn/doc-links": "link:bazel-bin/packages/kbn-doc-links",
"@kbn/ebt-tools": "link:bazel-bin/packages/kbn-ebt-tools",
"@kbn/es-errors": "link:bazel-bin/packages/kbn-es-errors",
"@kbn/es-query": "link:bazel-bin/packages/kbn-es-query",
"@kbn/eslint-plugin-disable": "link:bazel-bin/packages/kbn-eslint-plugin-disable",
@ -888,6 +889,7 @@
"@types/kbn__dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module_types",
"@types/kbn__doc-links": "link:bazel-bin/packages/kbn-doc-links/npm_module_types",
"@types/kbn__docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module_types",
"@types/kbn__ebt-tools": "link:bazel-bin/packages/kbn-ebt-tools/npm_module_types",
"@types/kbn__es-archiver": "link:bazel-bin/packages/kbn-es-archiver/npm_module_types",
"@types/kbn__es-errors": "link:bazel-bin/packages/kbn-es-errors/npm_module_types",
"@types/kbn__es-query": "link:bazel-bin/packages/kbn-es-query/npm_module_types",

View file

@ -147,6 +147,7 @@ filegroup(
"//packages/kbn-dev-utils:build",
"//packages/kbn-doc-links:build",
"//packages/kbn-docs-utils:build",
"//packages/kbn-ebt-tools:build",
"//packages/kbn-es-archiver:build",
"//packages/kbn-es-errors:build",
"//packages/kbn-es-query:build",
@ -399,6 +400,7 @@ filegroup(
"//packages/kbn-dev-utils:build_types",
"//packages/kbn-doc-links:build_types",
"//packages/kbn-docs-utils:build_types",
"//packages/kbn-ebt-tools:build_types",
"//packages/kbn-es-archiver:build_types",
"//packages/kbn-es-errors:build_types",
"//packages/kbn-es-query:build_types",

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { Type } from 'io-ts';
import type { Mixed } from 'io-ts';
import type { Observable } from 'rxjs';
import { BehaviorSubject, Subject, combineLatest, from, merge } from 'rxjs';
import {
@ -43,7 +43,7 @@ import { ContextService } from './context_service';
import { schemaToIoTs, validateSchema } from '../schema/validation';
interface EventDebugLogMeta extends LogMeta {
ebt_event: Event;
ebt_event: Event<unknown>;
}
export class AnalyticsClient implements IAnalyticsClient {
@ -65,7 +65,7 @@ export class AnalyticsClient implements IAnalyticsClient {
private readonly shipperRegistered$ = new Subject<void>();
private readonly eventTypeRegistry = new Map<
EventType,
EventTypeOpts<unknown> & { validator?: Type<Record<string, unknown>> }
EventTypeOpts<unknown> & { validator?: Mixed }
>();
private readonly contextService: ContextService;
private readonly context$ = new BehaviorSubject<Partial<EventContext>>({});
@ -88,7 +88,7 @@ export class AnalyticsClient implements IAnalyticsClient {
this.reportEnqueuedEventsWhenClientIsReady();
}
public reportEvent = <EventTypeData extends Record<string, unknown>>(
public reportEvent = <EventTypeData extends object>(
eventType: EventType,
eventData: EventTypeData
) => {
@ -119,14 +119,18 @@ export class AnalyticsClient implements IAnalyticsClient {
// If the validator is registered (dev-mode only), perform the validation.
if (eventTypeOpts.validator) {
validateSchema(`Event Type '${eventType}'`, eventTypeOpts.validator, eventData);
validateSchema<EventTypeData>(
`Event Type '${eventType}'`,
eventTypeOpts.validator,
eventData
);
}
const event: Event = {
timestamp,
event_type: eventType,
context: this.context$.value,
properties: eventData,
properties: eventData as unknown as Record<string, unknown>,
};
// debug-logging before checking the opt-in status to help during development

View file

@ -170,7 +170,7 @@ export interface IAnalyticsClient {
* @param eventType The event type registered via the `registerEventType` API.
* @param eventData The properties matching the schema declared in the `registerEventType` API.
*/
reportEvent: <EventTypeData extends Record<string, unknown>>(
reportEvent: <EventTypeData extends object>(
eventType: EventType,
eventData: EventTypeData
) => void;

View file

@ -108,7 +108,7 @@ export interface TelemetryCounter {
/**
* Definition of the full event structure
*/
export interface Event {
export interface Event<Properties = Record<string, unknown>> {
/**
* The time the event was generated in ISO format.
*/
@ -120,7 +120,7 @@ export interface Event {
/**
* The specific properties of the event type.
*/
properties: Record<string, unknown>;
properties: Properties;
/**
* The {@link EventContext} enriched during the processing pipeline.
*/

View file

@ -19,7 +19,7 @@ const FULLSTORY_RESERVED_PROPERTIES = [
'pageName',
];
export function formatPayload(context: Record<string, unknown>): Record<string, unknown> {
export function formatPayload(context: object): Record<string, unknown> {
// format context keys as required for env vars, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234
return Object.fromEntries(
Object.entries(context)

View file

@ -30,6 +30,7 @@ RUNTIME_DEPS = [
"@npm//rxjs",
"@npm//uuid",
"//packages/analytics/client",
"//packages/kbn-ebt-tools",
"//packages/core/base/core-base-browser-mocks",
"//packages/core/injected-metadata/core-injected-metadata-browser-mocks",
]
@ -41,6 +42,7 @@ TYPES_DEPS = [
"@npm//rxjs",
"//packages/kbn-logging:npm_module_types",
"//packages/analytics/client:npm_module_types",
"//packages/kbn-ebt-tools:npm_module_types",
"//packages/core/base/core-base-browser-internal:npm_module_types",
"//packages/core/injected-metadata/core-injected-metadata-browser-internal:npm_module_types",
"//packages/core/analytics/core-analytics-browser:npm_module_types",

View file

@ -20,30 +20,150 @@ describe('AnalyticsService', () => {
});
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({
expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$)
).toMatchInlineSnapshot(`
Object {
"branch": "branch",
"buildNum": 100,
"buildSha": "buildSha",
"isDev": true,
"isDistributable": false,
"version": "version",
}
`);
expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[1][0].context$)
).toEqual({ session_id: expect.any(String) });
expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[2][0].context$)
).toEqual({
preferred_language: 'en-US',
preferred_languages: ['en-US', 'en'],
user_agent: expect.any(String),
});
});
test('should register the `performance_metric` and `click` event types on creation', () => {
expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(2);
expect(analyticsClientMock.registerEventType.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"eventType": "performance_metric",
"schema": Object {
"duration": Object {
"_meta": Object {
"description": "The main event duration in ms",
},
"type": "integer",
},
"eventName": Object {
"_meta": Object {
"description": "The name of the event that is tracked in the metrics i.e. kibana_loaded, kibana_started",
},
"type": "keyword",
},
"key1": Object {
"_meta": Object {
"description": "Performance metric label 1",
"optional": true,
},
"type": "keyword",
},
"key2": Object {
"_meta": Object {
"description": "Performance metric label 2",
"optional": true,
},
"type": "keyword",
},
"key3": Object {
"_meta": Object {
"description": "Performance metric label 3",
"optional": true,
},
"type": "keyword",
},
"key4": Object {
"_meta": Object {
"description": "Performance metric label 4",
"optional": true,
},
"type": "keyword",
},
"key5": Object {
"_meta": Object {
"description": "Performance metric label 5",
"optional": true,
},
"type": "keyword",
},
"meta": Object {
"_meta": Object {
"description": "Meta data that is searchable but not aggregatable",
"optional": true,
},
"type": "pass_through",
},
"value1": Object {
"_meta": Object {
"description": "Performance metric value 1",
"optional": true,
},
"type": "long",
},
"value2": Object {
"_meta": Object {
"description": "Performance metric value 2",
"optional": true,
},
"type": "long",
},
"value3": Object {
"_meta": Object {
"description": "Performance metric value 3",
"optional": true,
},
"type": "long",
},
"value4": Object {
"_meta": Object {
"description": "Performance metric value 4",
"optional": true,
},
"type": "long",
},
"value5": Object {
"_meta": Object {
"description": "Performance metric value 5",
"optional": true,
},
"type": "long",
},
},
},
]
`);
expect(analyticsClientMock.registerEventType.mock.calls[1]).toMatchInlineSnapshot(`
Array [
Object {
"eventType": "click",
"schema": Object {
"target": Object {
"items": Object {
"_meta": Object {
"description": "The attributes of the clicked element and all its parents in the form \`{attr.name}={attr.value}\`. It allows finding the clicked elements by looking up its attributes like \\"data-test-subj=my-button\\".",
},
"type": "keyword",
},
"type": "array",
},
},
},
]
`);
});
test('setup should expose all the register APIs, reportEvent and opt-in', () => {
const injectedMetadata = injectedMetadataServiceMock.createSetupContract();
expect(analyticsService.setup({ injectedMetadata })).toStrictEqual({
@ -60,9 +180,9 @@ describe('AnalyticsService', () => {
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`);
expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$)
).toMatchInlineSnapshot(`undefined`);
});
test('setup should register the elasticsearch info context provider (with info)', async () => {
@ -73,15 +193,15 @@ describe('AnalyticsService', () => {
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",
}
`);
expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$)
).toMatchInlineSnapshot(`
Object {
"cluster_name": "cluster_name",
"cluster_uuid": "cluster_uuid",
"cluster_version": "version",
}
`);
});
test('setup should expose only the APIs report and opt-in', () => {

View file

@ -9,6 +9,7 @@
import { of } from 'rxjs';
import type { AnalyticsClient } from '@kbn/analytics-client';
import { createAnalytics } from '@kbn/analytics-client';
import { registerPerformanceMetricEventType } from '@kbn/ebt-tools';
import type { CoreContext } from '@kbn/core-base-browser-internal';
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
import type { AnalyticsServiceSetup, AnalyticsServiceStart } from '@kbn/core-analytics-browser';
@ -34,6 +35,7 @@ export class AnalyticsService {
});
this.registerBuildInfoAnalyticsContext(core);
registerPerformanceMetricEventType(this.analyticsClient);
// 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.

View file

@ -28,6 +28,7 @@ NPM_MODULE_EXTRA_FILES = [
RUNTIME_DEPS = [
"@npm//rxjs",
"//packages/analytics/client",
"//packages/kbn-ebt-tools",
]
TYPES_DEPS = [
@ -35,6 +36,7 @@ TYPES_DEPS = [
"@npm//@types/jest",
"@npm//rxjs",
"//packages/analytics/client:npm_module_types",
"//packages/kbn-ebt-tools:npm_module_types",
"//packages/core/base/core-base-server-internal:npm_module_types",
"//packages/core/analytics/core-analytics-server:npm_module_types",
]

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AnalyticsClient } from '@kbn/analytics-client';
import { Subject } from 'rxjs';
export const analyticsClientMock: jest.Mocked<AnalyticsClient> = {
optIn: jest.fn(),
reportEvent: jest.fn(),
registerEventType: jest.fn(),
registerContextProvider: jest.fn(),
removeContextProvider: jest.fn(),
registerShipper: jest.fn(),
telemetryCounter$: new Subject(),
shutdown: jest.fn(),
};
jest.doMock('@kbn/analytics-client', () => ({
createAnalytics: () => analyticsClientMock,
}));

View file

@ -0,0 +1,158 @@
/*
* 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 { mockCoreContext } from '@kbn/core-base-server-mocks';
import { analyticsClientMock } from './analytics_service.test.mocks';
import { AnalyticsService } from './analytics_service';
describe('AnalyticsService', () => {
let analyticsService: AnalyticsService;
beforeEach(() => {
jest.clearAllMocks();
analyticsService = new AnalyticsService(mockCoreContext.create());
});
test('should register the context provider `build info` on creation', async () => {
expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(1);
await expect(
await firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$)
).toMatchInlineSnapshot(`
Object {
"branch": "main",
"buildNum": 9007199254740991,
"buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"isDev": true,
"isDistributable": false,
"version": "8.5.0",
}
`);
});
test('should register the `performance_metric` event type on creation', () => {
expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(1);
expect(analyticsClientMock.registerEventType.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"eventType": "performance_metric",
"schema": Object {
"duration": Object {
"_meta": Object {
"description": "The main event duration in ms",
},
"type": "integer",
},
"eventName": Object {
"_meta": Object {
"description": "The name of the event that is tracked in the metrics i.e. kibana_loaded, kibana_started",
},
"type": "keyword",
},
"key1": Object {
"_meta": Object {
"description": "Performance metric label 1",
"optional": true,
},
"type": "keyword",
},
"key2": Object {
"_meta": Object {
"description": "Performance metric label 2",
"optional": true,
},
"type": "keyword",
},
"key3": Object {
"_meta": Object {
"description": "Performance metric label 3",
"optional": true,
},
"type": "keyword",
},
"key4": Object {
"_meta": Object {
"description": "Performance metric label 4",
"optional": true,
},
"type": "keyword",
},
"key5": Object {
"_meta": Object {
"description": "Performance metric label 5",
"optional": true,
},
"type": "keyword",
},
"meta": Object {
"_meta": Object {
"description": "Meta data that is searchable but not aggregatable",
"optional": true,
},
"type": "pass_through",
},
"value1": Object {
"_meta": Object {
"description": "Performance metric value 1",
"optional": true,
},
"type": "long",
},
"value2": Object {
"_meta": Object {
"description": "Performance metric value 2",
"optional": true,
},
"type": "long",
},
"value3": Object {
"_meta": Object {
"description": "Performance metric value 3",
"optional": true,
},
"type": "long",
},
"value4": Object {
"_meta": Object {
"description": "Performance metric value 4",
"optional": true,
},
"type": "long",
},
"value5": Object {
"_meta": Object {
"description": "Performance metric value 5",
"optional": true,
},
"type": "long",
},
},
},
]
`);
});
test('setup should expose all the register APIs, reportEvent and opt-in', () => {
expect(analyticsService.setup()).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 expose only the APIs report and opt-in', () => {
expect(analyticsService.start()).toStrictEqual({
reportEvent: expect.any(Function),
optIn: expect.any(Function),
telemetryCounter$: expect.any(Observable),
});
});
});

View file

@ -9,6 +9,7 @@
import { of } from 'rxjs';
import type { AnalyticsClient } from '@kbn/analytics-client';
import { createAnalytics } from '@kbn/analytics-client';
import { registerPerformanceMetricEventType } from '@kbn/ebt-tools';
import type { CoreContext } from '@kbn/core-base-server-internal';
import type {
AnalyticsServiceSetup,
@ -29,6 +30,7 @@ export class AnalyticsService {
});
this.registerBuildInfoAnalyticsContext(core);
registerPerformanceMetricEventType(this.analyticsClient);
}
public preboot(): AnalyticsServicePreboot {

View file

@ -0,0 +1,101 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_BASE_NAME = "kbn-ebt-tools"
PKG_REQUIRE_NAME = "@kbn/ebt-tools"
SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = ["**/*.test.*"],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
"README.md"
]
RUNTIME_DEPS = []
TYPES_DEPS = [
"//packages/analytics/client:npm_module_types",
"@npm//@types/jest",
"@npm//@types/node",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = "src",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_BASE_NAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node", ":tsc_types"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [
":%s" % PKG_BASE_NAME,
]
)
filegroup(
name = "build",
srcs = [
":npm_module",
],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [
":npm_module_types",
],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,3 @@
# @kbn/ebt-tools
Shared tools for event based telemetry

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-ebt-tools'],
};

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/ebt-tools",
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0",
"browser": "./target_web/index.js",
"main": "./target_node/index.js",
"private": true
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './performance_metric_events';

View file

@ -0,0 +1,98 @@
/*
* 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 { createAnalytics, type AnalyticsClient } from '@kbn/analytics-client';
import { loggerMock } from '@kbn/logging-mocks';
import { registerPerformanceMetricEventType, reportPerformanceMetricEvent } from './helpers';
import { METRIC_EVENT_SCHEMA } from './schema';
describe('performance metric event helpers', () => {
let analyticsClient: AnalyticsClient;
describe('registerPerformanceMetricEventType', () => {
beforeEach(() => {
analyticsClient = createAnalytics({
isDev: true, // Explicitly setting `true` to ensure we have event validation to make sure the events sent pass our validation.
sendTo: 'staging',
logger: loggerMock.create(),
});
});
test('registers the `performance_metric` eventType to the analytics client', () => {
const registerEventTypeSpy = jest.spyOn(analyticsClient, 'registerEventType');
expect(() => registerPerformanceMetricEventType(analyticsClient)).not.toThrow();
expect(registerEventTypeSpy).toHaveBeenCalledWith({
eventType: 'performance_metric',
schema: METRIC_EVENT_SCHEMA,
});
});
});
describe('reportPerformanceMetricEvent', () => {
beforeEach(() => {
analyticsClient = createAnalytics({
isDev: true, // Explicitly setting `true` to ensure we have event validation to make sure the events sent pass our validation.
sendTo: 'staging',
logger: loggerMock.create(),
});
registerPerformanceMetricEventType(analyticsClient);
});
test('reports the minimum allowed event', () => {
reportPerformanceMetricEvent(analyticsClient, { eventName: 'test-event', duration: 1000 });
});
test('reports all the allowed fields in the event', () => {
reportPerformanceMetricEvent(analyticsClient, {
eventName: 'test-event',
meta: { my: { custom: { fields: 'here' } }, another_field: true, status: 'something' },
duration: 10,
key1: 'something',
value1: 10,
key2: 'something',
value2: 10,
key3: 'something',
value3: 10,
key4: 'something',
value4: 10,
key5: 'something',
value5: 10,
});
});
test('should fail if eventName and duration is missing', () => {
expect(() =>
reportPerformanceMetricEvent(
analyticsClient,
// @ts-expect-error
{}
)
).toThrowErrorMatchingInlineSnapshot(`
"Failed to validate payload coming from \\"Event Type 'performance_metric'\\":
- [eventName]: {\\"expected\\":\\"string\\",\\"actual\\":\\"undefined\\",\\"value\\":\\"undefined\\"}
- [duration]: {\\"expected\\":\\"number\\",\\"actual\\":\\"undefined\\",\\"value\\":\\"undefined\\"}"
`);
});
test('should fail if any additional unknown keys are added', () => {
expect(() =>
reportPerformanceMetricEvent(analyticsClient, {
eventName: 'test-event',
duration: 1000,
// @ts-expect-error
an_unknown_field: 'blah',
})
).toThrowErrorMatchingInlineSnapshot(`
"Failed to validate payload coming from \\"Event Type 'performance_metric'\\":
- []: excess key 'an_unknown_field' found"
`);
});
});
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { AnalyticsClient } from '@kbn/analytics-client';
import { type PerformanceMetricEvent, METRIC_EVENT_SCHEMA } from './schema';
const PERFORMANCE_METRIC_EVENT_TYPE = 'performance_metric';
/**
* Register the `performance_metric` event type
* @param analytics The {@link AnalyticsClient} during the setup phase (it has the method `registerEventType`)
* @private To be called only by core's Analytics Service
*/
export function registerPerformanceMetricEventType(
analytics: Pick<AnalyticsClient, 'registerEventType'>
) {
analytics.registerEventType<PerformanceMetricEvent>({
eventType: PERFORMANCE_METRIC_EVENT_TYPE,
schema: METRIC_EVENT_SCHEMA,
});
}
/**
* Report a `performance_metric` event type.
* @param analytics The {@link AnalyticsClient} to report the events.
* @param eventData The data to send, conforming the structure of a {@link MetricEvent}.
*/
export function reportPerformanceMetricEvent(
analytics: Pick<AnalyticsClient, 'reportEvent'>,
eventData: PerformanceMetricEvent
) {
analytics.reportEvent(PERFORMANCE_METRIC_EVENT_TYPE, eventData);
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { PerformanceMetricEvent as MetricEvent } from './schema';
export {
registerPerformanceMetricEventType as registerPerformanceMetricEventType,
reportPerformanceMetricEvent,
} from './helpers';

View file

@ -0,0 +1,138 @@
/*
* 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 { RootSchema } from '@kbn/analytics-client';
/**
* Structure of the `metric` event
*/
export interface PerformanceMetricEvent {
/**
* The name of the event that is tracked in the metrics i.e. kibana_loaded, kibana_started
*/
eventName: string;
/**
* Searchable but not aggregateable metadata relevant to the tracked action.
*/
meta?: Record<string, unknown>;
/**
* @group Standardized fields
* The time (in milliseconds) it took to run the entire action.
*/
duration: number;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Description label for the metric 1
*/
key1?: string;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Value for the metric 1
*/
value1?: number;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Description label for the metric 2
*/
key2?: string;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Value for the metric 2
*/
value2?: number;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Description label for the metric 3
*/
key3?: string;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Value for the metric 3
*/
value3?: number;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Description label for the metric 4
*/
key4?: string;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Value for the metric 4
*/
value4?: number;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Description label for the metric 5
*/
key5?: string;
/**
* @group Free fields for custom metrics (searchable and aggregateable)
* Value for the metric 5
*/
value5?: number;
}
export const METRIC_EVENT_SCHEMA: RootSchema<PerformanceMetricEvent> = {
eventName: {
type: 'keyword',
_meta: {
description:
'The name of the event that is tracked in the metrics i.e. kibana_loaded, kibana_started',
},
},
meta: {
type: 'pass_through',
_meta: { description: 'Meta data that is searchable but not aggregatable', optional: true },
},
duration: {
type: 'integer',
_meta: { description: 'The main event duration in ms' },
},
key1: {
type: 'keyword',
_meta: { description: 'Performance metric label 1', optional: true },
},
value1: {
type: 'long',
_meta: { description: 'Performance metric value 1', optional: true },
},
key2: {
type: 'keyword',
_meta: { description: 'Performance metric label 2', optional: true },
},
value2: {
type: 'long',
_meta: { description: 'Performance metric value 2', optional: true },
},
key3: {
type: 'keyword',
_meta: { description: 'Performance metric label 3', optional: true },
},
value3: {
type: 'long',
_meta: { description: 'Performance metric value 3', optional: true },
},
key4: {
type: 'keyword',
_meta: { description: 'Performance metric label 4', optional: true },
},
value4: {
type: 'long',
_meta: { description: 'Performance metric value 4', optional: true },
},
key5: {
type: 'keyword',
_meta: { description: 'Performance metric label 5', optional: true },
},
value5: {
type: 'long',
_meta: { description: 'Performance metric value 5', optional: true },
},
};

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"types": [
"jest",
"node"
]
},
"include": [
"src/**/*"
]
}

View file

@ -41,6 +41,15 @@ import {
} from './core_system.test.mocks';
import { CoreSystem } from './core_system';
import {
KIBANA_LOADED_EVENT,
LOAD_START,
LOAD_BOOTSTRAP_START,
LOAD_CORE_CREATED,
LOAD_FIRST_NAV,
LOAD_SETUP_DONE,
LOAD_START_DONE,
} from './events';
jest.spyOn(CoreSystem.prototype, 'stop');
@ -75,12 +84,28 @@ beforeEach(() => {
window.performance.clearMarks = jest.fn();
window.performance.getEntriesByName = jest.fn().mockReturnValue([
{
detail: 'load_started',
startTime: 456,
detail: LOAD_START,
startTime: 111,
},
{
detail: 'bootstrap_started',
startTime: 123,
detail: LOAD_BOOTSTRAP_START,
startTime: 222,
},
{
detail: LOAD_CORE_CREATED,
startTime: 333,
},
{
detail: LOAD_SETUP_DONE,
startTime: 444,
},
{
detail: LOAD_START_DONE,
startTime: 555,
},
{
detail: LOAD_FIRST_NAV,
startTime: 666,
},
]);
});
@ -248,38 +273,71 @@ describe('#start()', () => {
);
});
it('reports the event Loaded Kibana and clears marks', async () => {
it('reports the deprecated event Loaded Kibana', async () => {
await startCore();
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', {
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(2);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenNthCalledWith(1, 'Loaded Kibana', {
kibana_version: '1.2.3',
load_started: 456,
bootstrap_started: 123,
protocol: 'http:',
});
expect(window.performance.clearMarks).toHaveBeenCalledTimes(1);
});
it('reports the event Loaded Kibana (with memory)', async () => {
fetchOptionalMemoryInfoMock.mockReturnValue({
load_started: 456,
bootstrap_started: 123,
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
it('reports the metric event kibana-loaded and clears marks', async () => {
await startCore();
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(2);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenNthCalledWith(2, 'performance_metric', {
eventName: KIBANA_LOADED_EVENT,
meta: {
kibana_version: '1.2.3',
protocol: 'http:',
},
key1: LOAD_START,
key2: LOAD_BOOTSTRAP_START,
key3: LOAD_CORE_CREATED,
key4: LOAD_SETUP_DONE,
key5: LOAD_START_DONE,
value1: 111,
value2: 222,
value3: 333,
value4: 444,
value5: 555,
duration: 666,
});
expect(window.performance.clearMarks).toHaveBeenCalledTimes(1);
});
it('reports the event kibana-loaded (with memory)', async () => {
const performanceMemory = {
usedJSHeapSize: 1,
jsHeapSizeLimit: 3,
totalJSHeapSize: 4,
};
fetchOptionalMemoryInfoMock.mockReturnValue(performanceMemory);
await startCore();
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', {
load_started: 456,
bootstrap_started: 123,
kibana_version: '1.2.3',
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
protocol: 'http:',
expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(2);
expect(analyticsServiceStartMock.reportEvent).toHaveBeenNthCalledWith(2, 'performance_metric', {
eventName: KIBANA_LOADED_EVENT,
meta: {
kibana_version: '1.2.3',
protocol: 'http:',
...performanceMemory,
},
key1: LOAD_START,
key2: LOAD_BOOTSTRAP_START,
key3: LOAD_CORE_CREATED,
key4: LOAD_SETUP_DONE,
key5: LOAD_START_DONE,
value1: 111,
value2: 222,
value3: 333,
value4: 444,
value5: 555,
duration: 666,
});
});

View file

@ -26,9 +26,11 @@ import { HttpService } from '@kbn/core-http-browser-internal';
import { UiSettingsService } from '@kbn/core-ui-settings-browser-internal';
import { DeprecationsService } from '@kbn/core-deprecations-browser-internal';
import { IntegrationsService } from '@kbn/core-integrations-browser-internal';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { OverlayService } from '@kbn/core-overlays-browser-internal';
import { KBN_LOAD_MARKS } from '@kbn/core-mount-utils-browser-internal';
import { NotificationsService } from '@kbn/core-notifications-browser-internal';
import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info';
import { CoreSetup, CoreStart } from '.';
import { ChromeService } from './chrome';
import { PluginsService } from './plugins';
@ -37,7 +39,16 @@ import { RenderingService } from './rendering';
import { SavedObjectsService } from './saved_objects';
import { CoreApp } from './core_app';
import type { InternalApplicationSetup, InternalApplicationStart } from './application/types';
import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info';
import {
LOAD_SETUP_DONE,
LOAD_START_DONE,
KIBANA_LOADED_EVENT,
LOAD_CORE_CREATED,
LOAD_FIRST_NAV,
LOAD_BOOTSTRAP_START,
LOAD_START,
} from './events';
interface Params {
rootDomElement: HTMLElement;
@ -129,12 +140,12 @@ export class CoreSystem {
this.coreApp = new CoreApp(this.coreContext);
performance.mark(KBN_LOAD_MARKS, {
detail: 'core_created',
detail: LOAD_CORE_CREATED,
});
}
private getLoadMarksInfo() {
if (!performance) return [];
private getLoadMarksInfo(): Record<string, number> {
if (!performance) return {};
const reportData: Record<string, number> = {};
const marks = performance.getEntriesByName(KBN_LOAD_MARKS);
for (const mark of marks) {
@ -145,11 +156,33 @@ export class CoreSystem {
}
private reportKibanaLoadedEvent(analytics: AnalyticsServiceStart) {
/**
* @deprecated here for backwards compatibility in FullStory
**/
analytics.reportEvent('Loaded Kibana', {
kibana_version: this.coreContext.env.packageInfo.version,
protocol: window.location.protocol,
...fetchOptionalMemoryInfo(),
...this.getLoadMarksInfo(),
});
const timing = this.getLoadMarksInfo();
reportPerformanceMetricEvent(analytics, {
eventName: KIBANA_LOADED_EVENT,
meta: {
kibana_version: this.coreContext.env.packageInfo.version,
protocol: window.location.protocol,
...fetchOptionalMemoryInfo(),
},
duration: timing[LOAD_FIRST_NAV],
key1: LOAD_START,
value1: timing[LOAD_START],
key2: LOAD_BOOTSTRAP_START,
value2: timing[LOAD_BOOTSTRAP_START],
key3: LOAD_CORE_CREATED,
value3: timing[LOAD_CORE_CREATED],
key4: LOAD_SETUP_DONE,
value4: timing[LOAD_SETUP_DONE],
key5: LOAD_START_DONE,
value5: timing[LOAD_START_DONE],
});
performance.clearMarks(KBN_LOAD_MARKS);
}
@ -170,6 +203,7 @@ export class CoreSystem {
this.docLinks.setup();
const analytics = this.analytics.setup({ injectedMetadata });
this.registerLoadedKibanaEventType(analytics);
const executionContext = this.executionContext.setup({ analytics });
@ -200,7 +234,7 @@ export class CoreSystem {
await this.plugins.setup(core);
performance.mark(KBN_LOAD_MARKS, {
detail: 'setup_done',
detail: LOAD_SETUP_DONE,
});
return { fatalErrors: this.fatalErrorsSetup };
@ -300,13 +334,13 @@ export class CoreSystem {
});
performance.mark(KBN_LOAD_MARKS, {
detail: 'start_done',
detail: LOAD_START_DONE,
});
// Wait for the first app navigation to report Kibana Loaded
firstValueFrom(application.currentAppId$.pipe(filter(Boolean))).then(() => {
performance.mark(KBN_LOAD_MARKS, {
detail: 'first_app_nav',
detail: LOAD_FIRST_NAV,
});
this.reportKibanaLoadedEvent(analytics);
});
@ -342,6 +376,9 @@ export class CoreSystem {
this.rootDomElement.textContent = '';
}
/**
* @deprecated
*/
private registerLoadedKibanaEventType(analytics: AnalyticsServiceSetup) {
analytics.registerEventType({
eventType: 'Loaded Kibana',
@ -350,45 +387,6 @@ export class CoreSystem {
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 },
},
load_started: {
type: 'long',
_meta: { description: 'When the render template starts loading assets', optional: true },
},
bootstrap_started: {
type: 'long',
_meta: { description: 'When kbnBootstrap callback is called', optional: true },
},
core_created: {
type: 'long',
_meta: { description: 'When core system is created', optional: true },
},
setup_done: {
type: 'long',
_meta: { description: 'When core system setup is complete', optional: true },
},
start_done: {
type: 'long',
_meta: { description: 'When core system start is complete', optional: true },
},
first_app_nav: {
type: 'long',
_meta: {
description: 'When the application emits the first app navigation',
optional: true,
},
},
protocol: {
type: 'keyword',
_meta: {

19
src/core/public/events.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* 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.
*/
/** @internal */
export const KBN_LOAD_MARKS = 'kbnLoad';
export const KIBANA_LOADED_EVENT = 'kibana_loaded';
export const LOAD_START = 'load_started';
export const LOAD_BOOTSTRAP_START = 'bootstrap_started';
export const LOAD_CORE_CREATED = 'core_created';
export const LOAD_SETUP_DONE = 'setup_done';
export const LOAD_START_DONE = 'start_done';
export const LOAD_FIRST_NAV = 'first_app_nav';

View file

@ -27,9 +27,9 @@ describe('fetchOptionalMemoryInfo', () => {
},
};
expect(fetchOptionalMemoryInfo()).toEqual({
memory_js_heap_size_limit: 3,
memory_js_heap_size_total: 2,
memory_js_heap_size_used: 1,
jsHeapSizeLimit: 3,
totalJSHeapSize: 2,
usedJSHeapSize: 1,
});
});
});

View file

@ -14,15 +14,15 @@ export interface BrowserPerformanceMemoryInfo {
/**
* The maximum size of the heap, in bytes, that is available to the context.
*/
memory_js_heap_size_limit: number;
jsHeapSizeLimit: number;
/**
* The total allocated heap size, in bytes.
*/
memory_js_heap_size_total: number;
totalJSHeapSize: number;
/**
* The currently active segment of JS heap, in bytes.
*/
memory_js_heap_size_used: number;
usedJSHeapSize: number;
}
/**
@ -34,9 +34,9 @@ export function fetchOptionalMemoryInfo(): BrowserPerformanceMemoryInfo | undefi
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,
jsHeapSizeLimit: memory.jsHeapSizeLimit,
totalJSHeapSize: memory.totalJSHeapSize,
usedJSHeapSize: memory.usedJSHeapSize,
};
}
}

View file

@ -11,10 +11,12 @@ import { KBN_LOAD_MARKS } from '@kbn/core-mount-utils-browser-internal';
import { CoreSystem } from './core_system';
import { ApmSystem } from './apm_system';
import { LOAD_BOOTSTRAP_START } from './events';
/** @internal */
export async function __kbnBootstrap__() {
performance.mark(KBN_LOAD_MARKS, {
detail: 'bootstrap_started',
detail: LOAD_BOOTSTRAP_START,
});
const injectedMetadata = JSON.parse(

View file

@ -23,7 +23,8 @@ import {
} from '@kbn/core-config-server-internal';
import { NodeService, nodeConfig } from '@kbn/core-node-server-internal';
import { AnalyticsService } from '@kbn/core-analytics-server-internal';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server';
import type { AnalyticsServiceSetup, AnalyticsServiceStart } from '@kbn/core-analytics-server';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { EnvironmentService, pidConfig } from '@kbn/core-environment-server-internal';
import {
ExecutionContextService,
@ -402,7 +403,7 @@ export class Server {
startTransaction?.end();
this.uptimePerStep.start = { start: startStartUptime, end: process.uptime() };
analyticsStart.reportEvent(KIBANA_STARTED_EVENT, { uptime_per_step: this.uptimePerStep });
this.reportKibanaStartedEvents(analyticsStart);
return this.coreStart;
}
@ -464,6 +465,11 @@ export class Server {
}
}
/**
* Register the legacy KIBANA_STARTED_EVENT.
* @param analyticsSetup The {@link AnalyticsServiceSetup}
* @private
*/
private registerKibanaStartedEventType(analyticsSetup: AnalyticsServiceSetup) {
analyticsSetup.registerEventType<{ uptime_per_step: UptimeSteps }>({
eventType: KIBANA_STARTED_EVENT,
@ -551,4 +557,33 @@ export class Server {
},
});
}
/**
* Reports the new and legacy KIBANA_STARTED_EVENT.
* @param analyticsStart The {@link AnalyticsServiceStart}.
* @private
*/
private reportKibanaStartedEvents(analyticsStart: AnalyticsServiceStart) {
// Report the legacy KIBANA_STARTED_EVENT.
analyticsStart.reportEvent(KIBANA_STARTED_EVENT, { uptime_per_step: this.uptimePerStep });
const ups = this.uptimePerStep;
const toMs = (sec: number) => Math.round(sec * 1000);
// Report the metric-shaped KIBANA_STARTED_EVENT.
reportPerformanceMetricEvent(analyticsStart, {
eventName: KIBANA_STARTED_EVENT,
duration: toMs(ups.start!.end - ups.constructor!.start),
key1: 'time_to_constructor',
value1: toMs(ups.constructor!.start),
key2: 'constructor_time',
value2: toMs(ups.constructor!.end - ups.constructor!.start),
key3: 'preboot_time',
value3: toMs(ups.preboot!.end - ups.preboot!.start),
key4: 'setup_time',
value4: toMs(ups.setup!.end - ups.setup!.start),
key5: 'start_time',
value5: toMs(ups.start!.end - ups.start!.start),
});
}
}

View file

@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import uuid from 'uuid';
import { CoreStart, IUiSettingsClient, KibanaExecutionContext } from '@kbn/core/public';
import { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { Filter, TimeRange } from '@kbn/es-query';
@ -31,7 +32,7 @@ import {
} from '../../services/embeddable';
import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants';
import { createPanelState } from './panel';
import { DashboardLoadedEvent, DashboardPanelState } from './types';
import { DashboardPanelState } from './types';
import { DashboardViewport } from './viewport/dashboard_viewport';
import {
KibanaContextProvider,
@ -40,6 +41,7 @@ import {
KibanaThemeProvider,
} from '../../services/kibana_react';
import { PLACEHOLDER_EMBEDDABLE } from './placeholder';
import { DASHBOARD_LOADED_EVENT } from '../../events';
import { DashboardAppCapabilities, DashboardContainerInput } from '../../types';
import { PresentationUtilPluginStart } from '../../services/presentation_util';
import type { ScreenshotModePluginStart } from '../../services/screenshot_mode';
@ -66,6 +68,13 @@ export interface DashboardContainerServices {
analytics?: CoreStart['analytics'];
}
export interface DashboardLoadedInfo {
timeToData: number;
timeToDone: number;
numOfPanels: number;
status: string;
}
interface IndexSignature {
[key: string]: unknown;
}
@ -155,10 +164,17 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
}
}
private onDataLoaded(data: DashboardLoadedEvent) {
this.services.analytics?.reportEvent('dashboard-data-loaded', {
...data,
});
private onDataLoaded(data: DashboardLoadedInfo) {
if (this.services.analytics) {
reportPerformanceMetricEvent(this.services.analytics, {
eventName: DASHBOARD_LOADED_EVENT,
duration: data.timeToDone,
key1: 'time_to_data',
value1: data.timeToData,
key2: 'num_of_panels',
value2: data.numOfPanels,
});
}
}
protected createNewPanelState<

View file

@ -19,9 +19,13 @@ import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout
import { GridData } from '../../../../common';
import { ViewMode, EmbeddablePhaseEvent } from '../../../services/embeddable';
import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../dashboard_constants';
import { DashboardLoadedEventStatus, DashboardLoadedEvent, DashboardPanelState } from '../types';
import { DashboardLoadedEventStatus, DashboardPanelState } from '../types';
import { withKibana } from '../../../services/kibana_react';
import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container';
import {
DashboardContainer,
DashboardReactContextValue,
DashboardLoadedInfo,
} from '../dashboard_container';
import { DashboardGridItem } from './dashboard_grid_item';
let lastValidGridSize = 0;
@ -103,7 +107,7 @@ const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid);
export interface DashboardGridProps extends ReactIntl.InjectedIntlProps {
kibana: DashboardReactContextValue;
container: DashboardContainer;
onDataLoaded?: (data: DashboardLoadedEvent) => void;
onDataLoaded?: (data: DashboardLoadedInfo) => void;
}
interface State {
@ -270,7 +274,7 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
doneCount++;
if (doneCount === panelsInOrder.length) {
const doneTime = performance.now();
const data: DashboardLoadedEvent = {
const data: DashboardLoadedInfo = {
timeToData: (lastTimeToData || doneTime) - loadStartTime,
timeToDone: doneTime - loadStartTime,
numOfPanels: panelsInOrder.length,

View file

@ -10,11 +10,6 @@ export * from '../../../common/types';
export type DashboardLoadedEventStatus = 'done' | 'error';
export interface DashboardLoadedEvent {
// Time from start to when data is loaded
timeToData: number;
// Time from start until render or error
timeToDone: number;
numOfPanels: number;
export interface DashboardLoadedEventMeta {
status: DashboardLoadedEventStatus;
}

View file

@ -14,18 +14,21 @@ import {
LazyControlsCallout,
} from '@kbn/controls-plugin/public';
import { ViewMode } from '../../../services/embeddable';
import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container';
import {
DashboardContainer,
DashboardReactContextValue,
DashboardLoadedInfo,
} from '../dashboard_container';
import { DashboardGrid } from '../grid';
import { context } from '../../../services/kibana_react';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
import { withSuspense } from '../../../services/presentation_util';
import { DashboardLoadedEvent } from '../types';
export interface DashboardViewportProps {
container: DashboardContainer;
controlGroup?: ControlGroupContainer;
controlsEnabled?: boolean;
onDataLoaded?: (data: DashboardLoadedEvent) => void;
onDataLoaded?: (data: DashboardLoadedInfo) => void;
}
interface State {

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const DASHBOARD_LOADED_EVENT = 'dashboard_loaded';

View file

@ -71,7 +71,6 @@ import {
LibraryNotificationAction,
CopyToDashboardAction,
DashboardCapabilities,
DashboardLoadedEvent,
} from './application';
import { DashboardAppLocatorDefinition, DashboardAppLocator } from './locator';
import { createSavedDashboardLoader } from './saved_dashboards';
@ -139,30 +138,6 @@ export class DashboardPlugin
private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig;
private locator?: DashboardAppLocator;
private registerEvents(analytics: CoreSetup['analytics']) {
analytics.registerEventType<DashboardLoadedEvent>({
eventType: 'dashboard-data-loaded',
schema: {
timeToData: {
type: 'long',
_meta: { description: 'Time all embeddables took to load data' },
},
timeToDone: {
type: 'long',
_meta: { description: 'Time all embeddables took to load data' },
},
status: {
type: 'keyword',
_meta: { description: 'Error ok' },
},
numOfPanels: {
type: 'long',
_meta: { description: 'Number of panels loaded' },
},
},
});
}
public setup(
core: CoreSetup<DashboardStartDependencies, DashboardStart>,
{
@ -312,8 +287,6 @@ export class DashboardPlugin
},
};
this.registerEvents(core.analytics);
core.application.register(app);
urlForwarding.forwardApp(
DashboardConstants.DASHBOARDS_ID,

View file

@ -17,6 +17,7 @@ import {
timer,
toArray,
} from 'rxjs';
import { get } from 'lodash';
import type { Event } from '@kbn/analytics-client';
import type { GetEventsOptions } from './types';
@ -25,7 +26,7 @@ export async function fetchEvents(
takeNumberOfEvents: number,
options: GetEventsOptions = {}
): Promise<Event[]> {
const { eventTypes = [], withTimeoutMs, fromTimestamp } = options;
const { eventTypes = [], withTimeoutMs, fromTimestamp, filters } = options;
const filteredEvents$ = events$.pipe(
filter((event) => {
@ -39,6 +40,28 @@ export async function fetchEvents(
return new Date(event.timestamp).getTime() - new Date(fromTimestamp).getTime() > 0;
}
return true;
}),
filter((event) => {
if (filters) {
return Object.entries(filters).every(([key, comparison]) => {
const value = get(event, key);
return Object.entries(comparison).every(([operation, valueToCompare]) => {
switch (operation) {
case 'eq':
return value === valueToCompare;
case 'gte':
return value >= (valueToCompare as typeof value);
case 'gt':
return value > (valueToCompare as typeof value);
case 'lte':
return value <= (valueToCompare as typeof value);
case 'lt':
return value < (valueToCompare as typeof value);
}
});
});
}
return true;
})
);

View file

@ -8,6 +8,10 @@
import type { Event, EventType } from '@kbn/analytics-client';
export type FiltersOptions = {
[key in 'eq' | 'gte' | 'gt' | 'lte' | 'lt']?: unknown;
};
export interface GetEventsOptions {
/**
* eventTypes (optional) array of event types to return
@ -24,6 +28,18 @@ export interface GetEventsOptions {
* @remarks Useful when we need to retrieve the events after a specific action, and we don't care about anything prior to that.
*/
fromTimestamp?: string;
/**
* List of internal keys to validate in the event with the validation comparison.
* @example
* {
* filters: {
* 'properties.my_key': {
* eq: 'my expected value',
* },
* },
* }
*/
filters?: Record<string, FiltersOptions>;
}
export interface EBTHelpersContract {
@ -37,7 +53,10 @@ export interface EBTHelpersContract {
* @param takeNumberOfEvents - number of events to return
* @param options (optional) list of options to filter events or for advanced usage of the API {@link GetEventsOptions}.
*/
getEvents: (takeNumberOfEvents: number, options?: GetEventsOptions) => Promise<Event[]>;
getEvents: (
takeNumberOfEvents: number,
options?: GetEventsOptions
) => Promise<Array<Event<Record<string, unknown>>>>;
/**
* Count the number of events that match the filters.
* @param options list of options to filter the events {@link GetEventsOptions}. `withTimeoutMs` is required in this API.

View file

@ -127,5 +127,32 @@ describe('AnalyticsFTRHelpers', () => {
})
).resolves.toEqual([{ ...event, timestamp: '2022-06-10T00:00:00.000Z' }]);
});
test('should filter by `filters` when provided', async () => {
// 3 enqueued events
const events = [
{ ...event, timestamp: '2022-01-10T00:00:00.000Z' },
{ ...event, timestamp: '2022-03-10T00:00:00.000Z', properties: { my_property: 20 } },
{ ...event, timestamp: '2022-06-10T00:00:00.000Z' },
];
events.forEach((ev) => events$.next(ev));
await expect(
window.__analytics_ftr_helpers__.getEvents(1, {
eventTypes: [event.event_type],
filters: {
'properties.my_property': {
eq: 20,
gte: 20,
lte: 20,
gt: 10,
lt: 30,
},
},
})
).resolves.toEqual([
{ ...event, timestamp: '2022-03-10T00:00:00.000Z', properties: { my_property: 20 } },
]);
});
});
});

View file

@ -48,6 +48,21 @@ export class AnalyticsFTRHelpers implements Plugin {
eventTypes: schema.arrayOf(schema.string()),
withTimeoutMs: schema.maybe(schema.number()),
fromTimestamp: schema.maybe(schema.string()),
filters: schema.maybe(
schema.recordOf(
schema.string(),
schema.recordOf(
schema.oneOf([
schema.literal('eq'),
schema.literal('gte'),
schema.literal('gt'),
schema.literal('lte'),
schema.literal('lt'),
]),
schema.any()
)
)
),
}),
},
},

View file

@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const { common } = getPageObjects(['common']);
describe('Core Context Providers', () => {
let event: Event;
let event: Event<Record<string, unknown>>;
before(async () => {
await common.navigateToApp('home');
[event] = await ebtUIHelper.getEvents(1, { eventTypes: ['Loaded Kibana'] }); // Get the loaded Kibana event

View file

@ -10,6 +10,8 @@ import { GetEventsOptions } from '@kbn/analytics-ftr-helpers-plugin/common/types
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../services';
const DASHBOARD_LOADED_EVENT = 'dashboard_loaded';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const ebtUIHelper = getService('kibana_ebt_ui');
const PageObjects = getPageObjects([
@ -29,23 +31,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const getEvents = async (count: number, options?: GetEventsOptions) =>
ebtUIHelper.getEvents(count, {
eventTypes: ['dashboard-data-loaded'],
eventTypes: ['performance_metric'],
fromTimestamp,
withTimeoutMs: 1000,
filters: { 'properties.eventName': { eq: DASHBOARD_LOADED_EVENT } },
...options,
});
const checkEmitsOnce = async (options?: GetEventsOptions) => {
const events = await getEvents(Number.MAX_SAFE_INTEGER, options);
expect(events.length).to.be(1);
const event = events[0];
expect(event.event_type).to.eql('dashboard-data-loaded');
expect(event.event_type).to.eql('performance_metric');
expect(event.properties.eventName).to.eql(DASHBOARD_LOADED_EVENT);
expect(event.context.applicationId).to.be('dashboards');
expect(event.context.page).to.be('app');
expect(event.context.pageName).to.be('application:dashboards:app');
expect(event.properties.status).to.be('done');
expect(event.properties.timeToData).to.be.a('number');
expect(event.properties.timeToDone).to.be.a('number');
expect(event.properties.duration).to.be.a('number');
expect(event.properties.key1).to.eql('time_to_data');
expect(event.properties.value1).to.be.a('number');
expect(event.properties.key2).to.eql('num_of_panels');
expect(event.properties.value2).to.be.a('number');
// update fromTimestamp
fromTimestamp = event.timestamp;
@ -81,7 +86,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboards');
});
it('doesnt emit on empty dashboard', async () => {
it("doesn't emit on empty dashboard", async () => {
await PageObjects.dashboard.clickNewDashboard();
await checkDoesNotEmit();
});
@ -100,7 +105,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const event = await checkEmitsOnce();
expect(event.context.entityId).to.be('new');
expect(event.properties.numOfPanels).to.be(1);
expect(event.properties.key2).to.be('num_of_panels');
expect(event.properties.value2).to.be(1);
});
it('emits on saved search refreshed', async () => {
@ -108,7 +114,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await checkEmitsOnce();
});
it('doesnt emit when removing saved search panel', async () => {
it("doesn't emit when removing saved search panel", async () => {
await dashboardPanelActions.removePanelByTitle(SAVED_SEARCH_PANEL_TITLE);
await checkDoesNotEmit();
});
@ -126,7 +132,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await checkEmitsOnce();
});
it('doesnt emit when removing vis panel', async () => {
it("doesn't emit when removing vis panel", async () => {
await dashboardPanelActions.removePanelByTitle(VIS_PANEL_TITLE);
await checkDoesNotEmit();
});
@ -150,7 +156,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await checkEmitsOnce();
});
it('doesnt emit when removing markup panel', async () => {
it("doesn't emit when removing markup panel", async () => {
await dashboardPanelActions.removePanelByTitle(MARKDOWN_PANEL_TITLE);
await checkDoesNotEmit();
});
@ -170,7 +176,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await checkEmitsOnce();
});
it('doesnt emit when removing map panel', async () => {
it("doesn't emit when removing map panel", async () => {
await dashboardPanelActions.removePanelByTitle(MAP_PANEL_TITLE);
await checkDoesNotEmit();
});
@ -187,10 +193,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const event = await checkEmitsOnce();
expect(event.context.entityId).to.be('7adfa750-4c81-11e8-b3d7-01146121b73d');
expect(event.properties.numOfPanels).to.be(17);
expect(event.properties.timeToDone as number).to.be.greaterThan(
event.properties.timeToData as number
expect(event.properties.key1).to.be('time_to_data');
expect(event.properties.duration as number).to.be.greaterThan(
event.properties.value1 as number
);
expect(event.properties.key2).to.be('num_of_panels');
expect(event.properties.value2).to.be(17);
});
/**

View file

@ -19,36 +19,57 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await common.navigateToApp('home');
});
it('should emit the "Loaded Kibana" event', async () => {
it('should emit the legacy "Loaded Kibana"', async () => {
const [event] = await ebtUIHelper.getEvents(1, { eventTypes: ['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');
expect(event.properties).to.have.property('protocol');
expect(event.properties.protocol).to.be.a('string');
});
it('should emit the kibana_loaded event', async () => {
const [event] = await ebtUIHelper.getEvents(1, {
eventTypes: ['performance_metric'],
filters: { 'properties.eventName': { eq: 'kibana_loaded' } },
});
// New event
expect(event.event_type).to.eql('performance_metric');
expect(event.properties.eventName).to.eql('kibana_loaded');
// meta
expect(event.properties).to.have.property('meta');
const meta = event.properties.meta as Record<string, any>;
expect(meta.kibana_version).to.be.a('string');
expect(meta.protocol).to.be.a('string');
// Kibana Loaded timings
expect(event.properties).to.have.property('load_started');
expect(event.properties.load_started).to.be.a('number');
expect(event.properties).to.have.property('bootstrap_started');
expect(event.properties.bootstrap_started).to.be.a('number');
expect(event.properties).to.have.property('core_created');
expect(event.properties.core_created).to.be.a('number');
expect(event.properties).to.have.property('setup_done');
expect(event.properties.setup_done).to.be.a('number');
expect(event.properties).to.have.property('start_done');
expect(event.properties.start_done).to.be.a('number');
expect(event.properties).to.have.property('first_app_nav');
expect(event.properties.start_done).to.be.a('number');
expect(event.properties).to.have.property('duration');
expect(event.properties.duration).to.be.a('number');
expect(event.properties).to.have.property('key1', 'load_started');
expect(event.properties).to.have.property('key2', 'bootstrap_started');
expect(event.properties).to.have.property('key3', 'core_created');
expect(event.properties).to.have.property('key4', 'setup_done');
expect(event.properties).to.have.property('key5', 'start_done');
expect(event.properties.value1).to.be.a('number');
expect(event.properties.value2).to.be.a('number');
expect(event.properties.value3).to.be.a('number');
expect(event.properties.value4).to.be.a('number');
expect(event.properties.value5).to.be.a('number');
if (browser.isChromium) {
// Kibana Loaded memory
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');
expect(meta).to.have.property('jsHeapSizeLimit');
expect(meta.jsHeapSizeLimit).to.be.a('number');
expect(meta).to.have.property('totalJSHeapSize');
expect(meta.totalJSHeapSize).to.be.a('number');
expect(meta).to.have.property('usedJSHeapSize');
expect(meta.usedJSHeapSize).to.be.a('number');
}
});
});

View file

@ -15,7 +15,7 @@ export default function ({ getService }: FtrProviderContext) {
const ebtServerHelper = getService('kibana_ebt_server');
describe('Core Context Providers', () => {
let event: Event;
let event: Event<Record<string, unknown>>;
before(async () => {
let i = 2;
do {

View file

@ -14,8 +14,8 @@ export default function ({ getService }: FtrProviderContext) {
const ebtServerHelper = getService('kibana_ebt_server');
describe('core-overall_status_changed', () => {
let initialEvent: Event;
let secondEvent: Event;
let initialEvent: Event<Record<string, unknown>>;
let secondEvent: Event<Record<string, unknown>>;
before(async () => {
[initialEvent, secondEvent] = await ebtServerHelper.getEvents(2, {

View file

@ -13,7 +13,7 @@ export default function ({ getService }: FtrProviderContext) {
const ebtServerHelper = getService('kibana_ebt_server');
describe('kibana_started', () => {
it('should emit the "kibana_started" event', async () => {
it('should emit the legacy "kibana_started" event', async () => {
const [event] = await ebtServerHelper.getEvents(1, { eventTypes: ['kibana_started'] });
expect(event.event_type).to.eql('kibana_started');
const uptimePerStep = event.properties.uptime_per_step as Record<
@ -29,5 +29,25 @@ export default function ({ getService }: FtrProviderContext) {
expect(uptimePerStep.start.start).to.be.a('number');
expect(uptimePerStep.start.end).to.be.a('number');
});
it('should emit the "kibana_started" metric event', async () => {
const [event] = await ebtServerHelper.getEvents(1, {
eventTypes: ['performance_metric'],
filters: { 'properties.eventName': { eq: 'kibana_started' } },
});
expect(event.event_type).to.eql('performance_metric');
expect(event.properties.eventName).to.eql('kibana_started');
expect(event.properties.duration).to.be.a('number');
expect(event.properties.key1).to.eql('time_to_constructor');
expect(event.properties.value1).to.be.a('number');
expect(event.properties.key2).to.eql('constructor_time');
expect(event.properties.value2).to.be.a('number');
expect(event.properties.key3).to.eql('preboot_time');
expect(event.properties.value3).to.be.a('number');
expect(event.properties.key4).to.eql('setup_time');
expect(event.properties.value4).to.be.a('number');
expect(event.properties.key5).to.eql('start_time');
expect(event.properties.value5).to.be.a('number');
});
});
}

View file

@ -735,7 +735,7 @@ export class MapEmbeddable
) {
/**
* Maps emit rendered when the data is loaded, as we don't have feedback from the maps rendering library atm.
* This means that the dashboard-loaded event might be fired while a map is still rendering in some cases.
* This means that the DASHBOARD_LOADED_EVENT event might be fired while a map is still rendering in some cases.
* For more details please contact the maps team.
*/
this.updateOutput({

View file

@ -3463,6 +3463,10 @@
version "0.0.0"
uid ""
"@kbn/ebt-tools@link:bazel-bin/packages/kbn-ebt-tools":
version "0.0.0"
uid ""
"@kbn/es-archiver@link:bazel-bin/packages/kbn-es-archiver":
version "0.0.0"
uid ""
@ -7351,6 +7355,10 @@
version "0.0.0"
uid ""
"@types/kbn__ebt-tools@link:bazel-bin/packages/kbn-ebt-tools/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__es-archiver@link:bazel-bin/packages/kbn-es-archiver/npm_module_types":
version "0.0.0"
uid ""