Skip to content

Commit

Permalink
[EBT] Core Context Providers (#130785)
Browse files Browse the repository at this point in the history
  • Loading branch information
afharo authored May 6, 2022
1 parent 2c09170 commit 8539a91
Show file tree
Hide file tree
Showing 63 changed files with 2,033 additions and 448 deletions.
42 changes: 42 additions & 0 deletions packages/analytics/client/src/schema/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/analytics/client/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
25 changes: 25 additions & 0 deletions src/core/public/analytics/analytics_service.test.mocks.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 93 additions & 0 deletions src/core/public/analytics/analytics_service.test.ts
Original file line number Diff line number Diff line change
@@ -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),
});
});
});
132 changes: 131 additions & 1 deletion src/core/public/analytics/analytics_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -27,6 +30,11 @@ export type AnalyticsServiceStart = Pick<
'optIn' | 'reportEvent' | 'telemetryCounter$'
>;

/** @internal */
export interface AnalyticsServiceSetupDeps {
injectedMetadata: InjectedMetadataSetup;
}

export class AnalyticsService {
private readonly analyticsClient: AnalyticsClient;

Expand All @@ -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,
Expand All @@ -51,14 +68,127 @@ export class AnalyticsService {
telemetryCounter$: this.analyticsClient.telemetryCounter$,
};
}

public start(): AnalyticsServiceStart {
return {
optIn: this.analyticsClient.optIn,
reportEvent: this.analyticsClient.reportEvent,
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 changes: 22 additions & 0 deletions src/core/public/analytics/get_session_id.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions src/core/public/analytics/get_session_id.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 8539a91

Please sign in to comment.