-
Notifications
You must be signed in to change notification settings - Fork 8.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add licensed feature usage API #63549
Changes from all commits
962cd8b
835341f
2db26c8
3bda52e
fd753cc
6a9d940
7cf24e1
250081c
eb7fa18
6349736
3ddc32e
90fd4ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may have already been discussed but I wonder if there could be any problems with using absolute dates. If clocks are not configured correctly, it could make making sense of this data harder from the Cloud side. One option could be to return a relative date, ie. how many seconds ago it was used. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment as #63549 (comment), not sure if we can change the expected format here.
This has different drawbacks btw, such as being vulnerable to latency, and forcing to be processed on the fly or enhanced to not loose the time reference of the consumer call. Also I would be expecting cloud instances to have properly synchronized clocks between their machines/vms/cris so this is probably a non-issue? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another problem could be that a user couldn't know in what timezone the timestamp was created. Not sure if it's a critical problem. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assumed it was not an issue, as the API consumption is just going to pool calls to the API to count the actual number of usages in a given period of time. TBH I would have returned a unix timestamp instead of an ISO date representation if the elasticsearch API counterpart was not returning this format. If we want to have correct timezones, I can just change the feature usage service to store dates instead of unix timestamps, but once again, this is assuming the actual user/client and the kibana server are on the same timezone. My main difficulty on this feature is that it's very unclear who has the power of decision on that API. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think this is a safe enough assumption for its usage. There's always the potential for clock drift, but even if we're off a bit it won't have catastrophic effects. Using an ISO-8601 format for dates is 👍 , they can be easily translated to a unix timestamp. |
||
}; | ||
}, | ||
{} | ||
), | ||
}); | ||
} | ||
); | ||
} |
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, | ||
}; |
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, | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
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()), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm following @rjernst 'specifications' from #60984 (comment) here. non-used features does not appear in this API apparently. I'm unsure what liberty we have on that, as the goal seems to be mirroring ES' WIP api? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why we need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That was my first thought as well. If we don't need to return the list of all features (even if unused) then we could probably remove it. Though it does make it a bit safer that plugins can't just shove random strings into the Maybe we could get away with a union string type for the allowed strings, though that would mean plugins have to update these types in order to add new usage tracking. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There is no functional reasons for this call atm. I just added it because it felt more KP compliant for plugins to explicitly register every licensed feature they wanted to notify usages of. If we think this is useless, I can definitely remove that part.
Mixed on that. In addition to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
What prevents them from passing a random string to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing, but at least they can only notify usage of the arbitrary features that were registered. |
||
}; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I encountered (yet another) issue with the
getStartServices
mock type in tests. the mockStartServicesAccessor
is now explicitly typed asany, any
.