[7.x] [Telemetry] Application Usage track sub application views (#85765) (#86072)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ahmad Bamieh 2020-12-16 06:11:57 +02:00 committed by GitHub
parent 5615db0116
commit 6c1d24fc31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2929 additions and 240 deletions

View file

@ -0,0 +1,174 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Reporter } from './reporter';
import { createApplicationUsageMetric, ApplicationUsageMetric } from './metrics';
type TrackedApplication = Record<string, ApplicationUsageMetric>;
interface ApplicationKey {
appId: string;
viewId: string;
}
export class ApplicationUsageTracker {
private trackedApplicationViews: TrackedApplication = {};
private reporter: Reporter;
private currentAppId?: string;
private currentApplicationKeys: ApplicationKey[] = [];
private beforeUnloadListener?: EventListener;
private onVisiblityChangeListener?: EventListener;
constructor(reporter: Reporter) {
this.reporter = reporter;
}
private createKey(appId: string, viewId: string): ApplicationKey {
return { appId, viewId };
}
static serializeKey({ appId, viewId }: ApplicationKey): string {
return `${appId}-${viewId}`;
}
private trackApplications(appKeys: ApplicationKey[]) {
for (const { appId, viewId } of appKeys.filter(Boolean)) {
const serializedKey = ApplicationUsageTracker.serializeKey({ appId, viewId });
if (typeof this.trackedApplicationViews[serializedKey] !== 'undefined') {
continue;
}
const metric = createApplicationUsageMetric(appId, viewId);
this.trackedApplicationViews[serializedKey] = metric;
}
}
private attachListeners() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
this.beforeUnloadListener = () => {
this.flushTrackedViews();
};
this.onVisiblityChangeListener = () => {
if (document.visibilityState === 'visible') {
this.resumeTrackingAll();
} else if (document.visibilityState === 'hidden') {
this.pauseTrackingAll();
}
};
// Before leaving the page, make sure we store the current usage
window.addEventListener('beforeunload', this.beforeUnloadListener);
// Monitoring dashboards might be open in background and we are fine with that
// but we don't want to report hours if the user goes to another tab and Kibana is not shown
document.addEventListener('visibilitychange', this.onVisiblityChangeListener);
}
private detachListeners() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
if (this.beforeUnloadListener) {
window.removeEventListener('beforeunload', this.beforeUnloadListener);
}
if (this.onVisiblityChangeListener) {
document.removeEventListener('visibilitychange', this.onVisiblityChangeListener);
}
}
private sendMetricsToReporter(metrics: ApplicationUsageMetric[]) {
metrics.forEach((metric) => {
this.reporter.reportApplicationUsage(metric);
});
}
public updateViewClickCounter(viewId: string) {
if (!this.currentAppId) {
return;
}
const appKey = ApplicationUsageTracker.serializeKey({ appId: this.currentAppId, viewId });
if (this.trackedApplicationViews[appKey]) {
this.trackedApplicationViews[appKey].numberOfClicks++;
}
}
private flushTrackedViews() {
const appViewMetrics = Object.values(this.trackedApplicationViews);
this.sendMetricsToReporter(appViewMetrics);
this.trackedApplicationViews = {};
}
public start() {
this.attachListeners();
}
public stop() {
this.flushTrackedViews();
this.detachListeners();
}
public setCurrentAppId(appId: string) {
// application change, flush current views first.
this.flushTrackedViews();
this.currentAppId = appId;
}
public trackApplicationViewUsage(viewId: string): void {
if (!this.currentAppId) {
return;
}
const appKey = this.createKey(this.currentAppId, viewId);
this.trackApplications([appKey]);
}
public pauseTrackingAll() {
this.currentApplicationKeys = Object.values(
this.trackedApplicationViews
).map(({ appId, viewId }) => this.createKey(appId, viewId));
this.flushTrackedViews();
}
public resumeTrackingAll() {
this.trackApplications(this.currentApplicationKeys);
this.currentApplicationKeys = [];
// We also want to send the report now because intervals and timeouts be stalled when too long in the "hidden" state
// Note: it might be better to create a separate listener in the reporter for this.
this.reporter.sendReports();
}
public flushTrackedView(viewId: string) {
if (!this.currentAppId) {
return;
}
const appKey = this.createKey(this.currentAppId, viewId);
const serializedKey = ApplicationUsageTracker.serializeKey(appKey);
const appViewMetric = this.trackedApplicationViews[serializedKey];
this.sendMetricsToReporter([appViewMetric]);
delete this.trackedApplicationViews[serializedKey];
}
}

View file

@ -20,4 +20,5 @@
export { ReportHTTP, Reporter, ReporterConfig } from './reporter';
export { UiCounterMetricType, METRIC_TYPE } from './metrics';
export { Report, ReportManager } from './report';
export { ApplicationUsageTracker } from './application_usage_tracker';
export { Storage } from './storage';

View file

@ -19,38 +19,23 @@
import moment, { Moment } from 'moment-timezone';
import { METRIC_TYPE } from './';
export interface ApplicationUsageCurrent {
export interface ApplicationUsageMetric {
type: METRIC_TYPE.APPLICATION_USAGE;
appId: string;
viewId: string;
startTime: Moment;
numberOfClicks: number;
}
export class ApplicationUsage {
private currentUsage?: ApplicationUsageCurrent;
public start() {
// Count any clicks and assign it to the current app
if (window)
window.addEventListener(
'click',
() => this.currentUsage && this.currentUsage.numberOfClicks++
);
}
public appChanged(appId?: string) {
const currentUsage = this.currentUsage;
if (appId) {
this.currentUsage = {
type: METRIC_TYPE.APPLICATION_USAGE,
appId,
startTime: moment(),
numberOfClicks: 0,
};
} else {
this.currentUsage = void 0;
}
return currentUsage;
}
export function createApplicationUsageMetric(
appId: string,
viewId: string
): ApplicationUsageMetric {
return {
type: METRIC_TYPE.APPLICATION_USAGE,
appId,
viewId,
startTime: moment(),
numberOfClicks: 0,
};
}

View file

@ -19,13 +19,13 @@
import { UiCounterMetric } from './ui_counter';
import { UserAgentMetric } from './user_agent';
import { ApplicationUsageCurrent } from './application_usage';
import { ApplicationUsageMetric } from './application_usage';
export { UiCounterMetric, createUiCounterMetric, UiCounterMetricType } from './ui_counter';
export { trackUsageAgent } from './user_agent';
export { ApplicationUsage, ApplicationUsageCurrent } from './application_usage';
export { createApplicationUsageMetric, ApplicationUsageMetric } from './application_usage';
export type Metric = UiCounterMetric | UserAgentMetric | ApplicationUsageCurrent;
export type Metric = UiCounterMetric | UserAgentMetric | ApplicationUsageMetric;
export enum METRIC_TYPE {
COUNT = 'count',
LOADED = 'loaded',

View file

@ -19,8 +19,9 @@
import moment from 'moment-timezone';
import { UnreachableCaseError, wrapArray } from './util';
import { ApplicationUsageTracker } from './application_usage_tracker';
import { Metric, UiCounterMetricType, METRIC_TYPE } from './metrics';
const REPORT_VERSION = 2;
const REPORT_VERSION = 3;
export interface Report {
reportVersion: typeof REPORT_VERSION;
@ -46,6 +47,8 @@ export interface Report {
application_usage?: Record<
string,
{
appId: string;
viewId: string;
minutesOnScreen: number;
numberOfClicks: number;
}
@ -91,8 +94,10 @@ export class ReportManager {
const { appName, eventName, type } = metric;
return `${appName}-${type}-${eventName}`;
}
case METRIC_TYPE.APPLICATION_USAGE:
return metric.appId;
case METRIC_TYPE.APPLICATION_USAGE: {
const { appId, viewId } = metric;
return ApplicationUsageTracker.serializeKey({ appId, viewId });
}
default:
throw new UnreachableCaseError(metric);
}
@ -130,20 +135,25 @@ export class ReportManager {
};
return;
}
case METRIC_TYPE.APPLICATION_USAGE:
const { numberOfClicks, startTime } = metric;
case METRIC_TYPE.APPLICATION_USAGE: {
const { numberOfClicks, startTime, appId, viewId } = metric;
const minutesOnScreen = moment().diff(startTime, 'minutes', true);
report.application_usage = report.application_usage || {};
const appExistingData = report.application_usage[key] || {
minutesOnScreen: 0,
numberOfClicks: 0,
appId,
viewId,
};
report.application_usage[key] = {
...appExistingData,
minutesOnScreen: appExistingData.minutesOnScreen + minutesOnScreen,
numberOfClicks: appExistingData.numberOfClicks + numberOfClicks,
};
break;
return;
}
default:
throw new UnreachableCaseError(metric);
}

View file

@ -18,11 +18,16 @@
*/
import { wrapArray } from './util';
import { Metric, createUiCounterMetric, trackUsageAgent, UiCounterMetricType } from './metrics';
import {
Metric,
createUiCounterMetric,
trackUsageAgent,
UiCounterMetricType,
ApplicationUsageMetric,
} from './metrics';
import { Storage, ReportStorageManager } from './storage';
import { Report, ReportManager } from './report';
import { ApplicationUsage } from './metrics';
export interface ReporterConfig {
http: ReportHTTP;
@ -37,21 +42,17 @@ export type ReportHTTP = (report: Report) => Promise<void>;
export class Reporter {
checkInterval: number;
private interval?: NodeJS.Timer;
private lastAppId?: string;
private http: ReportHTTP;
private reportManager: ReportManager;
private storageManager: ReportStorageManager;
private readonly applicationUsage: ApplicationUsage;
private debug: boolean;
private retryCount = 0;
private readonly maxRetries = 3;
private started = false;
constructor(config: ReporterConfig) {
const { http, storage, debug, checkInterval = 90000, storageKey = 'analytics' } = config;
this.http = http;
this.checkInterval = checkInterval;
this.applicationUsage = new ApplicationUsage();
this.storageManager = new ReportStorageManager(storageKey, storage);
const storedReport = this.storageManager.get();
this.reportManager = new ReportManager(storedReport);
@ -76,30 +77,6 @@ export class Reporter {
this.sendReports();
}, this.checkInterval);
}
if (this.started) {
return;
}
if (window && document) {
// Before leaving the page, make sure we store the current usage
window.addEventListener('beforeunload', () => this.reportApplicationUsage());
// Monitoring dashboards might be open in background and we are fine with that
// but we don't want to report hours if the user goes to another tab and Kibana is not shown
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.lastAppId) {
this.reportApplicationUsage(this.lastAppId);
} else if (document.visibilityState === 'hidden') {
this.reportApplicationUsage();
// We also want to send the report now because intervals and timeouts be stalled when too long in the "hidden" state
this.sendReports();
}
});
}
this.started = true;
this.applicationUsage.start();
};
private log(message: any) {
@ -130,11 +107,9 @@ export class Reporter {
this.saveToReport([report]);
};
public reportApplicationUsage(appId?: string) {
this.log(`Reporting application changed to ${appId}`);
this.lastAppId = appId || this.lastAppId;
const appChangedReport = this.applicationUsage.appChanged(appId);
if (appChangedReport) this.saveToReport([appChangedReport]);
public reportApplicationUsage(appUsageReport: ApplicationUsageMetric) {
this.log(`Reporting application usage for ${appUsageReport.appId}, ${appUsageReport.viewId}`);
this.saveToReport([appUsageReport]);
}
public sendReports = async () => {

View file

@ -10,6 +10,7 @@ import { Adapters as Adapters_2 } from 'src/plugins/inspector/common';
import { ApiResponse } from '@elastic/elasticsearch';
import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch/lib/Transport';
import { ApplicationStart } from 'kibana/public';
import { ApplicationUsageTracker } from '@kbn/analytics';
import { Assign } from '@kbn/utility-types';
import { BehaviorSubject } from 'rxjs';
import { BfetchPublicSetup } from 'src/plugins/bfetch/public';

View file

@ -3,6 +3,9 @@
"version": "kibana",
"server": true,
"ui": false,
"requiredBundles": [
"usageCollection"
],
"requiredPlugins": [
"usageCollection"
],

View file

@ -1,5 +1,64 @@
# Application Usage
## Tracking Your Plugin Application
Application Usage is tracked automatically for each plugin by using the platform `currentAppId$` observer.
### Tracking sub views inside your application
To track a sub view inside your application (ie a flyout, a tab, form step, etc) Application Usage provides you with the tools to do so:
#### For a React Component
For tracking an application view rendered using react the simplest way is to wrap your component with the `TrackApplicationView` Higher order component:
kibana.json
```
{
"id": "myPlugin",
"version": "kibana",
"server": false,
"ui": true,
"optionalPlugins": ["usageCollection"],
"requiredBundles": ["usageCollection"]
}
```
Flyout component
```
import { TrackApplicationView } from 'src/plugins/usage_collection/public';
...
render() {
return (
<TrackApplicationView
viewId="myFlyout"
applicationUsageTracker={usageCollection?.applicationUsageTracker}
>
<MyFlyout />
</TrackApplicationView>
)
}
```
Application Usage will automatically track the active minutes on screen and clicks for both the application and the `MyFlyout` component whenever the component is mounted on the screen. Application Usage pauses counting screen minutes whenever the user is tabbed to another browser window.
The prop `viewId` is used as a unique identifier for your plugin. `applicationUsageTracker` can be passed directly from `usageCollection` setup or start contracts of the plugin. The Application Id is automatically attached to the tracked usage.
#### Advanced Usage
If you have a custom use case not provided by the Application Usage helpers you can use the `usageCollection.applicationUsageTracker` public api directly.
To start tracking a view: `applicationUsageTracker.trackApplicationViewUsage(viewId)`
Calling this method will marks the specified `viewId` as active. applicationUsageTracker will start tracking clicks and screen minutes for the view.
To stop tracking a view: `applicationUsageTracker.flushTrackedView(viewId)`
Calling this method will stop tracking the clicks and screen minutes for that view. Usually once the view is no longer active.
## Application Usage Telemetry Data
This collector reports the number of general clicks and minutes on screen for each registered application in Kibana.
The final payload matches the following contract:
@ -8,19 +67,37 @@ The final payload matches the following contract:
{
"application_usage": {
"application_ID": {
"clicks_7_days": 10,
"clicks_30_days": 100,
"clicks_90_days": 300,
"clicks_total": 600,
"minutes_on_screen_7_days": 10.40,
"minutes_on_screen_30_days": 20.0,
"minutes_on_screen_90_days": 110.1,
"minutes_on_screen_total": 112.5
"appId": "application_ID",
"viewId": "main",
"clicks_7_days": 10,
"clicks_30_days": 100,
"clicks_90_days": 300,
"clicks_total": 600,
"minutes_on_screen_7_days": 10.40,
"minutes_on_screen_30_days": 20.0,
"minutes_on_screen_90_days": 110.1,
"minutes_on_screen_total": 112.5,
"views": [
{
"appId": "application_ID",
"viewId": "view_ID",
"clicks_7_days": 10,
"clicks_30_days": 20,
"clicks_90_days": 100,
"clicks_total": 140,
"minutes_on_screen_7_days": 1.5,
"minutes_on_screen_30_days": 10.0,
"minutes_on_screen_90_days": 11.5,
"minutes_on_screen_total": 32.5
}
]
}
}
}
```
The view id `main` contains the total minutes on screen and clicks for the active application. The views array contains a list of all the tracked application views with the screen time and click count while the view is active.
Where `application_ID` matches the `id` registered when calling the method `core.application.register`.
This collection occurs by default for every application registered via the mentioned method and there is no need to do anything else to enable it or _opt-in_ for your plugin.
@ -31,7 +108,9 @@ This collection occurs by default for every application registered via the menti
In order to keep the count of the events, this collector uses 3 Saved Objects:
1. `application_usage_transactional`: It stores each individually reported event. Grouped by `timestamp` and `appId`. The reason for having these documents instead of editing `application_usage_daily` documents on very report is to provide faster response to the requests to `/api/ui_counters/_report` (creating new documents instead of finding and editing existing ones) and to avoid conflicts when multiple users reach to the API concurrently.
2. `application_usage_daily`: Periodically, documents from `application_usage_transactional` are aggregated to daily summaries and deleted. Also grouped by `timestamp` and `appId`.
3. `application_usage_totals`: It stores the sum of all the events older than 90 days old, grouped by `appId`.
2. `application_usage_daily`: Periodically, documents from `application_usage_transactional` are aggregated to daily summaries and deleted. Also grouped by `timestamp` and `appId` for the main view concatenated with `viewId` for other views.
3. `application_usage_totals`: It stores the sum of all the events older than 90 days old, grouped by `appId` for the main view concatenated with `viewId` for other views.
All the types use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`, but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). `application_usage_transactional` and `application_usage_daily` also store `timestamp: { type: 'date' }`.
All the types use the shared fields `appId: 'keyword'`, `viewId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`, but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). `application_usage_transactional` and `application_usage_daily` also store `timestamp: { type: 'date' }`.
Rollups uses `appId` in the savedObject id for the default view. For other views `viewId` is concatenated. This keeps backwards compatiblity with previously stored documents on the clusters without requiring any form of migration.

View file

@ -19,6 +19,7 @@
import { rollDailyData, rollTotals } from './rollups';
import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks';
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
import { SavedObjectsErrorHelpers } from '../../../../../core/server';
import {
SAVED_OBJECTS_DAILY_TYPE,
@ -80,12 +81,25 @@ describe('rollDailyData', () => {
attributes: {
appId: 'appId',
timestamp: '2020-01-01T11:31:00.000Z',
minutesOnScreen: 1.5,
minutesOnScreen: 2.5,
numberOfClicks: 2,
},
},
{
id: 'test-id-3',
type,
score: 0,
references: [],
attributes: {
appId: 'appId',
viewId: 'appId_viewId',
timestamp: '2020-01-01T11:31:00.000Z',
minutesOnScreen: 1,
numberOfClicks: 5,
},
},
],
total: 2,
total: 3,
page,
per_page: perPage,
};
@ -99,11 +113,17 @@ describe('rollDailyData', () => {
});
await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined);
expect(savedObjectClient.get).toHaveBeenCalledTimes(1);
expect(savedObjectClient.get).toHaveBeenCalledWith(
expect(savedObjectClient.get).toHaveBeenCalledTimes(2);
expect(savedObjectClient.get).toHaveBeenNthCalledWith(
1,
SAVED_OBJECTS_DAILY_TYPE,
'appId:2020-01-01'
);
expect(savedObjectClient.get).toHaveBeenNthCalledWith(
2,
SAVED_OBJECTS_DAILY_TYPE,
'appId:2020-01-01:appId_viewId'
);
expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1);
expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith(
[
@ -112,23 +132,42 @@ describe('rollDailyData', () => {
id: 'appId:2020-01-01',
attributes: {
appId: 'appId',
viewId: undefined,
timestamp: '2020-01-01T00:00:00.000Z',
minutesOnScreen: 2.0,
minutesOnScreen: 3.0,
numberOfClicks: 3,
},
},
{
type: SAVED_OBJECTS_DAILY_TYPE,
id: 'appId:2020-01-01:appId_viewId',
attributes: {
appId: 'appId',
viewId: 'appId_viewId',
timestamp: '2020-01-01T00:00:00.000Z',
minutesOnScreen: 1.0,
numberOfClicks: 5,
},
},
],
{ overwrite: true }
);
expect(savedObjectClient.delete).toHaveBeenCalledTimes(2);
expect(savedObjectClient.delete).toHaveBeenCalledWith(
expect(savedObjectClient.delete).toHaveBeenCalledTimes(3);
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
1,
SAVED_OBJECTS_TRANSACTIONAL_TYPE,
'test-id-1'
);
expect(savedObjectClient.delete).toHaveBeenCalledWith(
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
2,
SAVED_OBJECTS_TRANSACTIONAL_TYPE,
'test-id-2'
);
expect(savedObjectClient.delete).toHaveBeenNthCalledWith(
3,
SAVED_OBJECTS_TRANSACTIONAL_TYPE,
'test-id-3'
);
});
test('error getting the daily document', async () => {
@ -229,12 +268,25 @@ describe('rollTotals', () => {
attributes: {
appId: 'appId-1',
timestamp: '2020-01-01T11:31:00.000Z',
minutesOnScreen: 1.5,
minutesOnScreen: 2.5,
numberOfClicks: 2,
},
},
{
id: 'appId-1:2020-01-01:viewId-1',
type,
score: 0,
references: [],
attributes: {
appId: 'appId-1',
viewId: 'viewId-1',
timestamp: '2020-01-01T11:31:00.000Z',
minutesOnScreen: 1,
numberOfClicks: 1,
},
},
],
total: 2,
total: 3,
page,
per_page: perPage,
};
@ -252,8 +304,32 @@ describe('rollTotals', () => {
numberOfClicks: 1,
},
},
{
id: 'appId-1___viewId-1',
type,
score: 0,
references: [],
attributes: {
appId: 'appId-1',
viewId: 'viewId-1',
minutesOnScreen: 4,
numberOfClicks: 2,
},
},
{
id: 'appId-2___viewId-1',
type,
score: 0,
references: [],
attributes: {
appId: 'appId-2',
viewId: 'viewId-1',
minutesOnScreen: 1,
numberOfClicks: 1,
},
},
],
total: 1,
total: 3,
page,
per_page: perPage,
};
@ -270,15 +346,37 @@ describe('rollTotals', () => {
id: 'appId-1',
attributes: {
appId: 'appId-1',
minutesOnScreen: 2.0,
viewId: MAIN_APP_DEFAULT_VIEW_ID,
minutesOnScreen: 3.0,
numberOfClicks: 3,
},
},
{
type: SAVED_OBJECTS_TOTAL_TYPE,
id: 'appId-1___viewId-1',
attributes: {
appId: 'appId-1',
viewId: 'viewId-1',
minutesOnScreen: 5.0,
numberOfClicks: 3,
},
},
{
type: SAVED_OBJECTS_TOTAL_TYPE,
id: 'appId-2___viewId-1',
attributes: {
appId: 'appId-2',
viewId: 'viewId-1',
minutesOnScreen: 1.0,
numberOfClicks: 1,
},
},
{
type: SAVED_OBJECTS_TOTAL_TYPE,
id: 'appId-2',
attributes: {
appId: 'appId-2',
viewId: MAIN_APP_DEFAULT_VIEW_ID,
minutesOnScreen: 0.5,
numberOfClicks: 1,
},
@ -286,7 +384,7 @@ describe('rollTotals', () => {
],
{ overwrite: true }
);
expect(savedObjectClient.delete).toHaveBeenCalledTimes(2);
expect(savedObjectClient.delete).toHaveBeenCalledTimes(3);
expect(savedObjectClient.delete).toHaveBeenCalledWith(
SAVED_OBJECTS_DAILY_TYPE,
'appId-2:2020-01-01'
@ -295,5 +393,9 @@ describe('rollTotals', () => {
SAVED_OBJECTS_DAILY_TYPE,
'appId-1:2020-01-01'
);
expect(savedObjectClient.delete).toHaveBeenCalledWith(
SAVED_OBJECTS_DAILY_TYPE,
'appId-1:2020-01-01:viewId-1'
);
});
});

View file

@ -28,6 +28,7 @@ import {
SAVED_OBJECTS_TRANSACTIONAL_TYPE,
} from './saved_objects_types';
import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
/**
* For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests)
@ -37,6 +38,10 @@ type ApplicationUsageDailyWithVersion = Pick<
'version' | 'attributes'
>;
export function serializeKey(appId: string, viewId: string) {
return `${appId}___${viewId}`;
}
/**
* Aggregates all the transactional events into daily aggregates
* @param logger
@ -60,12 +65,18 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO
for (const doc of rawApplicationUsageTransactional) {
const {
attributes: { appId, minutesOnScreen, numberOfClicks, timestamp },
attributes: { appId, viewId, minutesOnScreen, numberOfClicks, timestamp },
} = doc;
const dayId = moment(timestamp).format('YYYY-MM-DD');
const dailyId = `${appId}:${dayId}`;
const dailyId =
!viewId || viewId === MAIN_APP_DEFAULT_VIEW_ID
? `${appId}:${dayId}`
: `${appId}:${dayId}:${viewId}`;
const existingDoc =
toCreate.get(dailyId) || (await getDailyDoc(savedObjectsClient, dailyId, appId, dayId));
toCreate.get(dailyId) ||
(await getDailyDoc(savedObjectsClient, dailyId, appId, viewId, dayId));
toCreate.set(dailyId, {
...existingDoc,
attributes: {
@ -103,12 +114,14 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO
* @param savedObjectsClient
* @param id The ID of the document to retrieve (typically, `${appId}:${dayId}`)
* @param appId The application ID
* @param viewId The application view ID
* @param dayId The date of the document in the format YYYY-MM-DD
*/
async function getDailyDoc(
savedObjectsClient: ISavedObjectsRepository,
id: string,
appId: string,
viewId: string,
dayId: string
): Promise<ApplicationUsageDailyWithVersion> {
try {
@ -118,6 +131,7 @@ async function getDailyDoc(
return {
attributes: {
appId,
viewId,
// Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects
timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(),
minutesOnScreen: 0,
@ -156,25 +170,41 @@ export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObje
]);
const existingTotals = rawApplicationUsageTotals.reduce(
(acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => {
(
acc,
{
attributes: { appId, viewId = MAIN_APP_DEFAULT_VIEW_ID, numberOfClicks, minutesOnScreen },
}
) => {
const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId);
return {
...acc,
// No need to sum because there should be 1 document per appId only
[appId]: { appId, numberOfClicks, minutesOnScreen },
[key]: { appId, viewId, numberOfClicks, minutesOnScreen },
};
},
{} as Record<string, { appId: string; minutesOnScreen: number; numberOfClicks: number }>
{} as Record<
string,
{ appId: string; viewId: string; minutesOnScreen: number; numberOfClicks: number }
>
);
const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => {
const { appId, numberOfClicks, minutesOnScreen } = attributes;
const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 };
const {
appId,
viewId = MAIN_APP_DEFAULT_VIEW_ID,
numberOfClicks,
minutesOnScreen,
} = attributes;
const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId);
const existing = acc[key] || { minutesOnScreen: 0, numberOfClicks: 0 };
return {
...acc,
[appId]: {
[key]: {
appId,
viewId,
numberOfClicks: numberOfClicks + existing.numberOfClicks,
minutesOnScreen: minutesOnScreen + existing.minutesOnScreen,
},

View file

@ -24,6 +24,7 @@ import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server';
*/
export interface ApplicationUsageTotal extends SavedObjectAttributes {
appId: string;
viewId: string;
minutesOnScreen: number;
numberOfClicks: number;
}

View file

@ -21,29 +21,30 @@ import { MakeSchemaFrom } from 'src/plugins/usage_collection/server';
import { ApplicationUsageTelemetryReport } from './telemetry_application_usage_collector';
const commonSchema: MakeSchemaFrom<ApplicationUsageTelemetryReport[string]> = {
clicks_total: {
type: 'long',
},
clicks_7_days: {
type: 'long',
},
clicks_30_days: {
type: 'long',
},
clicks_90_days: {
type: 'long',
},
minutes_on_screen_total: {
type: 'float',
},
minutes_on_screen_7_days: {
type: 'float',
},
minutes_on_screen_30_days: {
type: 'float',
},
minutes_on_screen_90_days: {
type: 'float',
appId: { type: 'keyword' },
viewId: { type: 'keyword' },
clicks_total: { type: 'long' },
clicks_7_days: { type: 'long' },
clicks_30_days: { type: 'long' },
clicks_90_days: { type: 'long' },
minutes_on_screen_total: { type: 'float' },
minutes_on_screen_7_days: { type: 'float' },
minutes_on_screen_30_days: { type: 'float' },
minutes_on_screen_90_days: { type: 'float' },
views: {
type: 'array',
items: {
appId: { type: 'keyword' },
viewId: { type: 'keyword' },
clicks_total: { type: 'long' },
clicks_7_days: { type: 'long' },
clicks_30_days: { type: 'long' },
clicks_90_days: { type: 'long' },
minutes_on_screen_total: { type: 'float' },
minutes_on_screen_7_days: { type: 'float' },
minutes_on_screen_30_days: { type: 'float' },
minutes_on_screen_90_days: { type: 'float' },
},
},
};

View file

@ -25,7 +25,12 @@ import {
import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks';
import { ROLL_TOTAL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants';
import { registerApplicationUsageCollector } from './telemetry_application_usage_collector';
import {
registerApplicationUsageCollector,
transformByApplicationViews,
ApplicationUsageViews,
} from './telemetry_application_usage_collector';
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
import {
SAVED_OBJECTS_DAILY_TYPE,
SAVED_OBJECTS_TOTAL_TYPE,
@ -137,6 +142,8 @@ describe('telemetry_application_usage', () => {
expect(await collector.fetch(mockedFetchContext)).toStrictEqual({
appId: {
appId: 'appId',
viewId: 'main',
clicks_total: total + 1 + 10,
clicks_7_days: total + 1,
clicks_30_days: total + 1,
@ -145,6 +152,7 @@ describe('telemetry_application_usage', () => {
minutes_on_screen_7_days: (total + 1) * 0.5,
minutes_on_screen_30_days: (total + 1) * 0.5,
minutes_on_screen_90_days: (total + 1) * 0.5,
views: [],
},
});
expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith(
@ -154,6 +162,7 @@ describe('telemetry_application_usage', () => {
type: SAVED_OBJECTS_TOTAL_TYPE,
attributes: {
appId: 'appId',
viewId: 'main',
minutesOnScreen: 10.5,
numberOfClicks: 11,
},
@ -187,6 +196,26 @@ describe('telemetry_application_usage', () => {
numberOfClicks: 1,
},
},
{
id: 'test-id-2',
attributes: {
appId: 'appId',
viewId: 'main',
timestamp: new Date(0).toISOString(),
minutesOnScreen: 2,
numberOfClicks: 2,
},
},
{
id: 'test-id-3',
attributes: {
appId: 'appId',
viewId: 'viewId-1',
timestamp: new Date(0).toISOString(),
minutesOnScreen: 1,
numberOfClicks: 1,
},
},
],
total: 1,
};
@ -197,14 +226,127 @@ describe('telemetry_application_usage', () => {
expect(await collector.fetch(mockedFetchContext)).toStrictEqual({
appId: {
clicks_total: 1,
appId: 'appId',
viewId: 'main',
clicks_total: 3,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 0.5,
minutes_on_screen_total: 2.5,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
views: [
{
appId: 'appId',
viewId: 'viewId-1',
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
],
},
});
});
});
describe('transformByApplicationViews', () => {
it(`uses '${MAIN_APP_DEFAULT_VIEW_ID}' as the top level metric`, () => {
const report: ApplicationUsageViews = {
randomId1: {
appId: 'appId1',
viewId: MAIN_APP_DEFAULT_VIEW_ID,
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
};
const result = transformByApplicationViews(report);
expect(result).toEqual({
appId1: {
appId: 'appId1',
viewId: MAIN_APP_DEFAULT_VIEW_ID,
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
views: [],
},
});
});
it('nests views under each application', () => {
const report: ApplicationUsageViews = {
randomId1: {
appId: 'appId1',
viewId: MAIN_APP_DEFAULT_VIEW_ID,
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
randomId2: {
appId: 'appId1',
viewId: 'appView1',
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
};
const result = transformByApplicationViews(report);
expect(result).toEqual({
appId1: {
appId: 'appId1',
viewId: MAIN_APP_DEFAULT_VIEW_ID,
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
views: [
{
appId: 'appId1',
viewId: 'appView1',
clicks_total: 1,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 1,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
},
],
},
});
});

View file

@ -21,6 +21,9 @@ import moment from 'moment';
import { timer } from 'rxjs';
import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants';
import { serializeKey } from './rollups';
import {
ApplicationUsageDaily,
ApplicationUsageTotal,
@ -38,8 +41,27 @@ import {
ROLL_INDICES_START,
} from './constants';
export interface ApplicationViewUsage {
appId: string;
viewId: string;
clicks_total: number;
clicks_7_days: number;
clicks_30_days: number;
clicks_90_days: number;
minutes_on_screen_total: number;
minutes_on_screen_7_days: number;
minutes_on_screen_30_days: number;
minutes_on_screen_90_days: number;
}
export interface ApplicationUsageViews {
[serializedKey: string]: ApplicationViewUsage;
}
export interface ApplicationUsageTelemetryReport {
[appId: string]: {
appId: string;
viewId: string;
clicks_total: number;
clicks_7_days: number;
clicks_30_days: number;
@ -48,9 +70,31 @@ export interface ApplicationUsageTelemetryReport {
minutes_on_screen_7_days: number;
minutes_on_screen_30_days: number;
minutes_on_screen_90_days: number;
views?: ApplicationViewUsage[];
};
}
export const transformByApplicationViews = (
report: ApplicationUsageViews
): ApplicationUsageTelemetryReport => {
const reportMetrics = Object.values(report);
const mainApplications = reportMetrics.filter(
(appView) => appView.viewId === MAIN_APP_DEFAULT_VIEW_ID
);
const appViews = reportMetrics.filter((appView) => appView.viewId !== MAIN_APP_DEFAULT_VIEW_ID);
return mainApplications.reduce((acc, mainApplication) => {
const currentAppViews = appViews.filter((appView) => appView.appId === mainApplication.appId);
acc[mainApplication.appId] = {
...mainApplication,
views: currentAppViews,
};
return acc;
}, {} as ApplicationUsageTelemetryReport);
};
export function registerApplicationUsageCollector(
logger: Logger,
usageCollection: UsageCollectionSetup,
@ -89,11 +133,23 @@ export function registerApplicationUsageCollector(
]);
const applicationUsageFromTotals = rawApplicationUsageTotals.reduce(
(acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => {
(
acc,
{
attributes: {
appId,
viewId = MAIN_APP_DEFAULT_VIEW_ID,
minutesOnScreen,
numberOfClicks,
},
}
) => {
const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 };
return {
...acc,
[appId]: {
[serializeKey(appId, viewId)]: {
appId,
viewId,
clicks_total: numberOfClicks + existing.clicks_total,
clicks_7_days: 0,
clicks_30_days: 0,
@ -114,50 +170,66 @@ export function registerApplicationUsageCollector(
const applicationUsage = [
...rawApplicationUsageDaily,
...rawApplicationUsageTransactional,
].reduce((acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => {
const existing = acc[appId] || {
clicks_total: 0,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 0,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
};
].reduce(
(
acc,
{
attributes: {
appId,
viewId = MAIN_APP_DEFAULT_VIEW_ID,
minutesOnScreen,
numberOfClicks,
timestamp,
},
}
) => {
const existing = acc[serializeKey(appId, viewId)] || {
appId,
viewId,
clicks_total: 0,
clicks_7_days: 0,
clicks_30_days: 0,
clicks_90_days: 0,
minutes_on_screen_total: 0,
minutes_on_screen_7_days: 0,
minutes_on_screen_30_days: 0,
minutes_on_screen_90_days: 0,
};
const timeOfEntry = moment(timestamp);
const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7);
const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30);
const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90);
const timeOfEntry = moment(timestamp);
const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7);
const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30);
const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90);
const last7Days = {
clicks_7_days: existing.clicks_7_days + numberOfClicks,
minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen,
};
const last30Days = {
clicks_30_days: existing.clicks_30_days + numberOfClicks,
minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen,
};
const last90Days = {
clicks_90_days: existing.clicks_90_days + numberOfClicks,
minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen,
};
const last7Days = {
clicks_7_days: existing.clicks_7_days + numberOfClicks,
minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen,
};
const last30Days = {
clicks_30_days: existing.clicks_30_days + numberOfClicks,
minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen,
};
const last90Days = {
clicks_90_days: existing.clicks_90_days + numberOfClicks,
minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen,
};
return {
...acc,
[appId]: {
...existing,
clicks_total: existing.clicks_total + numberOfClicks,
minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen,
...(isInLast7Days ? last7Days : {}),
...(isInLast30Days ? last30Days : {}),
...(isInLast90Days ? last90Days : {}),
},
};
}, applicationUsageFromTotals);
return {
...acc,
[serializeKey(appId, viewId)]: {
...existing,
clicks_total: existing.clicks_total + numberOfClicks,
minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen,
...(isInLast7Days ? last7Days : {}),
...(isInLast30Days ? last30Days : {}),
...(isInLast90Days ? last90Days : {}),
},
};
},
applicationUsageFromTotals
);
return applicationUsage;
return transformByApplicationViews(applicationUsage);
},
}
);

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@
"version": "kibana",
"server": false,
"ui": true,
"optionalPlugins": ["usageCollection"],
"requiredBundles": ["usageCollection"],
"requiredPlugins": [
"advancedSettings",
"telemetry"

View file

@ -240,6 +240,10 @@ describe('TelemetryManagementSectionComponent', () => {
it('shows the OptInSecurityExampleFlyout', () => {
const onQueryMatchChange = jest.fn();
const isSecurityExampleEnabled = jest.fn().mockReturnValue(true);
const applicationUsageTrackerMock = {
trackApplicationViewUsage: jest.fn(),
flushTrackedView: jest.fn(),
} as any;
const telemetryService = new TelemetryService({
config: {
enabled: true,
@ -258,6 +262,7 @@ describe('TelemetryManagementSectionComponent', () => {
const component = mountWithIntl(
<TelemetryManagementSection
applicationUsageTracker={applicationUsageTrackerMock}
telemetryService={telemetryService}
onQueryMatchChange={onQueryMatchChange}
showAppliesSettingMessage={false}
@ -270,15 +275,22 @@ describe('TelemetryManagementSectionComponent', () => {
const toggleExampleComponent = component.find('FormattedMessage > EuiLink[onClick]').at(1);
const updatedView = toggleExampleComponent.simulate('click');
updatedView.find('OptInSecurityExampleFlyout');
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalled();
expect(applicationUsageTrackerMock.flushTrackedView).not.toHaveBeenCalled();
updatedView.simulate('close');
} finally {
component.unmount();
expect(applicationUsageTrackerMock.flushTrackedView).toHaveBeenCalled();
}
});
it('does not show the endpoint link when isSecurityExampleEnabled returns false', () => {
const onQueryMatchChange = jest.fn();
const isSecurityExampleEnabled = jest.fn().mockReturnValue(false);
const applicationUsageTrackerMock = {
trackApplicationViewUsage: jest.fn(),
flushTrackedView: jest.fn(),
} as any;
const telemetryService = new TelemetryService({
config: {
enabled: true,
@ -310,8 +322,11 @@ describe('TelemetryManagementSectionComponent', () => {
const description = (component.instance() as TelemetryManagementSection).renderDescription();
expect(isSecurityExampleEnabled).toBeCalled();
expect(description).toMatchSnapshot();
expect(applicationUsageTrackerMock.trackApplicationViewUsage).not.toHaveBeenCalled();
expect(applicationUsageTrackerMock.flushTrackedView).not.toHaveBeenCalled();
} finally {
component.unmount();
expect(applicationUsageTrackerMock.flushTrackedView).not.toHaveBeenCalled();
}
});

View file

@ -37,6 +37,7 @@ import { OptInExampleFlyout } from './opt_in_example_flyout';
import { OptInSecurityExampleFlyout } from './opt_in_security_example_flyout';
import { LazyField } from '../../../advanced_settings/public';
import { ToastsStart } from '../../../../core/public';
import { TrackApplicationView, UsageCollectionSetup } from '../../../usage_collection/public';
type TelemetryService = TelemetryPluginSetup['telemetryService'];
@ -50,6 +51,7 @@ interface Props {
enableSaving: boolean;
query?: any;
toasts: ToastsStart;
applicationUsageTracker?: UsageCollectionSetup['applicationUsageTracker'];
}
interface State {
@ -90,7 +92,7 @@ export class TelemetryManagementSection extends Component<Props, State> {
}
render() {
const { telemetryService, isSecurityExampleEnabled } = this.props;
const { telemetryService, isSecurityExampleEnabled, applicationUsageTracker } = this.props;
const { showExample, showSecurityExample, queryMatches, enabled, processing } = this.state;
const securityExampleEnabled = isSecurityExampleEnabled();
@ -105,13 +107,23 @@ export class TelemetryManagementSection extends Component<Props, State> {
return (
<Fragment>
{showExample && (
<OptInExampleFlyout
fetchExample={telemetryService.fetchExample}
onClose={this.toggleExample}
/>
<TrackApplicationView
viewId="optInExampleFlyout"
applicationUsageTracker={applicationUsageTracker}
>
<OptInExampleFlyout
fetchExample={telemetryService.fetchExample}
onClose={this.toggleExample}
/>
</TrackApplicationView>
)}
{showSecurityExample && securityExampleEnabled && (
<OptInSecurityExampleFlyout onClose={this.toggleSecurityExample} />
<TrackApplicationView
viewId="optInSecurityExampleFlyout"
applicationUsageTracker={applicationUsageTracker}
>
<OptInSecurityExampleFlyout onClose={this.toggleSecurityExample} />
</TrackApplicationView>
)}
<EuiPanel paddingSize="l">
<EuiForm>

View file

@ -20,7 +20,7 @@
import React, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { TelemetryPluginSetup } from 'src/plugins/telemetry/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
// It should be this but the types are way too vague in the AdvancedSettings plugin `Record<string, any>`
// type Props = Omit<TelemetryManagementSection['props'], 'telemetryService'>;
type Props = any;
@ -29,13 +29,15 @@ const TelemetryManagementSectionComponent = lazy(() => import('./telemetry_manag
export function telemetryManagementSectionWrapper(
telemetryService: TelemetryPluginSetup['telemetryService'],
shouldShowSecuritySolutionUsageExample: () => boolean
shouldShowSecuritySolutionUsageExample: () => boolean,
applicationUsageTracker?: UsageCollectionSetup['applicationUsageTracker']
) {
const TelemetryManagementSectionWrapper = (props: Props) => (
<Suspense fallback={<EuiLoadingSpinner />}>
<TelemetryManagementSectionComponent
showAppliesSettingMessage={true}
telemetryService={telemetryService}
applicationUsageTracker={applicationUsageTracker}
isSecurityExampleEnabled={shouldShowSecuritySolutionUsageExample}
{...props}
/>

View file

@ -18,6 +18,7 @@
*/
import { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public';
import { TelemetryPluginSetup } from 'src/plugins/telemetry/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
import { Plugin, CoreStart, CoreSetup } from '../../../core/public';
import { telemetryManagementSectionWrapper } from './components/telemetry_management_section_wrapper';
@ -36,6 +37,7 @@ export interface TelemetryPluginConfig {
export interface TelemetryManagementSectionPluginDepsSetup {
telemetry: TelemetryPluginSetup;
advancedSettings: AdvancedSettingsSetup;
usageCollection?: UsageCollectionSetup;
}
export interface TelemetryManagementSectionPluginSetup {
@ -51,11 +53,19 @@ export class TelemetryManagementSectionPlugin
public setup(
core: CoreSetup,
{ advancedSettings, telemetry: { telemetryService } }: TelemetryManagementSectionPluginDepsSetup
{
advancedSettings,
usageCollection,
telemetry: { telemetryService },
}: TelemetryManagementSectionPluginDepsSetup
) {
advancedSettings.component.register(
advancedSettings.component.componentType.PAGE_FOOTER_COMPONENT,
telemetryManagementSectionWrapper(telemetryService, this.shouldShowSecuritySolutionExample),
telemetryManagementSectionWrapper(
telemetryService,
this.shouldShowSecuritySolutionExample,
usageCollection?.applicationUsageTracker
),
true
);

View file

@ -19,3 +19,4 @@
export const KIBANA_STATS_TYPE = 'kibana_stats';
export const DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S = 60;
export const MAIN_APP_DEFAULT_VIEW_ID = 'main';

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { TrackApplicationView } from './track_application_view';

View file

@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Component, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { UsageCollectionSetup } from '../plugin';
interface Props {
viewId: string;
applicationUsageTracker?: UsageCollectionSetup['applicationUsageTracker'];
children: ReactNode;
}
export class TrackApplicationView extends Component<Props> {
onClick = () => {
const { applicationUsageTracker, viewId } = this.props;
applicationUsageTracker?.updateViewClickCounter(viewId);
};
componentDidMount() {
const { applicationUsageTracker, viewId } = this.props;
if (applicationUsageTracker) {
applicationUsageTracker.trackApplicationViewUsage(viewId);
ReactDOM.findDOMNode(this)?.parentNode?.addEventListener('click', this.onClick);
}
}
componentWillUnmount() {
const { applicationUsageTracker, viewId } = this.props;
if (applicationUsageTracker) {
applicationUsageTracker.flushTrackedView(viewId);
ReactDOM.findDOMNode(this)?.parentNode?.removeEventListener('click', this.onClick);
}
}
render() {
return this.props.children;
}
}

View file

@ -22,6 +22,7 @@ import { UsageCollectionPlugin } from './plugin';
export { METRIC_TYPE } from '@kbn/analytics';
export { UsageCollectionSetup, UsageCollectionStart } from './plugin';
export { TrackApplicationView } from './components';
export function plugin(initializerContext: PluginInitializerContext) {
return new UsageCollectionPlugin(initializerContext);

View file

@ -17,12 +17,24 @@
* under the License.
*/
import { ApplicationUsageTracker } from '@kbn/analytics';
import { UsageCollectionSetup, METRIC_TYPE } from '.';
export type Setup = jest.Mocked<UsageCollectionSetup>;
export const createApplicationUsageTrackerMock = (): ApplicationUsageTracker => {
const applicationUsageTrackerMock: jest.Mocked<ApplicationUsageTracker> = {
setCurrentAppId: jest.fn(),
trackApplicationViewUsage: jest.fn(),
} as any;
return applicationUsageTrackerMock;
};
const createSetupContract = (): Setup => {
const applicationUsageTrackerMock = createApplicationUsageTrackerMock();
const setupContract: Setup = {
applicationUsageTracker: applicationUsageTrackerMock,
allowTrackUserAgent: jest.fn(),
reportUiCounter: jest.fn(),
METRIC_TYPE,

View file

@ -17,10 +17,10 @@
* under the License.
*/
import { Reporter, METRIC_TYPE } from '@kbn/analytics';
import { Subject, merge } from 'rxjs';
import { Reporter, METRIC_TYPE, ApplicationUsageTracker } from '@kbn/analytics';
import { Subject, merge, Subscription } from 'rxjs';
import { Storage } from '../../kibana_utils/public';
import { createReporter } from './services';
import { createReporter, trackApplicationUsageChange } from './services';
import {
PluginInitializerContext,
Plugin,
@ -28,7 +28,6 @@ import {
CoreStart,
HttpSetup,
} from '../../../core/public';
import { reportApplicationUsage } from './services/application_usage';
export interface PublicConfigType {
uiCounters: {
@ -39,6 +38,10 @@ export interface PublicConfigType {
export interface UsageCollectionSetup {
allowTrackUserAgent: (allow: boolean) => void;
applicationUsageTracker: Pick<
ApplicationUsageTracker,
'trackApplicationViewUsage' | 'flushTrackedView' | 'updateViewClickCounter'
>;
reportUiCounter: Reporter['reportUiCounter'];
METRIC_TYPE: typeof METRIC_TYPE;
__LEGACY: {
@ -55,6 +58,10 @@ export interface UsageCollectionSetup {
export interface UsageCollectionStart {
reportUiCounter: Reporter['reportUiCounter'];
METRIC_TYPE: typeof METRIC_TYPE;
applicationUsageTracker: Pick<
ApplicationUsageTracker,
'trackApplicationViewUsage' | 'flushTrackedView' | 'updateViewClickCounter'
>;
}
export function isUnauthenticated(http: HttpSetup) {
@ -64,7 +71,9 @@ export function isUnauthenticated(http: HttpSetup) {
export class UsageCollectionPlugin implements Plugin<UsageCollectionSetup, UsageCollectionStart> {
private readonly legacyAppId$ = new Subject<string>();
private applicationUsageTracker?: ApplicationUsageTracker;
private trackUserAgent: boolean = true;
private subscriptions: Subscription[] = [];
private reporter?: Reporter;
private config: PublicConfigType;
constructor(initializerContext: PluginInitializerContext) {
@ -81,7 +90,20 @@ export class UsageCollectionPlugin implements Plugin<UsageCollectionSetup, Usage
fetch: http,
});
this.applicationUsageTracker = new ApplicationUsageTracker(this.reporter);
return {
applicationUsageTracker: {
trackApplicationViewUsage: this.applicationUsageTracker.trackApplicationViewUsage.bind(
this.applicationUsageTracker
),
flushTrackedView: this.applicationUsageTracker.flushTrackedView.bind(
this.applicationUsageTracker
),
updateViewClickCounter: this.applicationUsageTracker.updateViewClickCounter.bind(
this.applicationUsageTracker
),
},
allowTrackUserAgent: (allow: boolean) => {
this.trackUserAgent = allow;
},
@ -94,25 +116,44 @@ export class UsageCollectionPlugin implements Plugin<UsageCollectionSetup, Usage
}
public start({ http, application }: CoreStart) {
if (!this.reporter) {
if (!this.reporter || !this.applicationUsageTracker) {
throw new Error('Usage collection reporter not set up correctly');
}
if (this.config.uiCounters.enabled && !isUnauthenticated(http)) {
this.reporter.start();
this.applicationUsageTracker.start();
this.subscriptions = trackApplicationUsageChange(
merge(application.currentAppId$, this.legacyAppId$),
this.applicationUsageTracker
);
}
if (this.trackUserAgent) {
this.reporter.reportUserAgent('kibana');
}
reportApplicationUsage(merge(application.currentAppId$, this.legacyAppId$), this.reporter);
return {
applicationUsageTracker: {
trackApplicationViewUsage: this.applicationUsageTracker.trackApplicationViewUsage.bind(
this.applicationUsageTracker
),
flushTrackedView: this.applicationUsageTracker.flushTrackedView.bind(
this.applicationUsageTracker
),
updateViewClickCounter: this.applicationUsageTracker.updateViewClickCounter.bind(
this.applicationUsageTracker
),
},
reportUiCounter: this.reporter.reportUiCounter,
METRIC_TYPE,
};
}
public stop() {}
public stop() {
if (this.applicationUsageTracker) {
this.applicationUsageTracker.stop();
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
}
}
}

View file

@ -17,53 +17,52 @@
* under the License.
*/
import { Reporter } from '@kbn/analytics';
import { Subject } from 'rxjs';
import { reportApplicationUsage } from './application_usage';
import { trackApplicationUsageChange } from './application_usage';
import { createApplicationUsageTrackerMock } from '../mocks';
describe('application_usage', () => {
test('report an appId change', () => {
const reporterMock: jest.Mocked<Reporter> = {
reportApplicationUsage: jest.fn(),
} as any;
const applicationUsageTrackerMock = createApplicationUsageTrackerMock();
const currentAppId$ = new Subject<string | undefined>();
reportApplicationUsage(currentAppId$, reporterMock);
trackApplicationUsageChange(currentAppId$, applicationUsageTrackerMock);
currentAppId$.next('appId');
expect(reporterMock.reportApplicationUsage).toHaveBeenCalledWith('appId');
expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(1);
expect(applicationUsageTrackerMock.setCurrentAppId).toHaveBeenCalledWith('appId');
expect(applicationUsageTrackerMock.setCurrentAppId).toHaveBeenCalledTimes(1);
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalledWith('main');
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalledTimes(1);
});
test('skip duplicates', () => {
const reporterMock: jest.Mocked<Reporter> = {
reportApplicationUsage: jest.fn(),
} as any;
const applicationUsageTrackerMock = createApplicationUsageTrackerMock();
const currentAppId$ = new Subject<string | undefined>();
reportApplicationUsage(currentAppId$, reporterMock);
trackApplicationUsageChange(currentAppId$, applicationUsageTrackerMock);
currentAppId$.next('appId');
currentAppId$.next('appId');
expect(reporterMock.reportApplicationUsage).toHaveBeenCalledWith('appId');
expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(1);
expect(applicationUsageTrackerMock.setCurrentAppId).toHaveBeenCalledWith('appId');
expect(applicationUsageTrackerMock.setCurrentAppId).toHaveBeenCalledTimes(1);
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalledWith('main');
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalledTimes(1);
});
test('skip if not a valid value', () => {
const reporterMock: jest.Mocked<Reporter> = {
reportApplicationUsage: jest.fn(),
} as any;
const applicationUsageTrackerMock = createApplicationUsageTrackerMock();
const currentAppId$ = new Subject<string | undefined>();
reportApplicationUsage(currentAppId$, reporterMock);
trackApplicationUsageChange(currentAppId$, applicationUsageTrackerMock);
currentAppId$.next('');
currentAppId$.next('kibana');
currentAppId$.next(undefined);
expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(0);
expect(applicationUsageTrackerMock.setCurrentAppId).toHaveBeenCalledTimes(0);
expect(applicationUsageTrackerMock.trackApplicationViewUsage).toHaveBeenCalledTimes(0);
});
});

View file

@ -17,23 +17,36 @@
* under the License.
*/
import { Observable } from 'rxjs';
import { Observable, fromEvent } from 'rxjs';
import { filter, distinctUntilChanged } from 'rxjs/operators';
import { Reporter } from '@kbn/analytics';
import { ApplicationUsageTracker } from '@kbn/analytics';
import { MAIN_APP_DEFAULT_VIEW_ID } from '../../common/constants';
/**
* List of appIds not to report usage from (due to legacy hacks)
*/
const DO_NOT_REPORT = ['kibana'];
export function reportApplicationUsage(
export function trackApplicationUsageChange(
currentAppId$: Observable<string | undefined>,
reporter: Reporter
applicationUsageTracker: ApplicationUsageTracker
) {
currentAppId$
const windowClickSubscrition = fromEvent(window, 'click').subscribe(() => {
applicationUsageTracker.updateViewClickCounter(MAIN_APP_DEFAULT_VIEW_ID);
});
const appIdSubscription = currentAppId$
.pipe(
filter((appId) => typeof appId === 'string' && !DO_NOT_REPORT.includes(appId)),
distinctUntilChanged()
)
.subscribe((appId) => appId && reporter.reportApplicationUsage(appId));
.subscribe((appId) => {
if (!appId) {
return;
}
applicationUsageTracker.setCurrentAppId(appId);
applicationUsageTracker.trackApplicationViewUsage(MAIN_APP_DEFAULT_VIEW_ID);
});
return [windowClickSubscrition, appIdSubscription];
}

View file

@ -18,3 +18,4 @@
*/
export { createReporter } from './create_reporter';
export { trackApplicationUsageChange } from './application_usage';

View file

@ -29,6 +29,7 @@ export {
Collector,
CollectorFetchContext,
} from './collector';
export { UsageCollectionSetup } from './plugin';
export { config } from './config';
export const plugin = (initializerContext: PluginInitializerContext) =>

View file

@ -21,7 +21,7 @@ import { schema, TypeOf } from '@kbn/config-schema';
import { METRIC_TYPE } from '@kbn/analytics';
export const reportSchema = schema.object({
reportVersion: schema.maybe(schema.oneOf([schema.literal(1), schema.literal(2)])),
reportVersion: schema.maybe(schema.oneOf([schema.literal(3)])),
userAgent: schema.maybe(
schema.recordOf(
schema.string(),
@ -55,6 +55,8 @@ export const reportSchema = schema.object({
schema.object({
minutesOnScreen: schema.number(),
numberOfClicks: schema.number(),
appId: schema.string(),
viewId: schema.string(),
})
)
),

View file

@ -30,7 +30,7 @@ describe('store_report', () => {
test('stores report for all types of data', async () => {
const savedObjectClient = savedObjectsRepositoryMock.create();
const report: ReportSchemaType = {
reportVersion: 2,
reportVersion: 3,
userAgent: {
'key-user-agent': {
key: 'test-key',
@ -57,6 +57,8 @@ describe('store_report', () => {
},
application_usage: {
appId: {
appId: 'appId',
viewId: 'appId_view',
numberOfClicks: 3,
minutesOnScreen: 10,
},
@ -97,6 +99,7 @@ describe('store_report', () => {
numberOfClicks: 3,
minutesOnScreen: 10,
appId: 'appId',
viewId: 'appId_view',
timestamp: expect.any(Date),
},
},
@ -106,7 +109,7 @@ describe('store_report', () => {
test('it should not fail if nothing to store', async () => {
const savedObjectClient = savedObjectsRepositoryMock.create();
const report: ReportSchemaType = {
reportVersion: 1,
reportVersion: 3,
userAgent: void 0,
uiCounter: void 0,
application_usage: void 0,

View file

@ -28,7 +28,7 @@ export async function storeReport(
) {
const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : [];
const userAgents = report.userAgent ? Object.entries(report.userAgent) : [];
const appUsage = report.application_usage ? Object.entries(report.application_usage) : [];
const appUsage = report.application_usage ? Object.values(report.application_usage) : [];
const momentTimestamp = moment();
const timestamp = momentTimestamp.toDate();
@ -79,9 +79,12 @@ export async function storeReport(
(async () => {
if (!appUsage.length) return [];
const { saved_objects: savedObjects } = await internalRepository.bulkCreate(
appUsage.map(([appId, metric]) => ({
appUsage.map((metric) => ({
type: 'application_usage_transactional',
attributes: { ...metric, appId, timestamp },
attributes: {
...metric,
timestamp,
},
}))
);

View file

@ -165,13 +165,14 @@ export default function ({ getService }) {
});
describe('application usage limits', () => {
function createSavedObject() {
function createSavedObject(viewId) {
return supertest
.post('/api/saved_objects/application_usage_transactional')
.send({
attributes: {
appId: 'test-app',
minutesOnScreen: 10.99,
viewId,
minutesOnScreen: 10.33,
numberOfClicks: 10,
timestamp: new Date().toISOString(),
},
@ -181,14 +182,22 @@ export default function ({ getService }) {
}
describe('basic behaviour', () => {
let savedObjectId;
before('create 1 entry', async () => {
return createSavedObject().then((id) => (savedObjectId = id));
let savedObjectIds = [];
before('create application usage entries', async () => {
savedObjectIds = await Promise.all([
createSavedObject(),
createSavedObject('appView1'),
createSavedObject(),
]);
});
after('cleanup', () => {
return supertest
.delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`)
.expect(200);
after('cleanup', async () => {
await Promise.all(
savedObjectIds.map((savedObjectId) => {
return supertest
.delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`)
.expect(200);
})
);
});
it('should return application_usage data', async () => {
@ -202,14 +211,30 @@ export default function ({ getService }) {
const stats = body[0];
expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({
'test-app': {
clicks_total: 10,
clicks_7_days: 10,
clicks_30_days: 10,
clicks_90_days: 10,
minutes_on_screen_total: 10.99,
minutes_on_screen_7_days: 10.99,
minutes_on_screen_30_days: 10.99,
minutes_on_screen_90_days: 10.99,
appId: 'test-app',
viewId: 'main',
clicks_total: 20,
clicks_7_days: 20,
clicks_30_days: 20,
clicks_90_days: 20,
minutes_on_screen_total: 20.66,
minutes_on_screen_7_days: 20.66,
minutes_on_screen_30_days: 20.66,
minutes_on_screen_90_days: 20.66,
views: [
{
appId: 'test-app',
viewId: 'appView1',
clicks_total: 10,
clicks_7_days: 10,
clicks_30_days: 10,
clicks_90_days: 10,
minutes_on_screen_total: 10.33,
minutes_on_screen_7_days: 10.33,
minutes_on_screen_30_days: 10.33,
minutes_on_screen_90_days: 10.33,
},
],
},
});
});
@ -253,6 +278,8 @@ export default function ({ getService }) {
const stats = body[0];
expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({
'test-app': {
appId: 'test-app',
viewId: 'main',
clicks_total: 10000,
clicks_7_days: 10000,
clicks_30_days: 10000,
@ -261,6 +288,7 @@ export default function ({ getService }) {
minutes_on_screen_7_days: 10000,
minutes_on_screen_30_days: 10000,
minutes_on_screen_90_days: 10000,
views: [],
},
});
});