Skip to content
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

Adds telemetry support to alerting and actions plugins #58081

Merged
merged 16 commits into from
Mar 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion x-pack/plugins/actions/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "actions"],
"requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog"],
"optionalPlugins": ["spaces"],
"optionalPlugins": ["usageCollection", "spaces"],
"ui": false
}
8 changes: 8 additions & 0 deletions x-pack/plugins/actions/server/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import { licensingMock } from '../../licensing/server/mocks';
import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks';
import { taskManagerMock } from '../../task_manager/server/mocks';
import { eventLogMock } from '../../event_log/server/mocks';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';

describe('Actions Plugin', () => {
const usageCollectionMock: jest.Mocked<UsageCollectionSetup> = ({
makeUsageCollector: jest.fn(),
registerCollector: jest.fn(),
} as unknown) as jest.Mocked<UsageCollectionSetup>;
describe('setup()', () => {
let context: PluginInitializerContext;
let plugin: ActionsPlugin;
Expand All @@ -23,11 +28,13 @@ describe('Actions Plugin', () => {
context = coreMock.createPluginInitializerContext();
plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup();

pluginsSetup = {
taskManager: taskManagerMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
licensing: licensingMock.createSetup(),
eventLog: eventLogMock.createSetup(),
usageCollection: usageCollectionMock,
};
});

Expand Down Expand Up @@ -108,6 +115,7 @@ describe('Actions Plugin', () => {
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
licensing: licensingMock.createSetup(),
eventLog: eventLogMock.createSetup(),
usageCollection: usageCollectionMock,
};
pluginsStart = {
taskManager: taskManagerMock.createStart(),
Expand Down
24 changes: 24 additions & 0 deletions x-pack/plugins/actions/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { first, map } from 'rxjs/operators';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import {
PluginInitializerContext,
Plugin,
Expand Down Expand Up @@ -35,6 +36,7 @@ import { ActionTypeRegistry } from './action_type_registry';
import { ExecuteOptions } from './create_execute_function';
import { createExecuteFunction } from './create_execute_function';
import { registerBuiltInActionTypes } from './builtin_action_types';
import { registerActionsUsageCollector } from './usage';

import { getActionsConfigurationUtilities } from './actions_config';

Expand All @@ -49,6 +51,7 @@ import {
} from './routes';
import { LicenseState } from './lib/license_state';
import { IEventLogger, IEventLogService } from '../../event_log/server';
import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task';

const EVENT_LOG_PROVIDER = 'actions';
export const EVENT_LOG_ACTIONS = {
Expand All @@ -71,6 +74,7 @@ export interface ActionsPluginsSetup {
licensing: LicensingPluginSetup;
spaces?: SpacesPluginSetup;
eventLog: IEventLogService;
usageCollection?: UsageCollectionSetup;
}
export interface ActionsPluginsStart {
encryptedSavedObjects: EncryptedSavedObjectsPluginStart;
Expand All @@ -91,6 +95,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
private spaces?: SpacesServiceSetup;
private eventLogger?: IEventLogger;
private isESOUsingEphemeralEncryptionKey?: boolean;
private readonly telemetryLogger: Logger;

constructor(initContext: PluginInitializerContext) {
this.config = initContext.config
Expand All @@ -106,6 +111,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
.toPromise();

this.logger = initContext.logger.get('actions');
this.telemetryLogger = initContext.logger.get('telemetry');
}

public async setup(core: CoreSetup, plugins: ActionsPluginsSetup): Promise<PluginSetupContract> {
Expand Down Expand Up @@ -140,6 +146,8 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
const actionExecutor = new ActionExecutor({
isESOUsingEphemeralEncryptionKey: this.isESOUsingEphemeralEncryptionKey,
});

// get executions count
const taskRunnerFactory = new TaskRunnerFactory(actionExecutor);
const actionsConfigUtils = getActionsConfigurationUtilities(
(await this.config) as ActionsConfig
Expand All @@ -162,6 +170,20 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
actionsConfigUtils,
});

const usageCollection = plugins.usageCollection;
if (usageCollection) {
core.getStartServices().then(async ([coreStart, startPlugins]: [CoreStart, any]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh neat, didn't realized you could get start services (async) in setup!

registerActionsUsageCollector(usageCollection, startPlugins.taskManager);

initializeActionsTelemetry(
this.telemetryLogger,
plugins.taskManager,
core,
await this.kibanaIndex
);
});
}

core.http.registerRouteHandlerContext(
'actions',
this.createRouteHandlerContext(await this.kibanaIndex)
Expand Down Expand Up @@ -211,6 +233,8 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
getScopedSavedObjectsClient: core.savedObjects.getScopedClient,
});

scheduleActionsTelemetry(this.telemetryLogger, plugins.taskManager);

return {
execute: createExecuteFunction({
taskManager: plugins.taskManager,
Expand Down
145 changes: 145 additions & 0 deletions x-pack/plugins/actions/server/usage/actions_telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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 { APICaller } from 'kibana/server';

export async function getTotalCount(callCluster: APICaller, kibanaIndex: string) {
const scriptedMetric = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly surprised we can't use some existing agg here, instead of having to script it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing aggregations is designed to work on the document fields, but in case it is a child document like a relation or alert.actions list - we should use nested path and in this case we still able to aggregate only fields on the nested level. I did a deep research over documentation and didn't find any other option https://www.elastic.co/guide/en/elasticsearch/reference/7.6/search-aggregations-bucket-nested-aggregation.html. The most pain here that we don't have an actionTypeId in 'relations', but have it on alert.actions and both of this objects are the separate branches of nesting which is not accessible for each other.

scripted_metric: {
init_script: 'state.types = [:]',
map_script: `
String actionType = doc['action.actionTypeId'].value;
state.types.put(actionType, state.types.containsKey(actionType) ? state.types.get(actionType) + 1 : 1);
`,
// Combine script is executed per cluster, but we already have a key-value pair per cluster.
// Despite docs that say this is optional, this script can't be blank.
combine_script: 'return state',
// Reduce script is executed across all clusters, so we need to add up all the total from each cluster
// This also needs to account for having no data
reduce_script: `
Map result = [:];
for (Map m : states.toArray()) {
if (m !== null) {
for (String k : m.keySet()) {
result.put(k, result.containsKey(k) ? result.get(k) + m.get(k) : m.get(k));
}
}
}
return result;
`,
},
};

const searchResult = await callCluster('search', {
index: kibanaIndex,
rest_total_hits_as_int: true,
body: {
query: {
bool: {
filter: [{ term: { type: 'action' } }],
},
},
aggs: {
byActionTypeId: scriptedMetric,
},
},
});

return {
countTotal: Object.keys(searchResult.aggregations.byActionTypeId.value.types).reduce(
(total: number, key: string) =>
parseInt(searchResult.aggregations.byActionTypeId.value.types[key], 0) + total,
0
),
countByType: searchResult.aggregations.byActionTypeId.value.types,
};
}

export async function getInUseTotalCount(callCluster: APICaller, kibanaIndex: string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we could probably do all these aggs in a single search, but I doubt there's a great reason to do that, since they won't be running all that often. And it no doubt easier to deal with a small set of semi-complex searchs, rather than run a single search with a bunch of semi-complex aggs in it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thoughts, when was writing this code, but decided to keep it separately for now.

const scriptedMetric = {
scripted_metric: {
init_script: 'state.connectorIds = new HashMap(); state.total = 0;',
map_script: `
String connectorId = doc['references.id'].value;
String actionRef = doc['references.name'].value;
if (state.connectorIds[connectorId] === null) {
state.connectorIds[connectorId] = actionRef;
state.total++;
}
`,
// Combine script is executed per cluster, but we already have a key-value pair per cluster.
// Despite docs that say this is optional, this script can't be blank.
combine_script: 'return state',
// Reduce script is executed across all clusters, so we need to add up all the total from each cluster
// This also needs to account for having no data
reduce_script: `
Map connectorIds = [:];
long total = 0;
for (state in states) {
if (state !== null) {
total += state.total;
for (String k : state.connectorIds.keySet()) {
connectorIds.put(k, connectorIds.containsKey(k) ? connectorIds.get(k) + state.connectorIds.get(k) : state.connectorIds.get(k));
}
}
}
Map result = new HashMap();
result.total = total;
result.connectorIds = connectorIds;
return result;
`,
},
};

const actionResults = await callCluster('search', {
index: kibanaIndex,
rest_total_hits_as_int: true,
body: {
query: {
bool: {
filter: {
bool: {
must: {
nested: {
path: 'references',
query: {
bool: {
filter: {
bool: {
must: [
{
term: {
'references.type': 'action',
},
},
],
},
},
},
},
},
},
},
},
},
},
aggs: {
refs: {
nested: {
path: 'references',
},
aggs: {
actionRefIds: scriptedMetric,
},
},
},
},
});

return actionResults.aggregations.refs.actionRefIds.value.total;
}

// TODO: Implement executions count telemetry with eventLog, when it will write to index
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { registerActionsUsageCollector } from './actions_usage_collector';
import { taskManagerMock } from '../../../task_manager/server/task_manager.mock';

const mockTaskManagerStart = taskManagerMock.start();

beforeEach(() => jest.resetAllMocks());

describe('registerActionsUsageCollector', () => {
let usageCollectionMock: jest.Mocked<UsageCollectionSetup>;
beforeEach(() => {
usageCollectionMock = ({
makeUsageCollector: jest.fn(),
registerCollector: jest.fn(),
} as unknown) as jest.Mocked<UsageCollectionSetup>;
});

it('should call registerCollector', () => {
registerActionsUsageCollector(usageCollectionMock, mockTaskManagerStart);
expect(usageCollectionMock.registerCollector).toHaveBeenCalledTimes(1);
});

it('should call makeUsageCollector with type = actions', () => {
registerActionsUsageCollector(usageCollectionMock, mockTaskManagerStart);
expect(usageCollectionMock.makeUsageCollector).toHaveBeenCalledTimes(1);
expect(usageCollectionMock.makeUsageCollector.mock.calls[0][0].type).toBe('actions');
});
});
65 changes: 65 additions & 0 deletions x-pack/plugins/actions/server/usage/actions_usage_collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { get } from 'lodash';
import { TaskManagerStartContract } from '../../../task_manager/server';
import { ActionsUsage } from './types';

export function createActionsUsageCollector(
usageCollection: UsageCollectionSetup,
taskManager: TaskManagerStartContract
) {
return usageCollection.makeUsageCollector({
type: 'actions',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so does usageCollection take type: actions and then look for actions-telemetry types in the SO? I'm not seeing any other references to actions-telemetry in the code, other than in the SO mappings at the top, so guessing this must be how it's done.

isReady: () => true,
fetch: async (): Promise<ActionsUsage> => {
try {
const doc = await getLatestTaskState(await taskManager);
// get the accumulated state from the recurring task
const state: ActionsUsage = get(doc, 'state');

return {
...state,
};
} catch (err) {
return {
count_total: 0,
count_active_total: 0,
count_active_by_type: {},
count_by_type: {},
};
}
},
});
}

async function getLatestTaskState(taskManager: TaskManagerStartContract) {
try {
const result = await taskManager.get('Actions-actions_telemetry');
return result;
} catch (err) {
const errMessage = err && err.message ? err.message : err.toString();
/*
The usage service WILL to try to fetch from this collector before the task manager has been initialized, because the
task manager has to wait for all plugins to initialize first. It's fine to ignore it as next time around it will be
initialized (or it will throw a different type of error)
*/
if (!errMessage.includes('NotInitialized')) {
throw err;
}
}

return null;
}

export function registerActionsUsageCollector(
usageCollection: UsageCollectionSetup,
taskManager: TaskManagerStartContract
) {
const collector = createActionsUsageCollector(usageCollection, taskManager);
usageCollection.registerCollector(collector);
}
7 changes: 7 additions & 0 deletions x-pack/plugins/actions/server/usage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* 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.
*/

export { registerActionsUsageCollector } from './actions_usage_collector';
Loading