Skip to content

Commit

Permalink
add licensed feature usage API (#63549)
Browse files Browse the repository at this point in the history
* add licensed feature usage API

* add FTR test and plugin

* jsdoc

* fix FTR test suite name

* remove clear API

* accept Date for notifyUsage
  • Loading branch information
pgayvallet committed Apr 28, 2020
1 parent 99c382f commit 2b3fade
Show file tree
Hide file tree
Showing 18 changed files with 502 additions and 13 deletions.
6 changes: 4 additions & 2 deletions src/core/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
import { of } from 'rxjs';
import { duration } from 'moment';
import { PluginInitializerContext, CoreSetup, CoreStart } from '.';
import { PluginInitializerContext, CoreSetup, CoreStart, StartServicesAccessor } from '.';
import { CspConfig } from './csp';
import { loggingServiceMock } from './logging/logging_service.mock';
import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock';
Expand Down Expand Up @@ -100,7 +100,9 @@ function pluginInitializerContextMock<T>(config: T = {} as T) {
return mock;
}

type CoreSetupMockType = MockedKeys<CoreSetup> & jest.Mocked<Pick<CoreSetup, 'getStartServices'>>;
type CoreSetupMockType = MockedKeys<CoreSetup> & {
getStartServices: jest.MockedFunction<StartServicesAccessor<any, any>>;
};

function createCoreSetupMock({
pluginStartDeps = {},
Expand Down
17 changes: 14 additions & 3 deletions x-pack/plugins/licensing/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject } from 'rxjs';
import { LicensingPluginSetup } from './types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { licenseMock } from '../common/licensing.mock';
import { featureUsageMock } from './services/feature_usage_service.mock';

const createSetupMock = () => {
const createSetupMock = (): jest.Mocked<LicensingPluginSetup> => {
const license = licenseMock.createLicense();
const mock: jest.Mocked<LicensingPluginSetup> = {
const mock = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
createLicensePoller: jest.fn(),
featureUsage: featureUsageMock.createSetup(),
};
mock.refresh.mockResolvedValue(license);
mock.createLicensePoller.mockReturnValue({
Expand All @@ -23,7 +25,16 @@ const createSetupMock = () => {
return mock;
};

const createStartMock = (): jest.Mocked<LicensingPluginStart> => {
const mock = {
featureUsage: featureUsageMock.createStart(),
};

return mock;
};

export const licensingMock = {
createSetup: createSetupMock,
createStart: createStartMock,
...licenseMock,
};
17 changes: 12 additions & 5 deletions x-pack/plugins/licensing/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import {
} from 'src/core/server';

import { ILicense, PublicLicense, PublicFeatures } from '../common/types';
import { LicensingPluginSetup } from './types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { License } from '../common/license';
import { createLicenseUpdate } from '../common/license_update';

import { ElasticsearchError, RawLicense, RawFeatures } from './types';
import { registerRoutes } from './routes';
import { FeatureUsageService } from './services';

import { LicenseConfigType } from './licensing_config';
import { createRouteHandlerContext } from './licensing_route_handler_context';
Expand Down Expand Up @@ -77,18 +78,19 @@ function sign({
* A plugin for fetching, refreshing, and receiving information about the license for the
* current Kibana instance.
*/
export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPluginStart, {}, {}> {
private stop$ = new Subject();
private readonly logger: Logger;
private readonly config$: Observable<LicenseConfigType>;
private loggingSubscription?: Subscription;
private featureUsage = new FeatureUsageService();

constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
this.config$ = this.context.config.create<LicenseConfigType>();
}

public async setup(core: CoreSetup) {
public async setup(core: CoreSetup<{}, LicensingPluginStart>) {
this.logger.debug('Setting up Licensing plugin');
const config = await this.config$.pipe(take(1)).toPromise();
const pollingFrequency = config.api_polling_frequency;
Expand All @@ -101,13 +103,14 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {

core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$));

registerRoutes(core.http.createRouter());
registerRoutes(core.http.createRouter(), core.getStartServices);
core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$));

return {
refresh,
license$,
createLicensePoller: this.createLicensePoller.bind(this),
featureUsage: this.featureUsage.setup(),
};
}

Expand Down Expand Up @@ -186,7 +189,11 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
return error.message;
}

public async start(core: CoreStart) {}
public async start(core: CoreStart) {
return {
featureUsage: this.featureUsage.start(),
};
}

public stop() {
this.stop$.next();
Expand Down
30 changes: 30 additions & 0 deletions x-pack/plugins/licensing/server/routes/feature_usage.ts
Original file line number Diff line number Diff line change
@@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { IRouter, StartServicesAccessor } from 'src/core/server';
import { LicensingPluginStart } from '../types';

export function registerFeatureUsageRoute(
router: IRouter,
getStartServices: StartServicesAccessor<{}, LicensingPluginStart>
) {
router.get(
{ path: '/api/licensing/feature_usage', validate: false },
async (context, request, response) => {
const [, , { featureUsage }] = await getStartServices();
return response.ok({
body: [...featureUsage.getLastUsages().entries()].reduce(
(res, [featureName, lastUsage]) => {
return {
...res,
[featureName]: new Date(lastUsage).toISOString(),
};
},
{}
),
});
}
);
}
11 changes: 9 additions & 2 deletions x-pack/plugins/licensing/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IRouter } from 'src/core/server';

import { IRouter, StartServicesAccessor } from 'src/core/server';
import { LicensingPluginStart } from '../types';
import { registerInfoRoute } from './info';
import { registerFeatureUsageRoute } from './feature_usage';

export function registerRoutes(router: IRouter) {
export function registerRoutes(
router: IRouter,
getStartServices: StartServicesAccessor<{}, LicensingPluginStart>
) {
registerInfoRoute(router);
registerFeatureUsageRoute(router, getStartServices);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import {
FeatureUsageService,
FeatureUsageServiceSetup,
FeatureUsageServiceStart,
} from './feature_usage_service';

const createSetupMock = (): jest.Mocked<FeatureUsageServiceSetup> => {
const mock = {
register: jest.fn(),
};

return mock;
};

const createStartMock = (): jest.Mocked<FeatureUsageServiceStart> => {
const mock = {
notifyUsage: jest.fn(),
getLastUsages: jest.fn(),
};

return mock;
};

const createServiceMock = (): jest.Mocked<PublicMethodsOf<FeatureUsageService>> => {
const mock = {
setup: jest.fn(() => createSetupMock()),
start: jest.fn(() => createStartMock()),
};

return mock;
};

export const featureUsageMock = {
create: createServiceMock,
createSetup: createSetupMock,
createStart: createStartMock,
};
116 changes: 116 additions & 0 deletions x-pack/plugins/licensing/server/services/feature_usage_service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { FeatureUsageService } from './feature_usage_service';

describe('FeatureUsageService', () => {
let service: FeatureUsageService;

beforeEach(() => {
service = new FeatureUsageService();
});

afterEach(() => {
jest.restoreAllMocks();
});

const toObj = (map: ReadonlyMap<string, any>): Record<string, any> =>
Object.fromEntries(map.entries());

describe('#setup', () => {
describe('#register', () => {
it('throws when registering the same feature twice', () => {
const setup = service.setup();
setup.register('foo');
expect(() => {
setup.register('foo');
}).toThrowErrorMatchingInlineSnapshot(`"Feature 'foo' has already been registered."`);
});
});
});

describe('#start', () => {
describe('#notifyUsage', () => {
it('allows to notify a feature usage', () => {
const setup = service.setup();
setup.register('feature');
const start = service.start();
start.notifyUsage('feature', 127001);

expect(start.getLastUsages().get('feature')).toBe(127001);
});

it('can receive a Date object', () => {
const setup = service.setup();
setup.register('feature');
const start = service.start();

const usageTime = new Date(2015, 9, 21, 17, 54, 12);
start.notifyUsage('feature', usageTime);
expect(start.getLastUsages().get('feature')).toBe(usageTime.getTime());
});

it('uses the current time when `usedAt` is unspecified', () => {
jest.spyOn(Date, 'now').mockReturnValue(42);

const setup = service.setup();
setup.register('feature');
const start = service.start();
start.notifyUsage('feature');

expect(start.getLastUsages().get('feature')).toBe(42);
});

it('throws when notifying for an unregistered feature', () => {
service.setup();
const start = service.start();
expect(() => {
start.notifyUsage('unregistered');
}).toThrowErrorMatchingInlineSnapshot(`"Feature 'unregistered' is not registered."`);
});
});

describe('#getLastUsages', () => {
it('returns the last usage for all used features', () => {
const setup = service.setup();
setup.register('featureA');
setup.register('featureB');
const start = service.start();
start.notifyUsage('featureA', 127001);
start.notifyUsage('featureB', 6666);

expect(toObj(start.getLastUsages())).toEqual({
featureA: 127001,
featureB: 6666,
});
});

it('returns the last usage even after notifying for an older usage', () => {
const setup = service.setup();
setup.register('featureA');
const start = service.start();
start.notifyUsage('featureA', 1000);
start.notifyUsage('featureA', 500);

expect(toObj(start.getLastUsages())).toEqual({
featureA: 1000,
});
});

it('does not return entries for unused registered features', () => {
const setup = service.setup();
setup.register('featureA');
setup.register('featureB');
const start = service.start();
start.notifyUsage('featureA', 127001);

expect(toObj(start.getLastUsages())).toEqual({
featureA: 127001,
});
});
});
});
});
63 changes: 63 additions & 0 deletions x-pack/plugins/licensing/server/services/feature_usage_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { isDate } from 'lodash';

/** @public */
export interface FeatureUsageServiceSetup {
/**
* Register a feature to be able to notify of it's usages using the {@link FeatureUsageServiceStart | service start contract}.
*/
register(featureName: string): void;
}

/** @public */
export interface FeatureUsageServiceStart {
/**
* Notify of a registered feature usage at given time.
*
* @param featureName - the name of the feature to notify usage of
* @param usedAt - Either a `Date` or an unix timestamp with ms. If not specified, it will be set to the current time.
*/
notifyUsage(featureName: string, usedAt?: Date | number): void;
/**
* Return a map containing last usage timestamp for all features.
* Features that were not used yet do not appear in the map.
*/
getLastUsages(): ReadonlyMap<string, number>;
}

export class FeatureUsageService {
private readonly features: string[] = [];
private readonly lastUsages = new Map<string, number>();

public setup(): FeatureUsageServiceSetup {
return {
register: featureName => {
if (this.features.includes(featureName)) {
throw new Error(`Feature '${featureName}' has already been registered.`);
}
this.features.push(featureName);
},
};
}

public start(): FeatureUsageServiceStart {
return {
notifyUsage: (featureName, usedAt = Date.now()) => {
if (!this.features.includes(featureName)) {
throw new Error(`Feature '${featureName}' is not registered.`);
}
if (isDate(usedAt)) {
usedAt = usedAt.getTime();
}
const currentValue = this.lastUsages.get(featureName) ?? 0;
this.lastUsages.set(featureName, Math.max(usedAt, currentValue));
},
getLastUsages: () => new Map(this.lastUsages.entries()),
};
}
}
Loading

0 comments on commit 2b3fade

Please sign in to comment.