diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index b2e6badae3c59..560d5dc3ecea5 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { KibanaRequest } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; @@ -18,7 +17,7 @@ import { } from '@kbn/core/server/mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; import { spacesServiceMock } from '@kbn/spaces-plugin/server/spaces_service/spaces_service.mock'; -import { ActionType as ConnectorType } from '../types'; +import { ActionType as ConnectorType, ConnectorUsageCollector } from '../types'; import { actionsAuthorizationMock, actionsMock } from '../mocks'; import { asBackgroundTaskExecutionSource, @@ -150,6 +149,10 @@ const connectorSavedObject = { references: [], }; +interface ActionUsage { + request_body_bytes: number; +} + const getBaseExecuteStartEventLogDoc = (unsecured: boolean) => { return { event: { @@ -163,6 +166,7 @@ const getBaseExecuteStartEventLogDoc = (unsecured: boolean) => { }, id: CONNECTOR_ID, name: '1', + type_id: 'test', }, ...(unsecured ? {} @@ -190,10 +194,23 @@ const getBaseExecuteStartEventLogDoc = (unsecured: boolean) => { }; }; -const getBaseExecuteEventLogDoc = (unsecured: boolean) => { +const getBaseExecuteEventLogDoc = ( + unsecured: boolean, + actionUsage: ActionUsage = { request_body_bytes: 0 } +) => { const base = getBaseExecuteStartEventLogDoc(unsecured); return { ...base, + kibana: { + ...base.kibana, + action: { + ...base.kibana.action, + execution: { + ...base.kibana.action.execution, + usage: actionUsage, + }, + }, + }, event: { ...base.event, action: 'execute', @@ -211,9 +228,12 @@ const getBaseExecuteEventLogDoc = (unsecured: boolean) => { }; }; +const mockGetRequestBodyByte = jest.spyOn(ConnectorUsageCollector.prototype, 'getRequestBodyByte'); + beforeEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); + mockGetRequestBodyByte.mockReturnValue(0); spacesMock.getSpaceId.mockReturnValue('some-namespace'); loggerMock.get.mockImplementation(() => loggerMock); const mockRealm = { name: 'default_native', type: 'native' }; @@ -237,6 +257,7 @@ describe('Action Executor', () => { const label = executeUnsecure ? 'executes unsecured' : 'executes'; test(`successfully ${label}`, async () => { + mockGetRequestBodyByte.mockReturnValue(300); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( connectorSavedObject ); @@ -280,13 +301,15 @@ describe('Action Executor', () => { }, params: { foo: true }, logger: loggerMock, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(loggerMock.debug).toBeCalledWith('executing action test:1: 1'); expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); - const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + const execDoc = getBaseExecuteEventLogDoc(executeUnsecure, { request_body_bytes: 300 }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, execStartDoc); expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, execDoc); }); @@ -353,6 +376,7 @@ describe('Action Executor', () => { params: { foo: true }, logger: loggerMock, source: executionSource.source, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(loggerMock.debug).toBeCalledWith('executing action test:1: 1'); @@ -360,6 +384,7 @@ describe('Action Executor', () => { const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { ...execStartDoc, kibana: { @@ -431,6 +456,7 @@ describe('Action Executor', () => { }, params: { foo: true }, logger: loggerMock, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(loggerMock.debug).toBeCalledWith('executing action test:preconfigured: Preconfigured'); @@ -438,6 +464,7 @@ describe('Action Executor', () => { const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { ...execStartDoc, kibana: { @@ -513,6 +540,7 @@ describe('Action Executor', () => { params: { foo: true }, logger: loggerMock, request: {}, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); } @@ -532,6 +560,7 @@ describe('Action Executor', () => { const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { ...execStartDoc, kibana: { @@ -540,6 +569,7 @@ describe('Action Executor', () => { ...execStartDoc.kibana.action, id: 'system-connector-.cases', name: 'System action: .cases', + type_id: '.cases', }, saved_objects: [ { @@ -569,6 +599,7 @@ describe('Action Executor', () => { ...execDoc.kibana.action, id: 'system-connector-.cases', name: 'System action: .cases', + type_id: '.cases', }, saved_objects: [ { @@ -890,6 +921,7 @@ describe('Action Executor', () => { }, params: { foo: true }, logger: loggerMock, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -921,6 +953,7 @@ describe('Action Executor', () => { params: { foo: true }, logger: loggerMock, request: {}, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -989,6 +1022,7 @@ describe('Action Executor', () => { }, params: { foo: true }, logger: loggerMock, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(loggerMock.debug).toBeCalledWith('executing action test:preconfigured: Preconfigured'); @@ -996,6 +1030,7 @@ describe('Action Executor', () => { const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { ...execStartDoc, kibana: { @@ -1026,6 +1061,12 @@ describe('Action Executor', () => { ...execDoc.kibana.action, id: 'preconfigured', name: 'Preconfigured', + execution: { + ...execStartDoc.kibana.action.execution, + usage: { + request_body_bytes: 0, + }, + }, }, saved_objects: [ { @@ -1074,6 +1115,7 @@ describe('Action Executor', () => { params: { foo: true }, logger: loggerMock, request: {}, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(loggerMock.debug).toBeCalledWith( @@ -1083,6 +1125,7 @@ describe('Action Executor', () => { const execStartDoc = getBaseExecuteStartEventLogDoc(executeUnsecure); const execDoc = getBaseExecuteEventLogDoc(executeUnsecure); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { ...execStartDoc, kibana: { @@ -1091,6 +1134,7 @@ describe('Action Executor', () => { ...execStartDoc.kibana.action, id: 'system-connector-.cases', name: 'System action: .cases', + type_id: '.cases', }, saved_objects: [ { @@ -1120,6 +1164,7 @@ describe('Action Executor', () => { ...execDoc.kibana.action, id: 'system-connector-.cases', name: 'System action: .cases', + type_id: '.cases', }, saved_objects: [ { @@ -1290,6 +1335,7 @@ describe('Action Executor', () => { }, params: { foo: true }, logger: loggerMock, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); } }); @@ -1385,6 +1431,7 @@ describe('Event log', () => { }, name: undefined, id: 'action1', + type_id: 'test', }, alert: { rule: { @@ -1430,6 +1477,7 @@ describe('Event log', () => { }, name: 'action-1', id: '1', + type_id: 'test', }, alert: { rule: { @@ -1483,6 +1531,7 @@ describe('Event log', () => { }, name: 'action-1', id: '1', + type_id: 'test', }, alert: { rule: { @@ -1559,9 +1608,13 @@ describe('Event log', () => { gen_ai: { usage: mockGenAi.usage, }, + usage: { + request_body_bytes: 0, + }, }, name: 'action-1', id: '1', + type_id: '.gen-ai', }, alert: { rule: { @@ -1655,9 +1708,13 @@ describe('Event log', () => { total_tokens: 35, }, }, + usage: { + request_body_bytes: 0, + }, }, name: 'action-1', id: '1', + type_id: '.gen-ai', }, alert: { rule: { diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 685e18c585ae0..c302b0da3e886 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -23,6 +23,7 @@ import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/se import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; import { GEN_AI_TOKEN_COUNT_EVENT } from './event_based_telemetry'; +import { ConnectorUsageCollector } from '../usage/connector_usage_collector'; import { getGenAiTokenTracking, shouldTrackGenAiToken } from './gen_ai_token_tracking'; import { validateConfig, @@ -293,6 +294,7 @@ export class ActionExecutor { actionExecutionId, isInMemory: this.actionInfo.isInMemory, ...(source ? { source } : {}), + actionTypeId: this.actionInfo.actionTypeId, }); eventLogger.logEvent(event); @@ -394,6 +396,14 @@ export class ActionExecutor { const { actionTypeId, name, config, secrets } = actionInfo; + const loggerId = actionTypeId.startsWith('.') ? actionTypeId.substring(1) : actionTypeId; + const logger = this.actionExecutorContext!.logger.get(loggerId); + + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: actionId, + }); + if (!this.actionInfo || this.actionInfo.actionId !== actionId) { this.actionInfo = actionInfo; } @@ -434,9 +444,6 @@ export class ActionExecutor { return err.result; } - const loggerId = actionTypeId.startsWith('.') ? actionTypeId.substring(1) : actionTypeId; - const logger = this.actionExecutorContext!.logger.get(loggerId); - if (span) { span.name = `${executeLabel} ${actionTypeId}`; span.addLabels({ @@ -477,6 +484,7 @@ export class ActionExecutor { actionExecutionId, isInMemory: this.actionInfo.isInMemory, ...(source ? { source } : {}), + actionTypeId, }); eventLogger.startTiming(event); @@ -510,6 +518,7 @@ export class ActionExecutor { logger, source, ...(actionType.isSystemActionType ? { request } : {}), + connectorUsageCollector, }); if (rawResult && rawResult.status === 'error') { @@ -548,6 +557,11 @@ export class ActionExecutor { event.user = event.user || {}; event.user.name = currentUser?.username; event.user.id = currentUser?.profile_uid; + set( + event, + 'kibana.action.execution.usage.request_body_bytes', + connectorUsageCollector.getRequestBodyByte() + ); if (result.status === 'ok') { span?.setOutcome('success'); diff --git a/x-pack/plugins/actions/server/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/lib/axios_utils.test.ts index c5e23f6cd3db3..bee09a90ed27b 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios, { AxiosInstance } from 'axios'; +import axios, { AxiosError, AxiosInstance } from 'axios'; import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -21,6 +21,7 @@ import { import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '../actions_config.mock'; import { getCustomAgents } from './get_custom_agents'; +import { ConnectorUsageCollector } from '../usage/connector_usage_collector'; const TestUrl = 'https://elastic.co/foo/bar/baz'; @@ -79,6 +80,80 @@ describe('request', () => { }); }); + test('adds request body bytes from request header on a successful request when connectorUsageCollector is provided', async () => { + const contentLength = 12; + axiosMock.mockImplementation(() => ({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { incidentId: '123' }, + request: { + headers: { 'Content-Length': contentLength }, + getHeader: () => contentLength, + }, + })); + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + await request({ + axios, + url: '/test', + logger, + data: { test: 12345 }, + configurationUtilities, + connectorUsageCollector, + }); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength); + }); + + test('adds request body bytes from request header on a failed', async () => { + const contentLength = 12; + axiosMock.mockImplementation( + () => + new AxiosError('failed', '500', undefined, { + headers: { 'Content-Length': contentLength }, + }) + ); + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + + try { + await request({ + axios, + url: '/test', + logger, + configurationUtilities, + connectorUsageCollector, + }); + } catch (e) { + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength); + } + }); + + test('adds request body bytes from data when request header does not exist', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + const data = { test: 12345 }; + + await request({ + axios, + url: '/test', + logger, + data, + configurationUtilities, + connectorUsageCollector, + }); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe( + Buffer.byteLength(JSON.stringify(data), 'utf8') + ); + }); + test('it have been called with proper proxy agent for a valid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ proxySSLSettings: { diff --git a/x-pack/plugins/actions/server/lib/axios_utils.ts b/x-pack/plugins/actions/server/lib/axios_utils.ts index 3852f2a33755b..254ad1a36f6e2 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.ts @@ -17,7 +17,7 @@ import { import { Logger } from '@kbn/core/server'; import { getCustomAgents } from './get_custom_agents'; import { ActionsConfigurationUtilities } from '../actions_config'; -import { SSLSettings } from '../types'; +import { ConnectorUsageCollector, SSLSettings } from '../types'; import { combineHeadersWithBasicAuthHeader } from './get_basic_auth_header'; export const request = async ({ @@ -30,6 +30,7 @@ export const request = async ({ headers, sslOverrides, timeout, + connectorUsageCollector, ...config }: { axios: AxiosInstance; @@ -41,6 +42,7 @@ export const request = async ({ headers?: Record; timeout?: number; sslOverrides?: SSLSettings; + connectorUsageCollector?: ConnectorUsageCollector; } & AxiosRequestConfig): Promise => { if (!isEmpty(axios?.defaults?.baseURL ?? '')) { throw new Error( @@ -64,18 +66,31 @@ export const request = async ({ headers, }); - return await axios(url, { - ...restConfig, - method, - headers: headersWithBasicAuth, - ...(data ? { data } : {}), - // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs - httpAgent, - httpsAgent, - proxy: false, - maxContentLength, - timeout: Math.max(settingsTimeout, timeout ?? 0), - }); + try { + const result = await axios(url, { + ...restConfig, + method, + headers: headersWithBasicAuth, + ...(data ? { data } : {}), + // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs + httpAgent, + httpsAgent, + proxy: false, + maxContentLength, + timeout: Math.max(settingsTimeout, timeout ?? 0), + }); + + if (connectorUsageCollector) { + connectorUsageCollector.addRequestBodyBytes(result, data); + } + + return result; + } catch (error) { + if (connectorUsageCollector) { + connectorUsageCollector.addRequestBodyBytes(error, data); + } + throw error; + } }; export const patch = async ({ @@ -84,12 +99,14 @@ export const patch = async ({ data, logger, configurationUtilities, + connectorUsageCollector, }: { axios: AxiosInstance; url: string; data: T; logger: Logger; configurationUtilities: ActionsConfigurationUtilities; + connectorUsageCollector?: ConnectorUsageCollector; }): Promise => { return request({ axios, @@ -98,6 +115,7 @@ export const patch = async ({ method: 'patch', data, configurationUtilities, + connectorUsageCollector, }); }; diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts index cb6390a4b3335..46f13ae1182ac 100644 --- a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts +++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts @@ -33,6 +33,7 @@ describe('createActionEventLogRecordObject', () => { spaceId: 'default', name: 'test name', actionExecutionId: '123abc', + actionTypeId: '.slack', }) ).toStrictEqual({ '@timestamp': '1970-01-01T00:00:00.000Z', @@ -64,6 +65,7 @@ describe('createActionEventLogRecordObject', () => { }, action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', @@ -92,6 +94,7 @@ describe('createActionEventLogRecordObject', () => { }, ], actionExecutionId: '123abc', + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -118,6 +121,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', @@ -145,6 +149,7 @@ describe('createActionEventLogRecordObject', () => { }, ], actionExecutionId: '123abc', + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -163,6 +168,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', @@ -192,6 +198,7 @@ describe('createActionEventLogRecordObject', () => { ], name: 'test name', actionExecutionId: '123abc', + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -220,6 +227,7 @@ describe('createActionEventLogRecordObject', () => { }, action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', @@ -255,6 +263,7 @@ describe('createActionEventLogRecordObject', () => { }, ], actionExecutionId: '123abc', + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -289,6 +298,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', @@ -319,6 +329,7 @@ describe('createActionEventLogRecordObject', () => { ], actionExecutionId: '123abc', source: asHttpRequestExecutionSource(httpServerMock.createKibanaRequest()), + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -345,6 +356,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { source: 'http_request', @@ -376,6 +388,7 @@ describe('createActionEventLogRecordObject', () => { ], actionExecutionId: '123abc', source: asHttpRequestExecutionSource(httpServerMock.createKibanaRequest()), + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -402,6 +415,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { source: 'http_request', @@ -433,6 +447,7 @@ describe('createActionEventLogRecordObject', () => { ], actionExecutionId: '123abc', isInMemory: true, + actionTypeId: '.slack', }) ).toStrictEqual({ event: { @@ -460,6 +475,7 @@ describe('createActionEventLogRecordObject', () => { ], action: { name: 'test name', + type_id: '.slack', id: '1', execution: { uuid: '123abc', diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts index 4f8bf08966c59..c3b7a3b35f512 100644 --- a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts +++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts @@ -37,6 +37,7 @@ interface CreateActionEventLogRecordParams { relatedSavedObjects?: RelatedSavedObjects; isInMemory?: boolean; source?: ActionExecutionSource; + actionTypeId: string; } export function createActionEventLogRecordObject(params: CreateActionEventLogRecordParams): Event { @@ -54,6 +55,7 @@ export function createActionEventLogRecordObject(params: CreateActionEventLogRec isInMemory, actionId, source, + actionTypeId, } = params; const kibanaAlertRule = { @@ -89,6 +91,7 @@ export function createActionEventLogRecordObject(params: CreateActionEventLogRec action: { ...(name ? { name } : {}), id: actionId, + type_id: actionTypeId, execution: { uuid: actionExecutionId, }, diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.test.ts b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts index 91e2df1972de8..aa32dd8853dba 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/case.test.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts @@ -12,12 +12,14 @@ import { actionsConfigMock } from '../actions_config.mock'; import { actionsMock } from '../mocks'; import { TestCaseConnector } from './mocks'; import { ActionsConfigurationUtilities } from '../actions_config'; +import { ConnectorUsageCollector } from '../usage'; describe('CaseConnector', () => { let logger: MockedLogger; let services: ReturnType; let mockedActionsConfig: jest.Mocked; let service: TestCaseConnector; + let connectorUsageCollector: ConnectorUsageCollector; const pushToServiceIncidentParamsSchema = { name: schema.string(), category: schema.nullable(schema.string()), @@ -57,6 +59,11 @@ describe('CaseConnector', () => { }, pushToServiceIncidentParamsSchema ); + + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('Sub actions', () => { @@ -191,7 +198,7 @@ describe('CaseConnector', () => { describe('pushToService', () => { it('should create an incident if externalId is null', async () => { - const res = await service.pushToService(pushToServiceParams); + const res = await service.pushToService(pushToServiceParams, connectorUsageCollector); expect(res).toEqual({ id: 'create-incident', title: 'Test incident', @@ -201,10 +208,13 @@ describe('CaseConnector', () => { }); it('should update an incident if externalId is not null', async () => { - const res = await service.pushToService({ - incident: { ...pushToServiceParams.incident, externalId: 'test-id' }, - comments: [], - }); + const res = await service.pushToService( + { + incident: { ...pushToServiceParams.incident, externalId: 'test-id' }, + comments: [], + }, + connectorUsageCollector + ); expect(res).toEqual({ id: 'update-incident', @@ -215,13 +225,16 @@ describe('CaseConnector', () => { }); it('should add comments', async () => { - const res = await service.pushToService({ - ...pushToServiceParams, - comments: [ - { comment: 'comment-1', commentId: 'comment-id-1' }, - { comment: 'comment-2', commentId: 'comment-id-2' }, - ], - }); + const res = await service.pushToService( + { + ...pushToServiceParams, + comments: [ + { comment: 'comment-1', commentId: 'comment-id-1' }, + { comment: 'comment-2', commentId: 'comment-id-2' }, + ], + }, + connectorUsageCollector + ); expect(res).toEqual({ id: 'create-incident', @@ -242,11 +255,14 @@ describe('CaseConnector', () => { }); it.each([[undefined], [null]])('should throw if externalId is %p', async (comments) => { - const res = await service.pushToService({ - ...pushToServiceParams, - // @ts-expect-error - comments, - }); + const res = await service.pushToService( + { + ...pushToServiceParams, + // @ts-expect-error + comments, + }, + connectorUsageCollector + ); expect(res).toEqual({ id: 'create-incident', @@ -257,10 +273,13 @@ describe('CaseConnector', () => { }); it('should not add comments if comments are an empty array', async () => { - const res = await service.pushToService({ - ...pushToServiceParams, - comments: [], - }); + const res = await service.pushToService( + { + ...pushToServiceParams, + comments: [], + }, + connectorUsageCollector + ); expect(res).toEqual({ id: 'create-incident', diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.ts b/x-pack/plugins/actions/server/sub_action_framework/case.ts index 24a0512378912..1d942b210dbf9 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/case.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/case.ts @@ -9,22 +9,38 @@ import { schema, Type } from '@kbn/config-schema'; import { ExternalServiceIncidentResponse, PushToServiceResponse } from './types'; import { SubActionConnector } from './sub_action_connector'; import { ServiceParams } from './types'; +import { ConnectorUsageCollector } from '../usage'; export interface CaseConnectorInterface { - addComment: ({ incidentId, comment }: { incidentId: string; comment: string }) => Promise; - createIncident: (incident: Incident) => Promise; - updateIncident: ({ - incidentId, - incident, - }: { - incidentId: string; - incident: Incident; - }) => Promise; - getIncident: ({ id }: { id: string }) => Promise; - pushToService: (params: { - incident: { externalId: string | null } & Incident; - comments: Array<{ commentId: string; comment: string }>; - }) => Promise; + addComment: ( + { incidentId, comment }: { incidentId: string; comment: string }, + connectorUsageCollector: ConnectorUsageCollector + ) => Promise; + createIncident: ( + incident: Incident, + connectorUsageCollector: ConnectorUsageCollector + ) => Promise; + updateIncident: ( + { + incidentId, + incident, + }: { + incidentId: string; + incident: Incident; + }, + connectorUsageCollector: ConnectorUsageCollector + ) => Promise; + getIncident: ( + { id }: { id: string }, + connectorUsageCollector: ConnectorUsageCollector + ) => Promise; + pushToService: ( + params: { + incident: { externalId: string | null } & Incident; + comments: Array<{ commentId: string; comment: string }>; + }, + connectorUsageCollector: ConnectorUsageCollector + ) => Promise; } export abstract class CaseConnector @@ -56,50 +72,71 @@ export abstract class CaseConnector; + public abstract addComment( + { + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise; - public abstract createIncident(incident: Incident): Promise; - public abstract updateIncident({ - incidentId, - incident, - }: { - incidentId: string; - incident: Incident; - }): Promise; - public abstract getIncident({ id }: { id: string }): Promise; + public abstract createIncident( + incident: Incident, + connectorUsageCollector: ConnectorUsageCollector + ): Promise; + public abstract updateIncident( + { + incidentId, + incident, + }: { + incidentId: string; + incident: Incident; + }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise; + public abstract getIncident( + { id }: { id: string }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise; - public async pushToService(params: { - incident: { externalId: string | null } & Incident; - comments: Array<{ commentId: string; comment: string }>; - }) { + public async pushToService( + params: { + incident: { externalId: string | null } & Incident; + comments: Array<{ commentId: string; comment: string }>; + }, + connectorUsageCollector: ConnectorUsageCollector + ) { const { incident, comments } = params; const { externalId, ...rest } = incident; let res: PushToServiceResponse; if (externalId != null) { - res = await this.updateIncident({ - incidentId: externalId, - incident: rest as Incident, - }); + res = await this.updateIncident( + { + incidentId: externalId, + incident: rest as Incident, + }, + connectorUsageCollector + ); } else { - res = await this.createIncident(rest as Incident); + res = await this.createIncident(rest as Incident, connectorUsageCollector); } if (comments && Array.isArray(comments) && comments.length > 0) { res.comments = []; for (const currentComment of comments) { - await this.addComment({ - incidentId: res.id, - comment: currentComment.comment, - }); + await this.addComment( + { + incidentId: res.id, + comment: currentComment.comment, + }, + connectorUsageCollector + ); res.comments = [ ...(res.comments ?? []), diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts index 35b1fa43c6ce3..1b8bdf0adcaee 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts @@ -21,6 +21,7 @@ import { } from './mocks'; import { IService, ServiceParams } from './types'; import { getErrorSource, TaskErrorSource } from '@kbn/task-manager-plugin/server/task_running'; +import { ConnectorUsageCollector } from '../usage'; describe('Executor', () => { const actionId = 'test-action-id'; @@ -30,6 +31,7 @@ describe('Executor', () => { let logger: MockedLogger; let services: ReturnType; let mockedActionsConfig: jest.Mocked; + let connectorUsageCollector: ConnectorUsageCollector; const createExecutor = (Service: IService) => { const connector = { @@ -55,6 +57,10 @@ describe('Executor', () => { logger = loggingSystemMock.createLogger(); services = actionsMock.createServices(); mockedActionsConfig = actionsConfigMock.create(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); it('should execute correctly', async () => { @@ -68,6 +74,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }); expect(res).toEqual({ @@ -90,6 +97,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }); expect(res).toEqual({ @@ -112,6 +120,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }); expect(res).toEqual({ @@ -132,6 +141,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }); expect(res).toEqual({ @@ -153,6 +163,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }) ).rejects.toThrowError('You should register at least one subAction for your connector type'); }); @@ -169,6 +180,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }) ).rejects.toThrowError( 'Sub action "not-exist" is not registered. Connector id: test-action-id. Connector name: Test. Connector type: .test' @@ -187,6 +199,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }); } catch (e) { expect(getErrorSource(e)).toBe(TaskErrorSource.USER); @@ -208,6 +221,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }) ).rejects.toThrowError( 'Method "not-exist" does not exists in service. Sub action: "testUrl". Connector id: test-action-id. Connector name: Test. Connector type: .test' @@ -226,6 +240,7 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }) ).rejects.toThrowError( 'Method "notAFunction" must be a function. Connector id: test-action-id. Connector name: Test. Connector type: .test' @@ -244,9 +259,50 @@ describe('Executor', () => { services, configurationUtilities: mockedActionsConfig, logger, + connectorUsageCollector, }) ).rejects.toThrowError( 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])' ); }); + + it('Passes connectorUsageCollector to the subAction method as a second param', async () => { + let echoSpy; + + const subActionParams = { id: 'test-id' }; + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + supportedFeatureIds: ['alerting'], + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + getService: (serviceParams: ServiceParams) => { + const service = new TestExecutor(serviceParams); + echoSpy = jest.spyOn(service, 'echo').mockResolvedValue(subActionParams); + return service; + }, + }; + + const executor = buildExecutor({ + configurationUtilities: mockedActionsConfig, + logger, + connector, + }); + + await executor({ + actionId, + params: { subAction: 'echo', subActionParams }, + config, + secrets, + services, + configurationUtilities: mockedActionsConfig, + logger, + connectorUsageCollector, + }); + + expect(echoSpy).toHaveBeenCalledWith(subActionParams, connectorUsageCollector); + }); }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.ts index d9f2f693c175d..a8fbcb6e05984 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/executor.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.ts @@ -30,7 +30,15 @@ export const buildExecutor = < logger: Logger; configurationUtilities: ActionsConfigurationUtilities; }): ExecutorType => { - return async ({ actionId, params, config, secrets, services, request }) => { + return async ({ + actionId, + params, + config, + secrets, + services, + request, + connectorUsageCollector, + }) => { const subAction = params.subAction; const subActionParams = params.subActionParams; @@ -88,7 +96,7 @@ export const buildExecutor = < } } - const data = await func.call(service, subActionParams); + const data = await func.call(service, subActionParams, connectorUsageCollector); return { status: 'ok', data: data ?? {}, actionId }; }; }; diff --git a/x-pack/plugins/actions/server/sub_action_framework/mocks.ts b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts index f6c8e86dd5af3..28e4a2abc224e 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/mocks.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts @@ -8,6 +8,7 @@ import { schema, Type, TypeOf } from '@kbn/config-schema'; import { AxiosError } from 'axios'; +import { ConnectorUsageCollector } from '../usage'; import { SubActionConnector } from './sub_action_connector'; import { CaseConnector } from './case'; import { ExternalServiceIncidentResponse, ServiceParams } from './types'; @@ -57,36 +58,54 @@ export class TestSubActionConnector extends SubActionConnector | null }) { - const res = await this.request({ - url, - data, - headers: { 'X-Test-Header': 'test' }, - responseSchema: schema.object({ status: schema.string() }), - }); + public async testUrl( + { url, data = {} }: { url: string; data?: Record | null }, + connectorUsageCollector: ConnectorUsageCollector + ) { + const res = await this.request( + { + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }, + connectorUsageCollector + ); return res; } - public async testData({ data }: { data: Record }) { - const res = await this.request({ - url: 'https://example.com', - data: this.removeNullOrUndefinedFields(data), - headers: { 'X-Test-Header': 'test' }, - responseSchema: schema.object({ status: schema.string() }), - }); + public async testData( + { data }: { data: Record }, + connectorUsageCollector: ConnectorUsageCollector + ) { + const res = await this.request( + { + url: 'https://example.com', + data: this.removeNullOrUndefinedFields(data), + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }, + connectorUsageCollector + ); return res; } - public async testAuth({ headers }: { headers?: Record } = {}) { - const res = await this.request({ - url: 'https://example.com', - data: {}, - auth: { username: 'username', password: 'password' }, - headers: { 'X-Test-Header': 'test', ...headers }, - responseSchema: schema.object({ status: schema.string() }), - }); + public async testAuth( + { headers }: { headers?: Record } = {}, + connectorUsageCollector: ConnectorUsageCollector + ) { + const res = await this.request( + { + url: 'https://example.com', + data: {}, + auth: { username: 'username', password: 'password' }, + headers: { 'X-Test-Header': 'test', ...headers }, + responseSchema: schema.object({ status: schema.string() }), + }, + connectorUsageCollector + ); return res; } diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts index 1358684d86093..ed599c3f30f71 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts @@ -13,6 +13,7 @@ import { actionsMock } from '../mocks'; import { TestSubActionConnector } from './mocks'; import { ActionsConfigurationUtilities } from '../actions_config'; import * as utils from '../lib/axios_utils'; +import { ConnectorUsageCollector } from '../usage'; jest.mock('axios'); @@ -43,6 +44,7 @@ describe('SubActionConnector', () => { let services: ReturnType; let mockedActionsConfig: jest.Mocked; let service: TestSubActionConnector; + let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { jest.resetAllMocks(); @@ -70,6 +72,11 @@ describe('SubActionConnector', () => { secrets: { username: 'elastic', password: 'changeme' }, services, }); + + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('Sub actions', () => { @@ -85,34 +92,37 @@ describe('SubActionConnector', () => { describe('URL validation', () => { it('removes double slashes correctly', async () => { - await service.testUrl({ url: 'https://example.com//api///test-endpoint' }); + await service.testUrl( + { url: 'https://example.com//api///test-endpoint' }, + connectorUsageCollector + ); expect(requestMock.mock.calls[0][0].url).toBe('https://example.com/api/test-endpoint'); }); it('removes the ending slash correctly', async () => { - await service.testUrl({ url: 'https://example.com/' }); + await service.testUrl({ url: 'https://example.com/' }, connectorUsageCollector); expect(requestMock.mock.calls[0][0].url).toBe('https://example.com'); }); it('throws an error if the url is invalid', async () => { expect.assertions(1); - await expect(async () => service.testUrl({ url: 'invalid-url' })).rejects.toThrow( - 'URL Error: Invalid URL: invalid-url' - ); + await expect(async () => + service.testUrl({ url: 'invalid-url' }, connectorUsageCollector) + ).rejects.toThrow('URL Error: Invalid URL: invalid-url'); }); it('throws an error if the url starts with backslashes', async () => { expect.assertions(1); - await expect(async () => service.testUrl({ url: '//example.com/foo' })).rejects.toThrow( - 'URL Error: Invalid URL: //example.com/foo' - ); + await expect(async () => + service.testUrl({ url: '//example.com/foo' }, connectorUsageCollector) + ).rejects.toThrow('URL Error: Invalid URL: //example.com/foo'); }); it('throws an error if the protocol is not supported', async () => { expect.assertions(1); - await expect(async () => service.testUrl({ url: 'ftp://example.com' })).rejects.toThrow( - 'URL Error: Invalid protocol' - ); + await expect(async () => + service.testUrl({ url: 'ftp://example.com' }, connectorUsageCollector) + ).rejects.toThrow('URL Error: Invalid protocol'); }); it('throws if the host is the URI is not allowed', async () => { @@ -122,15 +132,15 @@ describe('SubActionConnector', () => { throw new Error('URI is not allowed'); }); - await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( - 'error configuring connector action: URI is not allowed' - ); + await expect(async () => + service.testUrl({ url: 'https://example.com' }, connectorUsageCollector) + ).rejects.toThrow('error configuring connector action: URI is not allowed'); }); }); describe('Data', () => { it('sets data to an empty object if the data are null', async () => { - await service.testUrl({ url: 'https://example.com', data: null }); + await service.testUrl({ url: 'https://example.com', data: null }, connectorUsageCollector); expect(requestMock).toHaveBeenCalledTimes(1); const { data } = requestMock.mock.calls[0][0]; @@ -138,7 +148,10 @@ describe('SubActionConnector', () => { }); it('pass data to axios correctly if not null', async () => { - await service.testUrl({ url: 'https://example.com', data: { foo: 'foo' } }); + await service.testUrl( + { url: 'https://example.com', data: { foo: 'foo' } }, + connectorUsageCollector + ); expect(requestMock).toHaveBeenCalledTimes(1); const { data } = requestMock.mock.calls[0][0]; @@ -146,7 +159,10 @@ describe('SubActionConnector', () => { }); it('removeNullOrUndefinedFields: removes null values and undefined values correctly', async () => { - await service.testData({ data: { foo: 'foo', bar: null, baz: undefined } }); + await service.testData( + { data: { foo: 'foo', bar: null, baz: undefined } }, + connectorUsageCollector + ); expect(requestMock).toHaveBeenCalledTimes(1); const { data } = requestMock.mock.calls[0][0]; @@ -167,7 +183,7 @@ describe('SubActionConnector', () => { describe('Fetching', () => { it('fetch correctly', async () => { - const res = await service.testUrl({ url: 'https://example.com' }); + const res = await service.testUrl({ url: 'https://example.com' }, connectorUsageCollector); expect(requestMock).toHaveBeenCalledTimes(1); expect(requestMock).toBeCalledWith({ @@ -181,6 +197,7 @@ describe('SubActionConnector', () => { 'X-Test-Header': 'test', }, url: 'https://example.com', + connectorUsageCollector, }); expect(logger.debug).toBeCalledWith( @@ -192,7 +209,9 @@ describe('SubActionConnector', () => { it('validates the response correctly', async () => { requestMock.mockReturnValue({ data: { invalidField: 'test' } }); - await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + await expect(async () => + service.testUrl({ url: 'https://example.com' }, connectorUsageCollector) + ).rejects.toThrow( 'Response validation failed (Error: [status]: expected value of type [string] but got [undefined])' ); }); @@ -202,9 +221,9 @@ describe('SubActionConnector', () => { throw createAxiosError(); }); - await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( - 'Message: An error occurred. Code: 500' - ); + await expect(async () => + service.testUrl({ url: 'https://example.com' }, connectorUsageCollector) + ).rejects.toThrow('Message: An error occurred. Code: 500'); expect(logger.debug).toHaveBeenLastCalledWith( 'Request to external service failed. Connector Id: test-id. Connector type: .test. Method: get. URL: https://example.com' @@ -212,7 +231,7 @@ describe('SubActionConnector', () => { }); it('converts auth axios property to a basic auth header if provided', async () => { - await service.testAuth(); + await service.testAuth(undefined, connectorUsageCollector); expect(requestMock).toHaveBeenCalledTimes(1); expect(requestMock).toBeCalledWith({ @@ -227,11 +246,15 @@ describe('SubActionConnector', () => { Authorization: `Basic ${Buffer.from('username:password').toString('base64')}`, }, url: 'https://example.com', + connectorUsageCollector, }); }); it('does not override an authorization header if provided', async () => { - await service.testAuth({ headers: { Authorization: 'Bearer my_token' } }); + await service.testAuth( + { headers: { Authorization: 'Bearer my_token' } }, + connectorUsageCollector + ); expect(requestMock).toHaveBeenCalledTimes(1); expect(requestMock).toBeCalledWith({ @@ -246,6 +269,7 @@ describe('SubActionConnector', () => { Authorization: 'Bearer my_token', }, url: 'https://example.com', + connectorUsageCollector, }); }); }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index d5ad5391628bc..fe59feab4376b 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -24,6 +24,7 @@ import { IncomingMessage } from 'http'; import { PassThrough } from 'stream'; import { KibanaRequest } from '@kbn/core-http-server'; import { inspect } from 'util'; +import { ConnectorUsageCollector } from '../usage'; import { assertURL } from './helpers/validators'; import { ActionsConfigurationUtilities } from '../actions_config'; import { SubAction, SubActionRequestParams } from './types'; @@ -130,15 +131,18 @@ export abstract class SubActionConnector { protected abstract getResponseErrorMessage(error: AxiosError): string; - protected async request({ - url, - data, - method = 'get', - responseSchema, - headers, - timeout, - ...config - }: SubActionRequestParams): Promise> { + protected async request( + { + url, + data, + method = 'get', + responseSchema, + headers, + timeout, + ...config + }: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise> { try { this.assertURL(url); this.ensureUriAllowed(url); @@ -160,6 +164,7 @@ export abstract class SubActionConnector { configurationUtilities: this.configurationUtilities, headers: this.getHeaders(auth, headers as AxiosHeaders), timeout, + connectorUsageCollector, }); this.validateResponse(responseSchema, res.data); diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index aa6c7b26cf0ae..487e7630d40f9 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -39,11 +39,11 @@ export type ActionTypeSecrets = Record; export type ActionTypeParams = Record; export type ConnectorTokenClientContract = PublicMethodsOf; -import type { ActionExecutionSource } from './lib'; import { Connector, ConnectorWithExtraFindData } from './application/connector/types'; -export type { ActionExecutionSource } from './lib'; - +import type { ActionExecutionSource } from './lib'; export { ActionExecutionSourceType } from './lib'; +import { ConnectorUsageCollector } from './usage'; +export { ConnectorUsageCollector } from './usage'; export interface Services { savedObjectsClient: SavedObjectsClientContract; @@ -88,6 +88,7 @@ export interface ActionTypeExecutorOptions< configurationUtilities: ActionsConfigurationUtilities; source?: ActionExecutionSource; request?: KibanaRequest; + connectorUsageCollector: ConnectorUsageCollector; } export type ActionResult = Connector; diff --git a/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.ts b/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.ts index 8331f6890486c..066c477947e2c 100644 --- a/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.ts +++ b/x-pack/plugins/actions/server/unsecured_actions_client/unsecured_actions_client.ts @@ -12,11 +12,8 @@ import { ExecuteOptions, ExecutionResponse, } from '../create_unsecured_execute_function'; -import { - ActionExecutorContract, - asNotificationExecutionSource, - type RelatedSavedObjects, -} from '../lib'; +import { ActionExecutorContract, asNotificationExecutionSource } from '../lib'; +import type { RelatedSavedObjects } from '../lib'; import { ActionTypeExecutorResult, InMemoryConnector } from '../types'; import { asBackgroundTaskExecutionSource } from '../lib/action_execution_source'; import { ConnectorWithExtraFindData } from '../application/connector/types'; diff --git a/x-pack/plugins/actions/server/usage/connector_usage_collector.test.ts b/x-pack/plugins/actions/server/usage/connector_usage_collector.test.ts new file mode 100644 index 0000000000000..dcf071685f24f --- /dev/null +++ b/x-pack/plugins/actions/server/usage/connector_usage_collector.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorUsageCollector } from '../types'; +import { AxiosHeaders, AxiosResponse } from 'axios'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +describe('ConnectorUsageCollector', () => { + const logger = loggingSystemMock.createLogger(); + + test('it collects requestBodyBytes from response.request.headers', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + const data = { test: 'foo' }; + const contentLength = Buffer.byteLength(JSON.stringify(data), 'utf8'); + + const axiosResponse: AxiosResponse = { + data, + status: 200, + statusText: 'OK', + headers: {}, + config: { headers: new AxiosHeaders() }, + request: { + headers: { 'Content-Length': contentLength }, + getHeader: () => contentLength, + }, + }; + + connectorUsageCollector.addRequestBodyBytes(axiosResponse, data); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength); + + connectorUsageCollector.addRequestBodyBytes(axiosResponse, data); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength + contentLength); + }); + test('it collects requestBodyBytes from data when header is is missing', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + const data = { test: 'foo' }; + const contentLength = Buffer.byteLength(JSON.stringify(data), 'utf8'); + + const axiosResponse: AxiosResponse = { + data, + status: 200, + statusText: 'OK', + headers: {}, + config: { headers: new AxiosHeaders() }, + request: { + getHeader: () => undefined, + }, + }; + + connectorUsageCollector.addRequestBodyBytes(axiosResponse, data); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength); + + connectorUsageCollector.addRequestBodyBytes(axiosResponse, data); + + expect(connectorUsageCollector.getRequestBodyByte()).toBe(contentLength + contentLength); + }); + + test('it logs an error when the body cannot be stringified ', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + + const data = { + name: 'arun', + }; + + // @ts-ignore + data.foo = data; // this is to force JSON.stringify to throw + + const axiosResponse: AxiosResponse = { + data, + status: 200, + statusText: 'OK', + headers: {}, + config: { headers: new AxiosHeaders() }, + request: { + getHeader: () => undefined, + }, + }; + + connectorUsageCollector.addRequestBodyBytes(axiosResponse, data); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Request body bytes couldn't be calculated, Error: ") + ); + }); +}); diff --git a/x-pack/plugins/actions/server/usage/connector_usage_collector.ts b/x-pack/plugins/actions/server/usage/connector_usage_collector.ts new file mode 100644 index 0000000000000..542be0ebf7c70 --- /dev/null +++ b/x-pack/plugins/actions/server/usage/connector_usage_collector.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import { Logger } from '@kbn/core/server'; +import { isUndefined } from 'lodash'; + +interface ConnectorUsage { + requestBodyBytes: number; +} + +export class ConnectorUsageCollector { + private connectorId: string; + private usage: ConnectorUsage = { + requestBodyBytes: 0, + }; + + private logger: Logger; + + constructor({ logger, connectorId }: { logger: Logger; connectorId: string }) { + this.logger = logger; + this.connectorId = connectorId; + } + + public addRequestBodyBytes(result?: AxiosError | AxiosResponse, body: string | object = '') { + const contentLength = result?.request?.getHeader('content-length'); + let bytes = 0; + + if (!isUndefined(contentLength)) { + bytes = parseInt(contentLength, 10); + } else { + try { + const sBody = typeof body === 'string' ? body : JSON.stringify(body); + bytes = Buffer.byteLength(sBody, 'utf8'); + } catch (e) { + this.logger.error( + `Request body bytes couldn't be calculated, Error: ${e.message}, connectorId:${this.connectorId}` + ); + } + } + + this.usage.requestBodyBytes = this.usage.requestBodyBytes + bytes; + } + + public getRequestBodyByte() { + return this.usage.requestBodyBytes; + } +} diff --git a/x-pack/plugins/actions/server/usage/index.ts b/x-pack/plugins/actions/server/usage/index.ts index 722ad76014f07..d4faf364b7295 100644 --- a/x-pack/plugins/actions/server/usage/index.ts +++ b/x-pack/plugins/actions/server/usage/index.ts @@ -6,3 +6,4 @@ */ export { registerActionsUsageCollector } from './actions_usage_collector'; +export { ConnectorUsageCollector } from './connector_usage_collector'; diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 98908f516fc96..5fc8128baa7ae 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -482,6 +482,10 @@ "type": "keyword", "ignore_above": 1024 }, + "type_id": { + "type": "keyword", + "ignore_above": 1024 + }, "execution": { "properties": { "source": { @@ -508,6 +512,13 @@ } } } + }, + "usage": { + "properties": { + "request_body_bytes": { + "type": "long" + } + } } } } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index f8db21105539e..7542d6db5213a 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -212,6 +212,7 @@ export const EventSchema = schema.maybe( schema.object({ name: ecsString(), id: ecsString(), + type_id: ecsString(), execution: schema.maybe( schema.object({ source: ecsString(), @@ -227,6 +228,11 @@ export const EventSchema = schema.maybe( ), }) ), + usage: schema.maybe( + schema.object({ + request_body_bytes: ecsStringOrNumber(), + }) + ), }) ), }) diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index aa91f7fc5c2d6..770f9e6d45f9a 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -257,6 +257,10 @@ exports.EcsCustomPropertyMappings = { type: 'keyword', ignore_above: 1024, }, + type_id: { + type: 'keyword', + ignore_above: 1024, + }, execution: { properties: { source: { @@ -284,6 +288,13 @@ exports.EcsCustomPropertyMappings = { }, }, }, + usage: { + properties: { + request_body_bytes: { + type: 'long', + }, + }, + }, }, }, }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts index ce85e27a8eb43..2a4d91a07f1d3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.test.ts @@ -25,6 +25,7 @@ import { import { DEFAULT_BODY } from '../../../public/connector_types/bedrock/constants'; import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard'; import { AxiosError } from 'axios'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('../lib/gen_ai/create_gen_ai_dashboard'); // @ts-ignore @@ -37,6 +38,7 @@ describe('BedrockConnector', () => { completion: mockResponseString, stop_reason: 'stop_sequence', }; + const logger = loggingSystemMock.createLogger(); const claude3Response = { id: 'compl_01E7D3vTBHdNdKWCe6zALmLH', @@ -57,12 +59,18 @@ describe('BedrockConnector', () => { headers: {}, data: claude3Response, }; + let connectorUsageCollector: ConnectorUsageCollector; + beforeEach(() => { jest.clearAllMocks(); mockRequest = jest.fn().mockResolvedValue(mockResponse); mockError = jest.fn().mockImplementation(() => { throw new Error('API Error'); }); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); const connector = new BedrockConnector({ @@ -73,7 +81,7 @@ describe('BedrockConnector', () => { defaultModel: DEFAULT_BEDROCK_MODEL, }, secrets: { accessKey: '123', secret: 'secret' }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); @@ -85,7 +93,13 @@ describe('BedrockConnector', () => { describe('runApi', () => { it('the aws signature has non-streaming headers', async () => { - await connector.runApi({ body: DEFAULT_BODY }); + await connector.runApi( + { body: DEFAULT_BODY }, + new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }) + ); expect(mockSigner).toHaveBeenCalledWith( { body: DEFAULT_BODY, @@ -101,16 +115,19 @@ describe('BedrockConnector', () => { ); }); it('the Bedrock API call is successful with Claude 3 parameters; returns the response formatted for Claude 2 along with usage object', async () => { - const response = await connector.runApi({ body: DEFAULT_BODY }); + const response = await connector.runApi({ body: DEFAULT_BODY }, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: DEFAULT_BODY, - }); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: DEFAULT_BODY, + }, + connectorUsageCollector + ); expect(response).toEqual({ ...claude2Response, usage: claude3Response.usage, @@ -128,16 +145,19 @@ describe('BedrockConnector', () => { }); // @ts-ignore connector.request = mockRequest; - const response = await connector.runApi({ body: v2Body }); + const response = await connector.runApi({ body: v2Body }, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunActionResponseSchema, - data: v2Body, - }); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunActionResponseSchema, + data: v2Body, + }, + connectorUsageCollector + ); expect(response).toEqual(claude2Response); }); @@ -145,7 +165,15 @@ describe('BedrockConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.runApi({ body: DEFAULT_BODY })).rejects.toThrow('API Error'); + await expect( + connector.runApi( + { body: DEFAULT_BODY }, + new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }) + ) + ).rejects.toThrow('API Error'); }); }); @@ -170,7 +198,7 @@ describe('BedrockConnector', () => { }; it('the aws signature has streaming headers', async () => { - await connector.invokeStream(aiAssistantBody); + await connector.invokeStream(aiAssistantBody, connectorUsageCollector); expect(mockSigner).toHaveBeenCalledWith( { @@ -189,170 +217,197 @@ describe('BedrockConnector', () => { }); it('the API call is successful with correct request parameters', async () => { - await connector.invokeStream(aiAssistantBody); + await connector.invokeStream(aiAssistantBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, - method: 'post', - responseSchema: StreamingResponseSchema, - responseType: 'stream', - data: JSON.stringify({ ...JSON.parse(DEFAULT_BODY), temperature: 0 }), - }); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ ...JSON.parse(DEFAULT_BODY), temperature: 0 }), + }, + connectorUsageCollector + ); }); it('signal and timeout is properly passed to streamApi', async () => { const signal = jest.fn(); const timeout = 180000; - await connector.invokeStream({ ...aiAssistantBody, timeout, signal }); - - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, - method: 'post', - responseSchema: StreamingResponseSchema, - responseType: 'stream', - data: JSON.stringify({ ...JSON.parse(DEFAULT_BODY), temperature: 0 }), - timeout, - signal, - }); + await connector.invokeStream( + { ...aiAssistantBody, timeout, signal }, + connectorUsageCollector + ); + + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ ...JSON.parse(DEFAULT_BODY), temperature: 0 }), + timeout, + signal, + }, + connectorUsageCollector + ); }); it('ensureMessageFormat - formats messages from user, assistant, and system', async () => { - await connector.invokeStream({ - messages: [ - { - role: 'system', - content: 'Be a good chatbot', - }, - { - role: 'user', - content: 'Hello world', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - ], - }); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - responseType: 'stream', - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'Be a good chatbot', + await connector.invokeStream( + { messages: [ - { content: 'Hello world', role: 'user' }, - { content: 'Hi, I am a good chatbot', role: 'assistant' }, - { content: 'What is 2+2?', role: 'user' }, + { + role: 'system', + content: 'Be a good chatbot', + }, + { + role: 'user', + content: 'Hello world', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'user', + content: 'What is 2+2?', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + }, + connectorUsageCollector + ); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + responseType: 'stream', + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'Be a good chatbot', + messages: [ + { content: 'Hello world', role: 'user' }, + { content: 'Hi, I am a good chatbot', role: 'assistant' }, + { content: 'What is 2+2?', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); }); it('ensureMessageFormat - formats messages from when double user/assistant occurs', async () => { - await connector.invokeStream({ - messages: [ - { - role: 'system', - content: 'Be a good chatbot', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'assistant', - content: 'But I can be naughty', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - { - role: 'user', - content: 'I can be naughty too', - }, - { - role: 'system', - content: 'This is extra tricky', - }, - ], - }); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - responseType: 'stream', - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'Be a good chatbot\nThis is extra tricky', + await connector.invokeStream( + { messages: [ - { content: 'Hi, I am a good chatbot\nBut I can be naughty', role: 'assistant' }, - { content: 'What is 2+2?\nI can be naughty too', role: 'user' }, + { + role: 'system', + content: 'Be a good chatbot', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'assistant', + content: 'But I can be naughty', + }, + { + role: 'user', + content: 'What is 2+2?', + }, + { + role: 'user', + content: 'I can be naughty too', + }, + { + role: 'system', + content: 'This is extra tricky', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + }, + connectorUsageCollector + ); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + responseType: 'stream', + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke-with-response-stream`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'Be a good chatbot\nThis is extra tricky', + messages: [ + { content: 'Hi, I am a good chatbot\nBut I can be naughty', role: 'assistant' }, + { content: 'What is 2+2?\nI can be naughty too', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); }); it('formats the system message as a user message for claude<2.1', async () => { const modelOverride = 'anthropic.claude-v2'; - await connector.invokeStream({ - messages: [ - { - role: 'system', - content: 'Be a good chatbot', - }, - { - role: 'user', - content: 'Hello world', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - ], - model: modelOverride, - }); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - responseType: 'stream', - url: `${DEFAULT_BEDROCK_URL}/model/${modelOverride}/invoke-with-response-stream`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'Be a good chatbot', + await connector.invokeStream( + { messages: [ - { content: 'Hello world', role: 'user' }, - { content: 'Hi, I am a good chatbot', role: 'assistant' }, - { content: 'What is 2+2?', role: 'user' }, + { + role: 'system', + content: 'Be a good chatbot', + }, + { + role: 'user', + content: 'Hello world', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'user', + content: 'What is 2+2?', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + model: modelOverride, + }, + connectorUsageCollector + ); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + responseType: 'stream', + url: `${DEFAULT_BEDROCK_URL}/model/${modelOverride}/invoke-with-response-stream`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'Be a good chatbot', + messages: [ + { content: 'Hello world', role: 'user' }, + { content: 'Hi, I am a good chatbot', role: 'assistant' }, + { content: 'What is 2+2?', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); }); it('responds with a readable stream', async () => { - const response = await connector.invokeStream(aiAssistantBody); + const response = await connector.invokeStream(aiAssistantBody, connectorUsageCollector); expect(response instanceof PassThrough).toEqual(true); }); @@ -360,7 +415,9 @@ describe('BedrockConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.invokeStream(aiAssistantBody)).rejects.toThrow('API Error'); + await expect( + connector.invokeStream(aiAssistantBody, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); @@ -376,175 +433,201 @@ describe('BedrockConnector', () => { }; it('the API call is successful with correct parameters', async () => { - const response = await connector.invokeAI(aiAssistantBody); + const response = await connector.invokeAI(aiAssistantBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: JSON.stringify({ - ...JSON.parse(DEFAULT_BODY), - messages: [{ content: 'Hello world', role: 'user' }], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: JSON.stringify({ + ...JSON.parse(DEFAULT_BODY), + messages: [{ content: 'Hello world', role: 'user' }], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); expect(response.message).toEqual(mockResponseString); }); it('formats messages from user, assistant, and system', async () => { - const response = await connector.invokeAI({ - messages: [ - { - role: 'system', - content: 'Be a good chatbot', - }, - { - role: 'user', - content: 'Hello world', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - ], - }); - expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'Be a good chatbot', + const response = await connector.invokeAI( + { messages: [ - { content: 'Hello world', role: 'user' }, - { content: 'Hi, I am a good chatbot', role: 'assistant' }, - { content: 'What is 2+2?', role: 'user' }, + { + role: 'system', + content: 'Be a good chatbot', + }, + { + role: 'user', + content: 'Hello world', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'user', + content: 'What is 2+2?', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'Be a good chatbot', + messages: [ + { content: 'Hello world', role: 'user' }, + { content: 'Hi, I am a good chatbot', role: 'assistant' }, + { content: 'What is 2+2?', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); expect(response.message).toEqual(mockResponseString); }); it('adds system message from argument', async () => { - const response = await connector.invokeAI({ - messages: [ - { - role: 'user', - content: 'Hello world', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - ], - system: 'This is a system message', - }); - expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'This is a system message', + const response = await connector.invokeAI( + { messages: [ - { content: 'Hello world', role: 'user' }, - { content: 'Hi, I am a good chatbot', role: 'assistant' }, - { content: 'What is 2+2?', role: 'user' }, + { + role: 'user', + content: 'Hello world', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'user', + content: 'What is 2+2?', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + system: 'This is a system message', + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'This is a system message', + messages: [ + { content: 'Hello world', role: 'user' }, + { content: 'Hi, I am a good chatbot', role: 'assistant' }, + { content: 'What is 2+2?', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); expect(response.message).toEqual(mockResponseString); }); it('combines argument system message with conversation system message', async () => { - const response = await connector.invokeAI({ - messages: [ - { - role: 'system', - content: 'Be a good chatbot', - }, - { - role: 'user', - content: 'Hello world', - }, - { - role: 'assistant', - content: 'Hi, I am a good chatbot', - }, - { - role: 'user', - content: 'What is 2+2?', - }, - ], - system: 'This is a system message', - }); - expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - timeout: DEFAULT_TIMEOUT_MS, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - system: 'This is a system message\nBe a good chatbot', + const response = await connector.invokeAI( + { messages: [ - { content: 'Hello world', role: 'user' }, - { content: 'Hi, I am a good chatbot', role: 'assistant' }, - { content: 'What is 2+2?', role: 'user' }, + { + role: 'system', + content: 'Be a good chatbot', + }, + { + role: 'user', + content: 'Hello world', + }, + { + role: 'assistant', + content: 'Hi, I am a good chatbot', + }, + { + role: 'user', + content: 'What is 2+2?', + }, ], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - }); + system: 'This is a system message', + }, + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + timeout: DEFAULT_TIMEOUT_MS, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + system: 'This is a system message\nBe a good chatbot', + messages: [ + { content: 'Hello world', role: 'user' }, + { content: 'Hi, I am a good chatbot', role: 'assistant' }, + { content: 'What is 2+2?', role: 'user' }, + ], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + }, + connectorUsageCollector + ); expect(response.message).toEqual(mockResponseString); }); it('signal and timeout is properly passed to runApi', async () => { const signal = jest.fn(); const timeout = 180000; - await connector.invokeAI({ ...aiAssistantBody, timeout, signal }); - - expect(mockRequest).toHaveBeenCalledWith({ - signed: true, - url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, - method: 'post', - responseSchema: RunApiLatestResponseSchema, - data: JSON.stringify({ - ...JSON.parse(DEFAULT_BODY), - messages: [{ content: 'Hello world', role: 'user' }], - max_tokens: DEFAULT_TOKEN_LIMIT, - temperature: 0, - }), - timeout, - signal, - }); + await connector.invokeAI({ ...aiAssistantBody, timeout, signal }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + signed: true, + url: `${DEFAULT_BEDROCK_URL}/model/${DEFAULT_BEDROCK_MODEL}/invoke`, + method: 'post', + responseSchema: RunApiLatestResponseSchema, + data: JSON.stringify({ + ...JSON.parse(DEFAULT_BODY), + messages: [{ content: 'Hello world', role: 'user' }], + max_tokens: DEFAULT_TOKEN_LIMIT, + temperature: 0, + }), + timeout, + signal, + }, + connectorUsageCollector + ); }); it('errors during API calls are properly handled', async () => { // @ts-ignore connector.request = mockError; - await expect(connector.invokeAI(aiAssistantBody)).rejects.toThrow('API Error'); + await expect(connector.invokeAI(aiAssistantBody, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); }); }); describe('getResponseErrorMessage', () => { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts index 6b981a365b63a..b5ec114a9c456 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/bedrock/bedrock.ts @@ -11,6 +11,7 @@ import { AxiosError, Method } from 'axios'; import { IncomingMessage } from 'http'; import { PassThrough } from 'stream'; import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard'; import { RunActionParamsSchema, @@ -194,16 +195,18 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B } private async runApiRaw( - params: SubActionRequestParams + params: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - const response = await this.request(params); + const response = await this.request(params, connectorUsageCollector); return response.data; } private async runApiLatest( - params: SubActionRequestParams + params: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - const response = await this.request(params); + const response = await this.request(params, connectorUsageCollector); // keeping the response the same as claude 2 for our APIs // adding the usage object for better token tracking return { @@ -218,13 +221,10 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B * @param body The stringified request body to be sent in the POST request. * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ - public async runApi({ - body, - model: reqModel, - signal, - timeout, - raw, - }: RunActionParams): Promise { + public async runApi( + { body, model: reqModel, signal, timeout, raw }: RunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { // set model on per request basis const currentModel = reqModel ?? this.model; const path = `/model/${currentModel}/invoke`; @@ -240,13 +240,22 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B }; if (raw) { - return this.runApiRaw({ ...requestArgs, responseSchema: InvokeAIRawActionResponseSchema }); + return this.runApiRaw( + { ...requestArgs, responseSchema: InvokeAIRawActionResponseSchema }, + connectorUsageCollector + ); } // possible api received deprecated arguments, which will still work with the deprecated Claude 2 models if (usesDeprecatedArguments(body)) { - return this.runApiRaw({ ...requestArgs, responseSchema: RunActionResponseSchema }); + return this.runApiRaw( + { ...requestArgs, responseSchema: RunActionResponseSchema }, + connectorUsageCollector + ); } - return this.runApiLatest({ ...requestArgs, responseSchema: RunApiLatestResponseSchema }); + return this.runApiLatest( + { ...requestArgs, responseSchema: RunApiLatestResponseSchema }, + connectorUsageCollector + ); } /** @@ -257,26 +266,27 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B * @param body The stringified request body to be sent in the POST request. * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ - private async streamApi({ - body, - model: reqModel, - signal, - timeout, - }: RunActionParams): Promise { + private async streamApi( + { body, model: reqModel, signal, timeout }: RunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { // set model on per request basis const path = `/model/${reqModel ?? this.model}/invoke-with-response-stream`; const signed = this.signRequest(body, path, true); - const response = await this.request({ - ...signed, - url: `${this.url}${path}`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: body, - responseType: 'stream', - signal, - timeout, - }); + const response = await this.request( + { + ...signed, + url: `${this.url}${path}`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: body, + responseType: 'stream', + signal, + timeout, + }, + connectorUsageCollector + ); return response.data.pipe(new PassThrough()); } @@ -289,24 +299,30 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B * @param messages An array of messages to be sent to the API * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ - public async invokeStream({ - messages, - model, - stopSequences, - system, - temperature, - signal, - timeout, - tools, - }: InvokeAIActionParams | InvokeAIRawActionParams): Promise { - const res = (await this.streamApi({ - body: JSON.stringify( - formatBedrockBody({ messages, stopSequences, system, temperature, tools }) - ), + public async invokeStream( + { + messages, model, + stopSequences, + system, + temperature, signal, timeout, - })) as unknown as IncomingMessage; + tools, + }: InvokeAIActionParams | InvokeAIRawActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = (await this.streamApi( + { + body: JSON.stringify( + formatBedrockBody({ messages, stopSequences, system, temperature, tools }) + ), + model, + signal, + timeout, + }, + connectorUsageCollector + )) as unknown as IncomingMessage; return res; } @@ -318,54 +334,66 @@ The Kibana Connector in use may need to be reconfigured with an updated Amazon B * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. * @returns an object with the response string as a property called message */ - public async invokeAI({ - messages, - model, - stopSequences, - system, - temperature, - maxTokens, - signal, - timeout, - }: InvokeAIActionParams): Promise { - const res = (await this.runApi({ - body: JSON.stringify( - formatBedrockBody({ messages, stopSequences, system, temperature, maxTokens }) - ), + public async invokeAI( + { + messages, model, + stopSequences, + system, + temperature, + maxTokens, signal, timeout, - })) as RunActionResponse; + }: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = (await this.runApi( + { + body: JSON.stringify( + formatBedrockBody({ messages, stopSequences, system, temperature, maxTokens }) + ), + model, + signal, + timeout, + }, + connectorUsageCollector + )) as RunActionResponse; return { message: res.completion.trim() }; } - public async invokeAIRaw({ - messages, - model, - stopSequences, - system, - temperature, - maxTokens = DEFAULT_TOKEN_LIMIT, - signal, - timeout, - tools, - anthropicVersion, - }: InvokeAIRawActionParams): Promise { - const res = await this.runApi({ - body: JSON.stringify({ - messages, - stop_sequences: stopSequences, - system, - temperature, - max_tokens: maxTokens, - tools, - anthropic_version: anthropicVersion, - }), + public async invokeAIRaw( + { + messages, model, + stopSequences, + system, + temperature, + maxTokens = DEFAULT_TOKEN_LIMIT, signal, timeout, - raw: true, - }); + tools, + anthropicVersion, + }: InvokeAIRawActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.runApi( + { + body: JSON.stringify({ + messages, + stop_sequences: stopSequences, + system, + temperature, + max_tokens: maxTokens, + tools, + anthropic_version: anthropicVersion, + }), + model, + signal, + timeout, + raw: true, + }, + connectorUsageCollector + ); return res; } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/index.ts index 62dd881608605..ccd777634ec37 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/index.ts @@ -70,7 +70,7 @@ export async function executor( CasesWebhookActionParamsType > ): Promise> { - const { actionId, configurationUtilities, params, logger } = execOptions; + const { actionId, configurationUtilities, params, logger, connectorUsageCollector } = execOptions; const { subAction, subActionParams } = params; let data: CasesWebhookExecutorResultData | undefined; @@ -81,7 +81,8 @@ export async function executor( secrets: execOptions.secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); if (!api[subAction]) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts index 3a8cf2895e60e..a44b34bf88fce 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts @@ -14,6 +14,7 @@ import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { getBasicAuthHeader } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { AuthType, WebhookMethods, SSLCertType } from '../../../common/auth/constants'; import { CRT_FILE, KEY_FILE } from '../../../common/auth/mocks'; @@ -69,12 +70,17 @@ const sslConfig: CasesWebhookPublicConfigurationType = { hasAuth: true, }; const sslSecrets = { crt: CRT_FILE, key: KEY_FILE, password: 'foobar', user: null, pfx: null }; +let connectorUsageCollector: ConnectorUsageCollector; describe('Cases webhook service', () => { let service: ExternalService; let sslService: ExternalService; beforeAll(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService( actionId, { @@ -82,7 +88,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); sslService = createExternalService( @@ -92,7 +99,8 @@ describe('Cases webhook service', () => { secrets: sslSecrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); jest.useFakeTimers(); jest.setSystemTime(mockTime); @@ -121,7 +129,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -135,7 +144,8 @@ describe('Cases webhook service', () => { secrets: { ...secrets, user: '', password: '' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -149,7 +159,8 @@ describe('Cases webhook service', () => { secrets: { ...secrets, user: '', password: '' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).not.toThrow(); }); @@ -162,7 +173,8 @@ describe('Cases webhook service', () => { secrets: { ...secrets, user: 'username', password: 'password' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axios.create).toHaveBeenCalledWith({ @@ -182,7 +194,8 @@ describe('Cases webhook service', () => { secrets: { ...secrets, user: 'username', password: 'password' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axios.create).toHaveBeenCalledWith({ @@ -225,6 +238,7 @@ describe('Cases webhook service', () => { logger, configurationUtilities, sslOverrides: defaultSSLOverrides, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -238,6 +252,24 @@ describe('Cases webhook service', () => { expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": [Function], + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "logger": Object { "context": Array [], "debug": [MockFunction], @@ -481,6 +513,7 @@ describe('Cases webhook service', () => { configurationUtilities, sslOverrides: defaultSSLOverrides, data: `{"fields":{"title":"title","description":"desc","tags":["hello","world"],"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}`, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -510,6 +543,36 @@ describe('Cases webhook service', () => { expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": [Function], + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"1234\\": [HTTP 200] OK", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": "{\\"fields\\":{\\"title\\":\\"title\\",\\"description\\":\\"desc\\",\\"tags\\":[\\"hello\\",\\"world\\"],\\"project\\":{\\"key\\":\\"ROC\\"},\\"issuetype\\":{\\"id\\":\\"10024\\"}}}", "logger": Object { "context": Array [], @@ -756,6 +819,7 @@ describe('Cases webhook service', () => { issuetype: { id: '10024' }, }, }), + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -776,6 +840,24 @@ describe('Cases webhook service', () => { expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": [Function], + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": "{\\"fields\\":{\\"title\\":\\"title\\",\\"description\\":\\"desc\\",\\"tags\\":[\\"hello\\",\\"world\\"],\\"project\\":{\\"key\\":\\"ROC\\"},\\"issuetype\\":{\\"id\\":\\"10024\\"}}}", "logger": Object { "context": Array [], @@ -984,6 +1066,7 @@ describe('Cases webhook service', () => { sslOverrides: defaultSSLOverrides, url: 'https://coolsite.net/issue/1/comment', data: `{"body":"comment"}`, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -1004,6 +1087,24 @@ describe('Cases webhook service', () => { expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": [Function], + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction], + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": "{\\"body\\":\\"comment\\"}", "logger": Object { "context": Array [], @@ -1176,7 +1277,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); const res = await service.createComment(commentReq); expect(requestMock).not.toHaveBeenCalled(); @@ -1191,7 +1293,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); const res = await service.createComment(commentReq); expect(requestMock).not.toHaveBeenCalled(); @@ -1217,7 +1320,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); await service.createComment(commentReq); expect(requestMock).toHaveBeenCalledWith({ @@ -1228,6 +1332,7 @@ describe('Cases webhook service', () => { url: 'https://coolsite.net/issue/1/comment', data: `{"body":"comment","id":"1"}`, sslOverrides: defaultSSLOverrides, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); @@ -1257,7 +1362,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); await service.createComment(commentReq2); expect(requestMock).toHaveBeenCalledWith({ @@ -1268,6 +1374,7 @@ describe('Cases webhook service', () => { url: 'https://coolsite.net/issue/1/comment', data: `{"body":"comment","id":1}`, sslOverrides: defaultSSLOverrides, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }); }); @@ -1286,7 +1393,8 @@ describe('Cases webhook service', () => { ensureUriAllowed: jest.fn().mockImplementation(() => { throw new Error('Uri not allowed'); }), - } + }, + connectorUsageCollector ); }); @@ -1360,7 +1468,8 @@ describe('Cases webhook service', () => { secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); }); @@ -1430,7 +1539,8 @@ describe('Cases webhook service', () => { logger, { ...configurationUtilities, - } + }, + connectorUsageCollector ); requestMock.mockImplementation(() => createAxiosResponse({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts index 424fe9b394517..170c63a1d4e5b 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts @@ -12,6 +12,7 @@ import { renderMustacheStringNoEscape } from '@kbn/actions-plugin/server/lib/mus import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import { combineHeadersWithBasicAuthHeader } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { buildConnectorAuth, validateConnectorAuthConfiguration } from '../../../common/auth/utils'; import { validateAndNormalizeUrl, validateJson } from './validators'; import { @@ -38,7 +39,8 @@ export const createExternalService = ( actionId: string, { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): ExternalService => { const { createCommentJson, @@ -117,6 +119,7 @@ export const createExternalService = ( logger, configurationUtilities, sslOverrides, + connectorUsageCollector, }); throwDescriptiveErrorIfResponseIsNotValid({ @@ -162,6 +165,7 @@ export const createExternalService = ( data: json, configurationUtilities, sslOverrides, + connectorUsageCollector, }); const { status, statusText, data } = res; @@ -246,6 +250,7 @@ export const createExternalService = ( data: json, configurationUtilities, sslOverrides, + connectorUsageCollector, }); throwDescriptiveErrorIfResponseIsNotValid({ @@ -319,6 +324,7 @@ export const createExternalService = ( data: json, configurationUtilities, sslOverrides, + connectorUsageCollector, }); throwDescriptiveErrorIfResponseIsNotValid({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts index d53881cae1272..0c3d851981fde 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts @@ -10,27 +10,34 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { CROWDSTRIKE_CONNECTOR_ID } from '../../../public/common'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const tokenPath = 'https://api.crowdstrike.com/oauth2/token'; const hostPath = 'https://api.crowdstrike.com/devices/entities/devices/v2'; const onlineStatusPath = 'https://api.crowdstrike.com/devices/entities/online-state/v1'; const actionsPath = 'https://api.crowdstrike.com/devices/entities/devices-actions/v2'; describe('CrowdstrikeConnector', () => { + const logger = loggingSystemMock.createLogger(); const connector = new CrowdstrikeConnector({ configurationUtilities: actionsConfigMock.create(), connector: { id: '1', type: CROWDSTRIKE_CONNECTOR_ID }, config: { url: 'https://api.crowdstrike.com' }, secrets: { clientId: '123', clientSecret: 'secret' }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); let mockedRequest: jest.Mock; + let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { // @ts-expect-error private static - but I still want to reset it CrowdstrikeConnector.token = null; // @ts-expect-error mockedRequest = connector.request = jest.fn() as jest.Mock; + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); afterEach(() => { jest.clearAllMocks(); @@ -43,10 +50,13 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockResolvedValueOnce(mockResponse); - const result = await connector.executeHostActions({ - command: 'contain', - ids: ['id1', 'id2'], - }); + const result = await connector.executeHostActions( + { + command: 'contain', + ids: ['id1', 'id2'], + }, + connectorUsageCollector + ); expect(mockedRequest).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -58,7 +68,8 @@ describe('CrowdstrikeConnector', () => { method: 'post', responseSchema: expect.any(Object), url: tokenPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenNthCalledWith( 2, @@ -69,7 +80,8 @@ describe('CrowdstrikeConnector', () => { data: { ids: ['id1', 'id2'] }, paramsSerializer: expect.any(Function), responseSchema: expect.any(Object), - }) + }), + connectorUsageCollector ); expect(result).toEqual({ id: 'testid', path: 'testpath' }); }); @@ -82,7 +94,10 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockResolvedValueOnce(mockResponse); - const result = await connector.getAgentDetails({ ids: ['id1', 'id2'] }); + const result = await connector.getAgentDetails( + { ids: ['id1', 'id2'] }, + connectorUsageCollector + ); expect(mockedRequest).toHaveBeenNthCalledWith( 1, @@ -95,7 +110,8 @@ describe('CrowdstrikeConnector', () => { method: 'post', responseSchema: expect.any(Object), url: tokenPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenNthCalledWith( 2, @@ -108,7 +124,8 @@ describe('CrowdstrikeConnector', () => { paramsSerializer: expect.any(Function), responseSchema: expect.any(Object), url: hostPath, - }) + }), + connectorUsageCollector ); expect(result).toEqual({ resources: [{}] }); }); @@ -121,7 +138,10 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockResolvedValueOnce(mockResponse); - const result = await connector.getAgentOnlineStatus({ ids: ['id1', 'id2'] }); + const result = await connector.getAgentOnlineStatus( + { ids: ['id1', 'id2'] }, + connectorUsageCollector + ); expect(mockedRequest).toHaveBeenNthCalledWith( 1, @@ -134,7 +154,8 @@ describe('CrowdstrikeConnector', () => { method: 'post', responseSchema: expect.any(Object), url: tokenPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenNthCalledWith( 2, @@ -147,7 +168,8 @@ describe('CrowdstrikeConnector', () => { paramsSerializer: expect.any(Function), responseSchema: expect.any(Object), url: onlineStatusPath, - }) + }), + connectorUsageCollector ); expect(result).toEqual({ resources: [{}] }); }); @@ -226,7 +248,7 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce(mockResponse); // @ts-expect-error private method - but I still want to - const result = await connector.getTokenRequest(); + const result = await connector.getTokenRequest(connectorUsageCollector); expect(mockedRequest).toHaveBeenCalledWith( expect.objectContaining({ @@ -237,7 +259,8 @@ describe('CrowdstrikeConnector', () => { 'Content-Type': 'application/x-www-form-urlencoded', authorization: expect.stringContaining('Basic'), }, - }) + }), + connectorUsageCollector ); expect(result).toEqual('testToken'); }); @@ -247,7 +270,7 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockResolvedValue(mockResponse); - await connector.getAgentDetails({ ids: ['id1', 'id2'] }); + await connector.getAgentDetails({ ids: ['id1', 'id2'] }, connectorUsageCollector); expect(mockedRequest).toHaveBeenNthCalledWith( 1, @@ -260,7 +283,8 @@ describe('CrowdstrikeConnector', () => { method: 'post', responseSchema: expect.any(Object), url: tokenPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenNthCalledWith( 2, @@ -273,10 +297,11 @@ describe('CrowdstrikeConnector', () => { paramsSerializer: expect.any(Function), responseSchema: expect.any(Object), url: hostPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenCalledTimes(2); - await connector.getAgentDetails({ ids: ['id1', 'id2'] }); + await connector.getAgentDetails({ ids: ['id1', 'id2'] }, connectorUsageCollector); expect(mockedRequest).toHaveBeenNthCalledWith( 3, expect.objectContaining({ @@ -288,7 +313,8 @@ describe('CrowdstrikeConnector', () => { paramsSerializer: expect.any(Function), responseSchema: expect.any(Object), url: hostPath, - }) + }), + connectorUsageCollector ); expect(mockedRequest).toHaveBeenCalledTimes(3); }); @@ -298,9 +324,9 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockRejectedValueOnce(mockResponse); - await expect(() => connector.getAgentDetails({ ids: ['id1', 'id2'] })).rejects.toThrowError( - 'something goes wrong' - ); + await expect(() => + connector.getAgentDetails({ ids: ['id1', 'id2'] }, connectorUsageCollector) + ).rejects.toThrowError('something goes wrong'); expect(mockedRequest).toHaveBeenCalledTimes(2); }); it('should repeat the call one time if theres 401 error ', async () => { @@ -309,7 +335,9 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockRejectedValueOnce(mockResponse); - await expect(() => connector.getAgentDetails({ ids: ['id1', 'id2'] })).rejects.toThrowError(); + await expect(() => + connector.getAgentDetails({ ids: ['id1', 'id2'] }, connectorUsageCollector) + ).rejects.toThrowError(); expect(mockedRequest).toHaveBeenCalledTimes(3); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts index 3d14ae62924c4..a4fc84ae6a49a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts @@ -9,6 +9,7 @@ import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { isAggregateError, NodeSystemError } from './types'; import type { CrowdstrikeConfig, @@ -96,68 +97,87 @@ export class CrowdstrikeConnector extends SubActionConnector< }); } - public async executeHostActions({ alertIds, ...payload }: CrowdstrikeHostActionsParams) { - return this.crowdstrikeApiRequest({ - url: this.urls.hostAction, - method: 'post', - params: { - action_name: payload.command, - }, - data: { - ids: payload.ids, - ...(payload.actionParameters - ? { - action_parameters: Object.entries(payload.actionParameters).map(([name, value]) => ({ - name, - value, - })), - } - : {}), + public async executeHostActions( + { alertIds, ...payload }: CrowdstrikeHostActionsParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + return this.crowdstrikeApiRequest( + { + url: this.urls.hostAction, + method: 'post', + params: { + action_name: payload.command, + }, + data: { + ids: payload.ids, + ...(payload.actionParameters + ? { + action_parameters: Object.entries(payload.actionParameters).map( + ([name, value]) => ({ + name, + value, + }) + ), + } + : {}), + }, + paramsSerializer, + responseSchema: CrowdstrikeHostActionsResponseSchema, }, - paramsSerializer, - responseSchema: CrowdstrikeHostActionsResponseSchema, - }); + connectorUsageCollector + ); } public async getAgentDetails( - payload: CrowdstrikeGetAgentsParams + payload: CrowdstrikeGetAgentsParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - return this.crowdstrikeApiRequest({ - url: this.urls.agents, - method: 'GET', - params: { - ids: payload.ids, + return this.crowdstrikeApiRequest( + { + url: this.urls.agents, + method: 'GET', + params: { + ids: payload.ids, + }, + paramsSerializer, + responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, }, - paramsSerializer, - responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, - }) as Promise; + connectorUsageCollector + ) as Promise; } public async getAgentOnlineStatus( - payload: CrowdstrikeGetAgentsParams + payload: CrowdstrikeGetAgentsParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - return this.crowdstrikeApiRequest({ - url: this.urls.agentStatus, - method: 'GET', - params: { - ids: payload.ids, + return this.crowdstrikeApiRequest( + { + url: this.urls.agentStatus, + method: 'GET', + params: { + ids: payload.ids, + }, + paramsSerializer, + responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, }, - paramsSerializer, - responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, - }) as Promise; + connectorUsageCollector + ) as Promise; } - private async getTokenRequest() { - const response = await this.request({ - url: this.urls.getToken, - method: 'post', - headers: { - accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - authorization: 'Basic ' + CrowdstrikeConnector.base64encodedToken, + private async getTokenRequest(connectorUsageCollector: ConnectorUsageCollector) { + const response = await this.request( + { + url: this.urls.getToken, + method: 'post', + headers: { + accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + authorization: 'Basic ' + CrowdstrikeConnector.base64encodedToken, + }, + responseSchema: CrowdstrikeGetTokenResponseSchema, }, - responseSchema: CrowdstrikeGetTokenResponseSchema, - }); + connectorUsageCollector + ); const token = response.data?.access_token; if (token) { // Clear any existing timeout @@ -173,28 +193,33 @@ export class CrowdstrikeConnector extends SubActionConnector< private async crowdstrikeApiRequest( req: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector, retried?: boolean ): Promise { try { if (!CrowdstrikeConnector.token) { - CrowdstrikeConnector.token = (await this.getTokenRequest()) as string; + CrowdstrikeConnector.token = (await this.getTokenRequest( + connectorUsageCollector + )) as string; } - const response = await this.request({ - ...req, - headers: { - ...req.headers, - Authorization: `Bearer ${CrowdstrikeConnector.token}`, + const response = await this.request( + { + ...req, + headers: { + ...req.headers, + Authorization: `Bearer ${CrowdstrikeConnector.token}`, + }, }, - }); + connectorUsageCollector + ); return response.data; } catch (error) { if (error.code === 401 && !retried) { CrowdstrikeConnector.token = null; - return this.crowdstrikeApiRequest(req, true); + return this.crowdstrikeApiRequest(req, connectorUsageCollector, true); } - throw new CrowdstrikeError(error.message); } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.test.ts index 6f1a002cdf1d0..055a7fe7a7dba 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.test.ts @@ -11,6 +11,7 @@ import { D3_SECURITY_CONNECTOR_ID } from '../../../common/d3security/constants'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { D3SecurityRunActionResponseSchema } from '../../../common/d3security/schema'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; describe('D3SecurityConnector', () => { const sampleBody = JSON.stringify({ @@ -28,6 +29,7 @@ describe('D3SecurityConnector', () => { const mockError = jest.fn().mockImplementation(() => { throw new Error('API Error'); }); + const logger = loggingSystemMock.createLogger(); describe('D3 Security', () => { const connector = new D3SecurityConnector({ @@ -35,26 +37,35 @@ describe('D3SecurityConnector', () => { connector: { id: '1', type: D3_SECURITY_CONNECTOR_ID }, config: { url: 'https://example.com/api' }, secrets: { token: '123' }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); + let connectorUsageCollector: ConnectorUsageCollector; + beforeEach(() => { // @ts-ignore connector.request = mockRequest; jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); it('the D3 Security API call is successful with correct parameters', async () => { - const response = await connector.runApi({ body: sampleBody }); + const response = await connector.runApi({ body: sampleBody }, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api', - method: 'post', - responseSchema: D3SecurityRunActionResponseSchema, - data: sampleBodyFormatted, - headers: { - d3key: '123', + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api', + method: 'post', + responseSchema: D3SecurityRunActionResponseSchema, + data: sampleBodyFormatted, + headers: { + d3key: '123', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual({ result: 'success' }); }); @@ -62,7 +73,9 @@ describe('D3SecurityConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.runApi({ body: sampleBody })).rejects.toThrow('API Error'); + await expect(connector.runApi({ body: sampleBody }, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.ts b/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.ts index 804590c01b284..0c35766a3ddf3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/d3security/d3security.ts @@ -7,6 +7,7 @@ import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { addSeverityAndEventTypeInBody } from './helpers'; import { D3SecurityRunActionParamsSchema, @@ -57,22 +58,24 @@ export class D3SecurityConnector extends SubActionConnector { - const response = await this.request({ - url: this.url, - method: 'post', - responseSchema: D3SecurityRunActionResponseSchema, - data: addSeverityAndEventTypeInBody( - body ?? '', - severity ?? D3SecuritySeverity.EMPTY, - eventType ?? '' - ), - headers: { d3key: this.token || '' }, - }); + public async runApi( + { body, severity, eventType }: D3SecurityRunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const response = await this.request( + { + url: this.url, + method: 'post', + responseSchema: D3SecurityRunActionResponseSchema, + data: addSeverityAndEventTypeInBody( + body ?? '', + severity ?? D3SecuritySeverity.EMPTY, + eventType ?? '' + ), + headers: { d3key: this.token || '' }, + }, + connectorUsageCollector + ); return response.data; } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts index f3787e8d367d9..4ee4b3e4890b7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/index.test.ts @@ -20,6 +20,8 @@ import { validateParams, validateSecrets, } from '@kbn/actions-plugin/server/lib'; + +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { sendEmail } from './send_email'; import { ActionParamsType, @@ -514,6 +516,10 @@ describe('execute()', () => { text: 'Go to Elastic', }, }; + const connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); const actionId = 'some-id'; const executorOptions: EmailConnectorTypeExecutorOptions = { @@ -524,6 +530,7 @@ describe('execute()', () => { services, configurationUtilities: actionsConfigMock.create(), logger: mockedLogger, + connectorUsageCollector, }; beforeEach(() => { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts index 785ace370323f..3a1d01732eb7d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/index.ts @@ -274,8 +274,16 @@ async function executor( }, execOptions: EmailConnectorTypeExecutorOptions ): Promise> { - const { actionId, config, secrets, params, configurationUtilities, services, logger } = - execOptions; + const { + actionId, + config, + secrets, + params, + configurationUtilities, + services, + logger, + connectorUsageCollector, + } = execOptions; const connectorTokenClient = services.connectorTokenClient; const emails = params.to.concat(params.cc).concat(params.bcc); @@ -366,7 +374,12 @@ async function executor( let result; try { - result = await sendEmail(logger, sendEmailOptions, connectorTokenClient); + result = await sendEmail( + logger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); } catch (err) { const message = i18n.translate('xpack.stackConnectors.email.errorSendingErrorMessage', { defaultMessage: 'error sending email', diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.test.ts index 535c3932c04e7..77de60660a975 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.test.ts @@ -10,7 +10,7 @@ import { Logger } from '@kbn/core/server'; import { sendEmail } from './send_email'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import nodemailer from 'nodemailer'; -import { ProxySettings } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, ProxySettings } from '@kbn/actions-plugin/server/types'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { CustomHostSettings } from '@kbn/actions-plugin/server/config'; import { sendEmailGraphApi } from './send_email_graph_api'; @@ -39,6 +39,7 @@ const sendMailMock = jest.fn(); const mockLogger = loggingSystemMock.create().get() as jest.Mocked; const connectorTokenClient = connectorTokenClientMock.create(); +let connectorUsageCollector: ConnectorUsageCollector; describe('send_email module', () => { beforeEach(() => { @@ -53,11 +54,21 @@ describe('send_email module', () => { interceptors: mockAxiosInstanceInterceptor, }; }); + + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockLogger, + connectorId: 'test-connector-id', + }); }); test('handles authenticated email using service', async () => { const sendEmailOptions = getSendEmailOptions({ transport: { service: 'other' } }); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -101,7 +112,12 @@ describe('send_email module', () => { content: { hasHTMLMessage: true }, transport: { service: 'other' }, }); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -159,7 +175,7 @@ describe('send_email module', () => { status: 202, }); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector); expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ configurationUtilities: sendEmailOptions.configurationUtilities, connectorId: '1', @@ -176,6 +192,7 @@ describe('send_email module', () => { delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); sendEmailGraphApiMock.mock.calls[0].pop(); + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -254,7 +271,7 @@ describe('send_email module', () => { status: 202, }); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector); expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ configurationUtilities: sendEmailOptions.configurationUtilities, connectorId: '1', @@ -292,7 +309,7 @@ describe('send_email module', () => { status: 202, }); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector); expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ configurationUtilities: sendEmailOptions.configurationUtilities, connectorId: '1', @@ -322,7 +339,7 @@ describe('send_email module', () => { getOAuthClientCredentialsAccessTokenMock.mockReturnValueOnce(null); await expect(() => - sendEmail(mockLogger, sendEmailOptions, connectorTokenClient) + sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to retrieve access token for connectorId: 1"` ); @@ -362,7 +379,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -412,7 +434,12 @@ describe('send_email module', () => { delete sendEmailOptions.transport.user; // @ts-expect-error delete sendEmailOptions.transport.password; - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -462,7 +489,12 @@ describe('send_email module', () => { // @ts-expect-error delete sendEmailOptions.transport.password; - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -503,9 +535,9 @@ describe('send_email module', () => { sendMailMock.mockReset(); sendMailMock.mockRejectedValue(new Error('wops')); - await expect(sendEmail(mockLogger, sendEmailOptions, connectorTokenClient)).rejects.toThrow( - 'wops' - ); + await expect( + sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector) + ).rejects.toThrow('wops'); }); test('it bypasses with proxyBypassHosts when expected', async () => { @@ -526,7 +558,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -560,7 +597,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -596,7 +638,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -630,7 +677,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -667,7 +719,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); // note in the object below, the rejectUnauthenticated got set to false, @@ -710,7 +767,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); // in this case, rejectUnauthorized is true, as the custom host settings @@ -757,7 +819,12 @@ describe('send_email module', () => { } ); - const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + const result = await sendEmail( + mockLogger, + sendEmailOptions, + connectorTokenClient, + connectorUsageCollector + ); expect(result).toBe(sendMailMockResult); expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -791,7 +858,7 @@ describe('send_email module', () => { 'Bearer clienttokentokentoken' ); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector); expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); expect(createAxiosInstanceMock).toHaveBeenCalledWith(); expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); @@ -834,7 +901,7 @@ describe('send_email module', () => { 'Bearer clienttokentokentoken' ); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient, connectorUsageCollector); expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); expect(createAxiosInstanceMock).toHaveBeenCalledWith(); expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.ts index f3ab3bfa22c55..199d96f352389 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email.ts @@ -17,7 +17,11 @@ import { getNodeSSLOptions, getSSLSettingsFromConfig, } from '@kbn/actions-plugin/server/lib/get_node_ssl_options'; -import { ConnectorTokenClientContract, ProxySettings } from '@kbn/actions-plugin/server/types'; +import { + ConnectorUsageCollector, + ConnectorTokenClientContract, + ProxySettings, +} from '@kbn/actions-plugin/server/types'; import { getOAuthClientCredentialsAccessToken } from '@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token'; import { AdditionalEmailServices } from '../../../common'; import { sendEmailGraphApi } from './send_email_graph_api'; @@ -66,7 +70,8 @@ export interface Content { export async function sendEmail( logger: Logger, options: SendEmailOptions, - connectorTokenClient: ConnectorTokenClientContract + connectorTokenClient: ConnectorTokenClientContract, + connectorUsageCollector: ConnectorUsageCollector ): Promise { const { transport, content } = options; const { message, messageHTML } = content; @@ -74,9 +79,15 @@ export async function sendEmail( const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message); if (transport.service === AdditionalEmailServices.EXCHANGE) { - return await sendEmailWithExchange(logger, options, renderedMessage, connectorTokenClient); + return await sendEmailWithExchange( + logger, + options, + renderedMessage, + connectorTokenClient, + connectorUsageCollector + ); } else { - return await sendEmailWithNodemailer(logger, options, renderedMessage); + return await sendEmailWithNodemailer(logger, options, renderedMessage, connectorUsageCollector); } } @@ -85,7 +96,8 @@ export async function sendEmailWithExchange( logger: Logger, options: SendEmailOptions, messageHTML: string, - connectorTokenClient: ConnectorTokenClientContract + connectorTokenClient: ConnectorTokenClientContract, + connectorUsageCollector: ConnectorUsageCollector ): Promise { const { transport, configurationUtilities, connectorId } = options; const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport; @@ -155,6 +167,7 @@ export async function sendEmailWithExchange( }, logger, configurationUtilities, + connectorUsageCollector, axiosInstance ); } @@ -163,7 +176,8 @@ export async function sendEmailWithExchange( async function sendEmailWithNodemailer( logger: Logger, options: SendEmailOptions, - messageHTML: string + messageHTML: string, + connectorUsageCollector: ConnectorUsageCollector ): Promise { const { transport, routing, content, configurationUtilities, hasAuth } = options; const { service } = transport; @@ -186,6 +200,7 @@ async function sendEmailWithNodemailer( // some deep properties, so need to use any here. const transportConfig = getTransportConfig(configurationUtilities, logger, transport, hasAuth); const nodemailerTransport = nodemailer.createTransport(transportConfig); + connectorUsageCollector.addRequestBodyBytes(undefined, email); const result = await nodemailerTransport.sendMail(email); if (service === JSON_TRANSPORT_SERVICE) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts index 03289b79c3004..6166082345243 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts @@ -14,7 +14,7 @@ import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { CustomHostSettings } from '@kbn/actions-plugin/server/config'; -import { ProxySettings } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, ProxySettings } from '@kbn/actions-plugin/server/types'; import { sendEmailGraphApi } from './send_email_graph_api'; const createAxiosInstanceMock = axios.create as jest.Mock; @@ -28,6 +28,11 @@ describe('sendEmailGraphApi', () => { const configurationUtilities = actionsConfigMock.create(); test('email contains the proper message', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + axiosInstanceMock.mockReturnValueOnce({ status: 202, }); @@ -38,7 +43,8 @@ describe('sendEmailGraphApi', () => { headers: {}, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -118,6 +124,10 @@ describe('sendEmailGraphApi', () => { }); test('email was sent on behalf of the user "from" mailbox', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); axiosInstanceMock.mockReturnValueOnce({ status: 202, }); @@ -128,7 +138,8 @@ describe('sendEmailGraphApi', () => { headers: { Authorization: 'Bearer 1234567' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axiosInstanceMock.mock.calls[1]).toMatchInlineSnapshot(` Array [ @@ -210,6 +221,10 @@ describe('sendEmailGraphApi', () => { }); test('sendMail request was sent to the custom configured Graph API URL', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); axiosInstanceMock.mockReturnValueOnce({ status: 202, }); @@ -221,7 +236,8 @@ describe('sendEmailGraphApi', () => { headers: {}, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axiosInstanceMock.mock.calls[2]).toMatchInlineSnapshot(` Array [ @@ -301,6 +317,10 @@ describe('sendEmailGraphApi', () => { }); test('throw the exception and log the proper error if message was not sent successfuly', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); axiosInstanceMock.mockReturnValueOnce({ status: 400, data: { @@ -315,7 +335,8 @@ describe('sendEmailGraphApi', () => { sendEmailGraphApi( { options: getSendEmailOptions(), messageHTML: 'test1', headers: {} }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).rejects.toThrowErrorMatchingInlineSnapshot( '"{\\"error\\":{\\"code\\":\\"ErrorMimeContentInvalidBase64String\\",\\"message\\":\\"Invalid base64 string for MIME content.\\"}}"' diff --git a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.ts b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.ts index 79d7af05e041e..ed624299b3535 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/email/send_email_graph_api.ts @@ -11,18 +11,14 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { SendEmailOptions } from './send_email'; -interface SendEmailGraphApiOptions { - options: SendEmailOptions; - headers: Record; - messageHTML: string; -} - export async function sendEmailGraphApi( sendEmailOptions: SendEmailGraphApiOptions, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector, axiosInstance?: AxiosInstance ): Promise { const { options, headers, messageHTML } = sendEmailOptions; @@ -42,6 +38,7 @@ export async function sendEmailGraphApi( headers, configurationUtilities, validateStatus: () => true, + connectorUsageCollector, }); if (res.status === 202) { return res.data; @@ -53,6 +50,12 @@ export async function sendEmailGraphApi( throw new Error(errString); } +interface SendEmailGraphApiOptions { + options: SendEmailOptions; + headers: Record; + messageHTML: string; +} + function getMessage(emailOptions: SendEmailOptions, messageHTML: string) { const { routing, content } = emailOptions; const { to, cc, bcc } = routing; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts index 2b3fab30432fa..5b7353ef58291 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/es_index/index.test.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { validateConfig, validateParams } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { ActionParamsType, @@ -27,11 +28,16 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: ESIndexConnectorType; let configurationUtilities: ActionsConfigurationUtilities; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { jest.resetAllMocks(); configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connector registration', () => { @@ -185,6 +191,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const scopedClusterClient = elasticsearchClientMock .createClusterClient() @@ -230,6 +237,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; scopedClusterClient.bulk.mockClear(); await connectorType.executor({ @@ -280,6 +288,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; scopedClusterClient.bulk.mockClear(); @@ -324,6 +333,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; scopedClusterClient.bulk.mockClear(); await connectorType.executor({ @@ -656,6 +666,7 @@ describe('execute()', () => { services: { ...services, scopedClusterClient }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).toMatchInlineSnapshot(` Object { @@ -695,6 +706,7 @@ describe('execute()', () => { services: { ...services, scopedClusterClient }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).toMatchInlineSnapshot(` Object { @@ -757,6 +769,7 @@ describe('execute()', () => { services: { ...services, scopedClusterClient }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).toMatchInlineSnapshot(` Object { @@ -824,6 +837,7 @@ describe('execute()', () => { services: { ...services, scopedClusterClient }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts index d58cefe12f839..949ae0a6c1bd2 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.test.ts @@ -15,6 +15,7 @@ import { RunApiResponseSchema, StreamingResponseSchema } from '../../../common/g import { DEFAULT_GEMINI_MODEL } from '../../../common/gemini/constants'; import { AxiosError } from 'axios'; import { Transform } from 'stream'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('../lib/gen_ai/create_gen_ai_dashboard'); jest.mock('@kbn/actions-plugin/server/sub_action_framework/helpers/validators', () => ({ @@ -61,6 +62,7 @@ describe('GeminiConnector', () => { mockRequest = connector.request = jest.fn().mockResolvedValue(defaultResponse); }); + const logger = loggingSystemMock.createLogger(); const connector = new GeminiConnector({ connector: { id: '1', type: '.gemini' }, configurationUtilities: actionsConfigMock.create(), @@ -84,14 +86,19 @@ describe('GeminiConnector', () => { client_x509_cert_url: '', }), }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); + let connectorUsageCollector: ConnectorUsageCollector; describe('Gemini', () => { beforeEach(() => { // @ts-ignore connector.request = mockRequest; + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('runApi', () => { @@ -101,33 +108,36 @@ describe('GeminiConnector', () => { model: DEFAULT_GEMINI_MODEL, }; - const response = await connector.runApi(runActionParams); + const response = await connector.runApi(runActionParams, connectorUsageCollector); // Assertions expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, - method: 'post', - data: JSON.stringify({ - messages: [ - { - contents: [ - { - role: 'user', - parts: [{ text: 'What is the capital of France?' }], - }, - ], - }, - ], - }), - headers: { - Authorization: 'Bearer mock_access_token', - 'Content-Type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, + method: 'post', + data: JSON.stringify({ + messages: [ + { + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + }, + ], + }), + headers: { + Authorization: 'Bearer mock_access_token', + 'Content-Type': 'application/json', + }, + timeout: 60000, + responseSchema: RunApiResponseSchema, + signal: undefined, }, - timeout: 60000, - responseSchema: RunApiResponseSchema, - signal: undefined, - }); + connectorUsageCollector + ); expect(response).toEqual(connectorResponse); }); @@ -144,66 +154,72 @@ describe('GeminiConnector', () => { }; it('the API call is successful with correct parameters', async () => { - await connector.invokeAI(aiAssistantBody); + await connector.invokeAI(aiAssistantBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, - method: 'post', - responseSchema: RunApiResponseSchema, - data: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [{ text: 'What is the capital of France?' }], + expect(mockRequest).toHaveBeenCalledWith( + { + url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, + method: 'post', + responseSchema: RunApiResponseSchema, + data: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + generation_config: { + temperature: 0, + maxOutputTokens: 8192, }, - ], - generation_config: { - temperature: 0, - maxOutputTokens: 8192, + safety_settings: [ + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, + ], + }), + headers: { + Authorization: 'Bearer mock_access_token', + 'Content-Type': 'application/json', }, - safety_settings: [ - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, - ], - }), - headers: { - Authorization: 'Bearer mock_access_token', - 'Content-Type': 'application/json', + signal: undefined, + timeout: 60000, }, - signal: undefined, - timeout: 60000, - }); + connectorUsageCollector + ); }); it('signal and timeout is properly passed to runApi', async () => { const signal = jest.fn(); const timeout = 60000; - await connector.invokeAI({ ...aiAssistantBody, timeout, signal }); - expect(mockRequest).toHaveBeenCalledWith({ - url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, - method: 'post', - responseSchema: RunApiResponseSchema, - data: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [{ text: 'What is the capital of France?' }], + await connector.invokeAI({ ...aiAssistantBody, timeout, signal }, connectorUsageCollector); + expect(mockRequest).toHaveBeenCalledWith( + { + url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:generateContent`, + method: 'post', + responseSchema: RunApiResponseSchema, + data: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + generation_config: { + temperature: 0, + maxOutputTokens: 8192, }, - ], - generation_config: { - temperature: 0, - maxOutputTokens: 8192, + safety_settings: [ + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, + ], + }), + headers: { + Authorization: 'Bearer mock_access_token', + 'Content-Type': 'application/json', }, - safety_settings: [ - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, - ], - }), - headers: { - Authorization: 'Bearer mock_access_token', - 'Content-Type': 'application/json', + signal, + timeout: 60000, }, - signal, - timeout: 60000, - }); + connectorUsageCollector + ); }); }); @@ -226,68 +242,77 @@ describe('GeminiConnector', () => { }; it('the API call is successful with correct request parameters', async () => { - await connector.invokeStream(aiAssistantBody); + await connector.invokeStream(aiAssistantBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:streamGenerateContent?alt=sse`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [{ text: 'What is the capital of France?' }], + expect(mockRequest).toHaveBeenCalledWith( + { + url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:streamGenerateContent?alt=sse`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + generation_config: { + temperature: 0, + maxOutputTokens: 8192, }, - ], - generation_config: { - temperature: 0, - maxOutputTokens: 8192, + safety_settings: [ + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, + ], + }), + responseType: 'stream', + headers: { + Authorization: 'Bearer mock_access_token', + 'Content-Type': 'application/json', }, - safety_settings: [ - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, - ], - }), - responseType: 'stream', - headers: { - Authorization: 'Bearer mock_access_token', - 'Content-Type': 'application/json', + signal: undefined, + timeout: 60000, }, - signal: undefined, - timeout: 60000, - }); + connectorUsageCollector + ); }); it('signal and timeout is properly passed to streamApi', async () => { const signal = jest.fn(); const timeout = 60000; - await connector.invokeStream({ ...aiAssistantBody, timeout, signal }); - expect(mockRequest).toHaveBeenCalledWith({ - url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:streamGenerateContent?alt=sse`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [{ text: 'What is the capital of France?' }], + await connector.invokeStream( + { ...aiAssistantBody, timeout, signal }, + connectorUsageCollector + ); + expect(mockRequest).toHaveBeenCalledWith( + { + url: `https://api.gemini.com/v1/projects/my-project-12345/locations/us-central1/publishers/google/models/${DEFAULT_GEMINI_MODEL}:streamGenerateContent?alt=sse`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + generation_config: { + temperature: 0, + maxOutputTokens: 8192, }, - ], - generation_config: { - temperature: 0, - maxOutputTokens: 8192, + safety_settings: [ + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, + ], + }), + responseType: 'stream', + headers: { + Authorization: 'Bearer mock_access_token', + 'Content-Type': 'application/json', }, - safety_settings: [ - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }, - ], - }), - responseType: 'stream', - headers: { - Authorization: 'Bearer mock_access_token', - 'Content-Type': 'application/json', + signal, + timeout: 60000, }, - signal, - timeout: 60000, - }); + connectorUsageCollector + ); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts index 6cb4671b7aeeb..75f7458d3d6b3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gemini/gemini.ts @@ -11,7 +11,10 @@ import { PassThrough } from 'stream'; import { IncomingMessage } from 'http'; import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; import { getGoogleOAuthJwtAccessToken } from '@kbn/actions-plugin/server/lib/get_gcp_oauth_access_token'; -import { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; +import { + ConnectorUsageCollector, + ConnectorTokenClientContract, +} from '@kbn/actions-plugin/server/types'; import { HarmBlockThreshold, HarmCategory } from '@google/generative-ai'; import { @@ -211,13 +214,10 @@ export class GeminiConnector extends SubActionConnector { * @param body The stringified request body to be sent in the POST request. * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ - public async runApi({ - body, - model: reqModel, - signal, - timeout, - raw, - }: RunActionParams): Promise { + public async runApi( + { body, model: reqModel, signal, timeout, raw }: RunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { // set model on per request basis const currentModel = reqModel ?? this.model; const path = `/v1/projects/${this.gcpProjectID}/locations/${this.gcpRegion}/publishers/google/models/${currentModel}:generateContent`; @@ -236,7 +236,7 @@ export class GeminiConnector extends SubActionConnector { responseSchema: raw ? RunActionRawResponseSchema : RunApiResponseSchema, } as SubActionRequestParams; - const response = await this.request(requestArgs); + const response = await this.request(requestArgs, connectorUsageCollector); if (raw) { return response.data; @@ -249,65 +249,65 @@ export class GeminiConnector extends SubActionConnector { return { completion: completionText, usageMetadata }; } - private async streamAPI({ - body, - model: reqModel, - signal, - timeout, - }: RunActionParams): Promise { + private async streamAPI( + { body, model: reqModel, signal, timeout }: RunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { const currentModel = reqModel ?? this.model; const path = `/v1/projects/${this.gcpProjectID}/locations/${this.gcpRegion}/publishers/google/models/${currentModel}:streamGenerateContent?alt=sse`; const token = await this.getAccessToken(); - const response = await this.request({ - url: `${this.url}${path}`, - method: 'post', - responseSchema: StreamingResponseSchema, - data: body, - responseType: 'stream', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', + const response = await this.request( + { + url: `${this.url}${path}`, + method: 'post', + responseSchema: StreamingResponseSchema, + data: body, + responseType: 'stream', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + signal, + timeout: timeout ?? DEFAULT_TIMEOUT_MS, }, - signal, - timeout: timeout ?? DEFAULT_TIMEOUT_MS, - }); + connectorUsageCollector + ); return response.data.pipe(new PassThrough()); } - public async invokeAI({ - messages, - model, - temperature = 0, - signal, - timeout, - }: InvokeAIActionParams): Promise { - const res = await this.runApi({ - body: JSON.stringify(formatGeminiPayload(messages, temperature)), - model, - signal, - timeout, - }); + public async invokeAI( + { messages, model, temperature = 0, signal, timeout }: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.runApi( + { + body: JSON.stringify(formatGeminiPayload(messages, temperature)), + model, + signal, + timeout, + }, + connectorUsageCollector + ); return { message: res.completion, usageMetadata: res.usageMetadata }; } - public async invokeAIRaw({ - messages, - model, - temperature = 0, - signal, - timeout, - tools, - }: InvokeAIRawActionParams): Promise { - const res = await this.runApi({ - body: JSON.stringify({ ...formatGeminiPayload(messages, temperature), tools }), - model, - signal, - timeout, - raw: true, - }); + public async invokeAIRaw( + { messages, model, temperature = 0, signal, timeout, tools }: InvokeAIRawActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.runApi( + { + body: JSON.stringify({ ...formatGeminiPayload(messages, temperature), tools }), + model, + signal, + timeout, + raw: true, + }, + connectorUsageCollector + ); return res; } @@ -320,22 +320,28 @@ export class GeminiConnector extends SubActionConnector { * @param messages An array of messages to be sent to the API * @param model Optional model to be used for the API request. If not provided, the default model from the connector will be used. */ - public async invokeStream({ - messages, - model, - stopSequences, - temperature = 0, - signal, - timeout, - tools, - }: InvokeAIActionParams): Promise { - return (await this.streamAPI({ - body: JSON.stringify({ ...formatGeminiPayload(messages, temperature), tools }), + public async invokeStream( + { + messages, model, stopSequences, + temperature = 0, signal, timeout, - })) as unknown as IncomingMessage; + tools, + }: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + return (await this.streamAPI( + { + body: JSON.stringify({ ...formatGeminiPayload(messages, temperature), tools }), + model, + stopSequences, + signal, + timeout, + }, + connectorUsageCollector + )) as unknown as IncomingMessage; } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/index.ts index 630c0973935cd..1481ab8601fa6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/index.ts @@ -95,7 +95,15 @@ async function executor( ExecutorParams > ): Promise> { - const { actionId, config, params, secrets, configurationUtilities, logger } = execOptions; + const { + actionId, + config, + params, + secrets, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const { subAction, subActionParams } = params as ExecutorParams; let data: JiraExecutorResultData | null = null; @@ -105,7 +113,8 @@ async function executor( secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); if (!api[subAction]) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts index 34e0f1f799ce5..5e98bdc96c0ee 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.test.ts @@ -14,6 +14,7 @@ import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { getBasicAuthHeader } from '@kbn/actions-plugin/server'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; interface ResponseError extends Error { @@ -135,8 +136,13 @@ const mockOldAPI = () => describe('Jira service', () => { let service: ExternalService; + let connectorUsageCollector: ConnectorUsageCollector; beforeAll(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService( { // The trailing slash at the end of the url is intended. @@ -145,7 +151,8 @@ describe('Jira service', () => { secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); }); @@ -162,7 +169,8 @@ describe('Jira service', () => { secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -175,7 +183,8 @@ describe('Jira service', () => { secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -188,7 +197,8 @@ describe('Jira service', () => { secrets: { apiToken: 'token' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -201,7 +211,8 @@ describe('Jira service', () => { secrets: { email: 'elastic@elastic.com' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -213,7 +224,8 @@ describe('Jira service', () => { secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); expect(axios.create).toHaveBeenCalledWith({ @@ -258,6 +270,7 @@ describe('Jira service', () => { url: 'https://coolsite.net/rest/api/2/issue/1', logger, configurationUtilities, + connectorUsageCollector, }); }); @@ -401,6 +414,7 @@ describe('Jira service', () => { priority: { name: 'High' }, }, }, + connectorUsageCollector, }); }); @@ -459,6 +473,7 @@ describe('Jira service', () => { priority: { name: 'High' }, }, }, + connectorUsageCollector, }); }); @@ -492,6 +507,7 @@ describe('Jira service', () => { parent: { key: 'RJ-107' }, }, }, + connectorUsageCollector, }); }); @@ -561,6 +577,7 @@ describe('Jira service', () => { ...otherFields, }, }, + connectorUsageCollector, }); }); }); @@ -631,6 +648,7 @@ describe('Jira service', () => { parent: { key: 'RJ-107' }, }, }, + connectorUsageCollector, }); }); @@ -693,6 +711,7 @@ describe('Jira service', () => { ...otherFields, }, }, + connectorUsageCollector, }); }); }); @@ -746,6 +765,7 @@ describe('Jira service', () => { configurationUtilities, url: 'https://coolsite.net/rest/api/2/issue/1/comment', data: { body: 'comment' }, + connectorUsageCollector, }); }); @@ -802,6 +822,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: 'https://coolsite.net/rest/capabilities', + connectorUsageCollector, }); }); @@ -883,6 +904,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: 'https://coolsite.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields', + connectorUsageCollector, }); }); @@ -957,6 +979,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: 'https://coolsite.net/rest/api/2/issue/createmeta/CK/issuetypes', + connectorUsageCollector, }); }); @@ -1032,6 +1055,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: 'https://coolsite.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields', + connectorUsageCollector, }); }); @@ -1240,6 +1264,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: `https://coolsite.net/rest/api/2/search?jql=project%3D%22CK%22%20and%20summary%20~%22Test%20title%22`, + connectorUsageCollector, }); }); @@ -1266,6 +1291,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: `https://coolsite.net/rest/api/2/search?jql=project%3D%22CK%22%20and%20summary%20~%22%5C%5C%5Bth%5C%5C!s%5C%5C%5Eis%5C%5C(%5C%5C)a%5C%5C-te%5C%5C%2Bst%5C%5C-%5C%5C%7B%5C%5C~is%5C%5C*s%5C%5C%26ue%5C%5C%3For%5C%5C%7Cand%5C%5Cbye%5C%5C%3A%5C%5C%7D%5C%5C%5D%5C%5C%7D%5C%5C%5D%22`, + connectorUsageCollector, }); }); @@ -1344,6 +1370,7 @@ describe('Jira service', () => { method: 'get', configurationUtilities, url: `https://coolsite.net/rest/api/2/issue/RJ-107`, + connectorUsageCollector, }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts index 3cd5115234da1..064667558b37e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/service.ts @@ -16,6 +16,7 @@ import { } from '@kbn/actions-plugin/server/lib/axios_utils'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import { getBasicAuthHeader } from '@kbn/actions-plugin/server'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { CreateCommentParams, CreateIncidentParams, @@ -47,7 +48,8 @@ const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-field export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): ExternalService => { const { apiUrl: url, projectKey } = config as JiraPublicConfigurationType; const { apiToken, email } = secrets as JiraSecretConfigurationType; @@ -189,6 +191,7 @@ export const createExternalService = ( url: `${incidentUrl}/${id}`, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -242,6 +245,7 @@ export const createExternalService = ( fields, }, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -288,6 +292,7 @@ export const createExternalService = ( logger, data: { fields }, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -326,6 +331,7 @@ export const createExternalService = ( logger, data: { body: comment.comment }, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -358,6 +364,7 @@ export const createExternalService = ( url: capabilitiesUrl, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -389,6 +396,7 @@ export const createExternalService = ( url: getIssueTypesOldAPIURL, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -404,6 +412,7 @@ export const createExternalService = ( url: getIssueTypesUrl, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -436,6 +445,7 @@ export const createExternalService = ( url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId), logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -515,6 +525,7 @@ export const createExternalService = ( url: query, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -543,6 +554,7 @@ export const createExternalService = ( url: getIssueUrl, logger, configurationUtilities, + connectorUsageCollector, }); throwIfResponseIsNotValid({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts index 311de65eb4abe..4f6133891c431 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts @@ -12,6 +12,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { connectorTokenClientMock } from '@kbn/actions-plugin/server/lib/connector_token_client.mock'; import { snExternalServiceConfig } from './config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; const connectorTokenClient = connectorTokenClientMock.create(); @@ -19,10 +20,15 @@ const configurationUtilities = actionsConfigMock.create(); jest.mock('axios'); axios.create = jest.fn(() => axios); +let connectorUsageCollector: ConnectorUsageCollector; describe('createServiceWrapper', () => { beforeEach(() => { jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); test('creates axios instance with apiUrl', () => { @@ -45,6 +51,7 @@ describe('createServiceWrapper', () => { serviceConfig, connectorTokenClient, createServiceFn, + connectorUsageCollector, }); expect(createServiceFn).toHaveBeenCalledWith({ @@ -53,6 +60,7 @@ describe('createServiceWrapper', () => { configurationUtilities, serviceConfig, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -76,6 +84,7 @@ describe('createServiceWrapper', () => { serviceConfig, connectorTokenClient, createServiceFn, + connectorUsageCollector, }); expect(createServiceFn).toHaveBeenCalledWith({ @@ -84,6 +93,7 @@ describe('createServiceWrapper', () => { configurationUtilities, serviceConfig, axiosInstance: axios, + connectorUsageCollector, }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts index f2de6e3787f70..dbadbf66f8d5f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts @@ -6,7 +6,10 @@ */ import { Logger } from '@kbn/core/server'; -import type { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; +import { + ConnectorUsageCollector, + ConnectorTokenClientContract, +} from '@kbn/actions-plugin/server/types'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import { ExternalService, ExternalServiceCredentials, SNProductsConfigValue } from './types'; @@ -21,6 +24,7 @@ interface CreateServiceWrapperOpts { serviceConfig: SNProductsConfigValue; connectorTokenClient: ConnectorTokenClientContract; createServiceFn: ServiceFactory; + connectorUsageCollector: ConnectorUsageCollector; } export function createServiceWrapper({ @@ -31,6 +35,7 @@ export function createServiceWrapper({ serviceConfig, connectorTokenClient, createServiceFn, + connectorUsageCollector, }: CreateServiceWrapperOpts): T { const { config } = credentials; const { apiUrl: url } = config as ServiceNowPublicConfigurationType; @@ -50,5 +55,6 @@ export function createServiceWrapper({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }); } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts index ce6f33e1cc0d1..aa8d248566d9a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from './mocks'; import { snExternalServiceConfig } from './config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios', () => ({ @@ -178,6 +179,7 @@ const expectImportedIncident = (update: boolean) => { configurationUtilities, url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', method: 'get', + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -191,6 +193,7 @@ const expectImportedIncident = (update: boolean) => { u_description: 'desc', ...(update ? { elastic_incident_id: '1' } : {}), }, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -199,14 +202,21 @@ const expectImportedIncident = (update: boolean) => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }; describe('ServiceNow service', () => { let service: ExternalService; + let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + service = createExternalService({ credentials: { // The trailing slash at the end of the url is intended. @@ -218,6 +228,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }); }); @@ -233,6 +244,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -273,6 +285,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -437,6 +450,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -464,6 +478,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector, }); }); @@ -477,6 +492,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -490,6 +506,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); }); @@ -535,6 +552,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id', method: 'get', + connectorUsageCollector, }); }); @@ -559,6 +577,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -572,6 +591,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id', method: 'get', + connectorUsageCollector, }); }); @@ -625,6 +645,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-sir'], axiosInstance: axios, + connectorUsageCollector, }); const res = await createIncident(service); @@ -635,6 +656,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -644,6 +666,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc' }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -652,6 +675,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -707,6 +731,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc', foo: 'test' }, + connectorUsageCollector, }); }); }); @@ -723,6 +748,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -749,6 +775,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/incident', method: 'post', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); }); @@ -762,6 +789,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); mockIncidentResponse(false); @@ -778,6 +806,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/sn_si_incident', method: 'post', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -826,6 +855,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-sir'], axiosInstance: axios, + connectorUsageCollector, }); const res = await updateIncident(service); @@ -835,6 +865,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -844,6 +875,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -852,6 +884,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -915,6 +948,7 @@ describe('ServiceNow service', () => { elastic_incident_id: '1', foo: 'test', }, + connectorUsageCollector, }); }); }); @@ -931,6 +965,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -958,6 +993,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/incident/1', method: 'patch', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); }); @@ -971,6 +1007,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); mockIncidentResponse(false); @@ -988,6 +1025,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'patch', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -1032,6 +1070,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -1040,6 +1079,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -1054,6 +1094,7 @@ describe('ServiceNow service', () => { u_state: '7', u_close_notes: 'Closed by Caller', }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(4, { @@ -1062,6 +1103,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector, }); expect(res?.url).toEqual('https://example.com/nav_to.do?uri=incident.do?sys_id=1'); @@ -1097,6 +1139,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -1105,6 +1148,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -1119,6 +1163,7 @@ describe('ServiceNow service', () => { u_state: '7', u_close_notes: 'Closed by Caller', }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(4, { @@ -1127,6 +1172,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector, }); expect(res?.url).toEqual('https://example.com/nav_to.do?uri=incident.do?sys_id=1'); @@ -1237,6 +1283,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -1268,6 +1315,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); mockIncidentResponse(false); @@ -1285,6 +1333,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -1298,6 +1347,7 @@ describe('ServiceNow service', () => { state: '7', close_notes: 'Closed by Caller', }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -1306,6 +1356,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(res?.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -1325,6 +1376,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + connectorUsageCollector, }); }); @@ -1346,6 +1398,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -1358,6 +1411,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + connectorUsageCollector, }); }); @@ -1394,6 +1448,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + connectorUsageCollector, }); }); @@ -1415,6 +1470,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -1428,6 +1484,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + connectorUsageCollector, }); }); @@ -1520,6 +1577,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); await service.checkIfApplicationIsInstalled(); expect(requestMock).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts index 42aed9dcf8466..84a8592aaa832 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts @@ -37,6 +37,7 @@ export const createExternalService: ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }): ExternalService => { const { config, secrets } = credentials; const { table, importSetTable, useImportAPI, appScope } = serviceConfig; @@ -132,6 +133,7 @@ export const createExternalService: ServiceFactory = ({ logger, configurationUtilities, method: 'get', + connectorUsageCollector, // TODO check if this is internal }); checkInstance(res); @@ -160,6 +162,7 @@ export const createExternalService: ServiceFactory = ({ logger, configurationUtilities, method: 'get', + connectorUsageCollector, }); checkInstance(res); @@ -178,6 +181,7 @@ export const createExternalService: ServiceFactory = ({ logger, params, configurationUtilities, + connectorUsageCollector, }); checkInstance(res); @@ -201,6 +205,7 @@ export const createExternalService: ServiceFactory = ({ method: 'post', data: prepareIncident(useTableApi, incident), configurationUtilities, + connectorUsageCollector, }); checkInstance(res); @@ -240,6 +245,7 @@ export const createExternalService: ServiceFactory = ({ ...(useTableApi ? {} : { elastic_incident_id: incidentId }), }, configurationUtilities, + connectorUsageCollector, }); checkInstance(res); @@ -272,6 +278,7 @@ export const createExternalService: ServiceFactory = ({ method: 'get', logger, configurationUtilities, + connectorUsageCollector, }); checkInstance(res); @@ -350,6 +357,7 @@ export const createExternalService: ServiceFactory = ({ url: fieldsUrl, logger, configurationUtilities, + connectorUsageCollector, }); checkInstance(res); @@ -367,6 +375,7 @@ export const createExternalService: ServiceFactory = ({ url: getChoicesURL(fields), logger, configurationUtilities, + connectorUsageCollector, }); checkInstance(res); return res.data.result; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts index 86d037c324e41..7cecf0bcddb46 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts @@ -11,7 +11,7 @@ import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; -import { ValidatorServices } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, ValidatorServices } from '@kbn/actions-plugin/server/types'; import { ExecutorParamsSchemaITSM, ExecutorSubActionCommonFieldsParamsSchema, @@ -305,6 +305,7 @@ interface ServiceFactoryOpts { configurationUtilities: ActionsConfigurationUtilities; serviceConfig: SNProductsConfigValue; axiosInstance: AxiosInstance; + connectorUsageCollector: ConnectorUsageCollector; } export type ServiceFactory = ({ @@ -313,6 +314,7 @@ export type ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }: ServiceFactoryOpts) => T; /** diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts index 6f0974fe1796d..87dacaf4e6f17 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.test.ts @@ -19,6 +19,7 @@ import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { RunActionResponseSchema, StreamingResponseSchema } from '../../../common/openai/schema'; import { initDashboard } from '../lib/gen_ai/create_gen_ai_dashboard'; import { PassThrough, Transform } from 'stream'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('../lib/gen_ai/create_gen_ai_dashboard'); const mockTee = jest.fn(); @@ -46,6 +47,9 @@ jest.mock('openai', () => ({ describe('OpenAIConnector', () => { let mockRequest: jest.Mock; let mockError: jest.Mock; + let connectorUsageCollector: ConnectorUsageCollector; + + const logger = loggingSystemMock.createLogger(); const mockResponseString = 'Hello! How can I assist you today?'; const mockResponse = { headers: {}, @@ -72,6 +76,10 @@ describe('OpenAIConnector', () => { }, }; beforeEach(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); mockRequest = jest.fn().mockResolvedValue(mockResponse); mockError = jest.fn().mockImplementation(() => { throw new Error('API Error'); @@ -92,7 +100,7 @@ describe('OpenAIConnector', () => { }, }, secrets: { apiKey: '123' }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); @@ -113,48 +121,74 @@ describe('OpenAIConnector', () => { describe('runApi', () => { it('uses the default model if none is supplied', async () => { - const response = await connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }); + const response = await connector.runApi( + { body: JSON.stringify(sampleOpenAiBody) }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); it('overrides the default model with the default model specified in the body', async () => { const requestBody = { model: 'gpt-3.5-turbo', ...sampleOpenAiBody }; - const response = await connector.runApi({ body: JSON.stringify(requestBody) }); + const response = await connector.runApi( + { body: JSON.stringify(requestBody) }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...requestBody, stream: false }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ ...requestBody, stream: false }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); it('the OpenAI API call is successful with correct parameters', async () => { - const response = await connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }); + const response = await connector.runApi( + { body: JSON.stringify(sampleOpenAiBody) }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); @@ -168,25 +202,31 @@ describe('OpenAIConnector', () => { }, ], }; - const response = await connector.runApi({ - body: JSON.stringify({ - ...body, - stream: true, - }), - }); + const response = await connector.runApi( + { + body: JSON.stringify({ + ...body, + stream: true, + }), + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ - ...body, - stream: false, - }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...body, + stream: false, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); @@ -194,51 +234,71 @@ describe('OpenAIConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.runApi({ body: JSON.stringify(sampleOpenAiBody) })).rejects.toThrow( - 'API Error' - ); + await expect( + connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); describe('streamApi', () => { it('the OpenAI API call is successful with correct parameters when stream = false', async () => { - const response = await connector.streamApi({ - body: JSON.stringify(sampleOpenAiBody), - stream: false, - }); + const response = await connector.streamApi( + { + body: JSON.stringify(sampleOpenAiBody), + stream: false, + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: RunActionResponseSchema, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: RunActionResponseSchema, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); it('the OpenAI API call is successful with correct parameters when stream = true', async () => { - const response = await connector.streamApi({ - body: JSON.stringify(sampleOpenAiBody), - stream: true, - }); + const response = await connector.streamApi( + { + body: JSON.stringify(sampleOpenAiBody), + stream: true, + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - responseType: 'stream', - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ ...sampleOpenAiBody, stream: true, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual({ headers: { 'Content-Type': 'dont-compress-this' }, ...mockResponse.data, @@ -255,29 +315,35 @@ describe('OpenAIConnector', () => { }, ], }; - const response = await connector.streamApi({ - body: JSON.stringify({ - ...body, - stream: false, - }), - stream: true, - }); - expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - responseType: 'stream', - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - ...body, + const response = await connector.streamApi( + { + body: JSON.stringify({ + ...body, + stream: false, + }), stream: true, - }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', }, - }); + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + ...body, + stream: true, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); expect(response).toEqual({ headers: { 'Content-Type': 'dont-compress-this' }, ...mockResponse.data, @@ -289,7 +355,10 @@ describe('OpenAIConnector', () => { connector.request = mockError; await expect( - connector.streamApi({ body: JSON.stringify(sampleOpenAiBody), stream: true }) + connector.streamApi( + { body: JSON.stringify(sampleOpenAiBody), stream: true }, + connectorUsageCollector + ) ).rejects.toThrow('API Error'); }); }); @@ -314,135 +383,181 @@ describe('OpenAIConnector', () => { }); it('the API call is successful with correct request parameters', async () => { - await connector.invokeStream(sampleOpenAiBody); + await connector.invokeStream(sampleOpenAiBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: StreamingResponseSchema, - responseType: 'stream', - data: JSON.stringify({ ...sampleOpenAiBody, stream: true, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); }); it('signal is properly passed to streamApi', async () => { const signal = jest.fn(); - await connector.invokeStream({ ...sampleOpenAiBody, signal }); - - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: StreamingResponseSchema, - responseType: 'stream', - data: JSON.stringify({ ...sampleOpenAiBody, stream: true, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', - }, - signal, - }); + await connector.invokeStream({ ...sampleOpenAiBody, signal }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + signal, + }, + connectorUsageCollector + ); }); it('timeout is properly passed to streamApi', async () => { const timeout = 180000; - await connector.invokeStream({ ...sampleOpenAiBody, timeout }); - - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.openai.com/v1/chat/completions', - method: 'post', - responseSchema: StreamingResponseSchema, - responseType: 'stream', - data: JSON.stringify({ ...sampleOpenAiBody, stream: true, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', - }, - timeout, - }); + await connector.invokeStream({ ...sampleOpenAiBody, timeout }, connectorUsageCollector); + + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://api.openai.com/v1/chat/completions', + method: 'post', + responseSchema: StreamingResponseSchema, + responseType: 'stream', + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: true, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + timeout, + }, + connectorUsageCollector + ); }); it('errors during API calls are properly handled', async () => { // @ts-ignore connector.request = mockError; - await expect(connector.invokeStream(sampleOpenAiBody)).rejects.toThrow('API Error'); + await expect( + connector.invokeStream(sampleOpenAiBody, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); it('responds with a readable stream', async () => { // @ts-ignore connector.request = mockStream(); - const response = await connector.invokeStream(sampleOpenAiBody); + const response = await connector.invokeStream(sampleOpenAiBody, connectorUsageCollector); expect(response instanceof PassThrough).toEqual(true); }); }); describe('invokeAI', () => { it('the API call is successful with correct parameters', async () => { - const response = await connector.invokeAI(sampleOpenAiBody); + const response = await connector.invokeAI(sampleOpenAiBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response.message).toEqual(mockResponseString); expect(response.usage.total_tokens).toEqual(9); }); it('signal is properly passed to runApi', async () => { const signal = jest.fn(); - await connector.invokeAI({ ...sampleOpenAiBody, signal }); + await connector.invokeAI({ ...sampleOpenAiBody, signal }, connectorUsageCollector); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + signal, }, - signal, - }); + connectorUsageCollector + ); }); it('timeout is properly passed to runApi', async () => { const timeout = 180000; - await connector.invokeAI({ ...sampleOpenAiBody, timeout }); + await connector.invokeAI({ ...sampleOpenAiBody, timeout }, connectorUsageCollector); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'X-My-Custom-Header': 'foo', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'X-My-Custom-Header': 'foo', + 'content-type': 'application/json', + }, + timeout, }, - timeout, - }); + connectorUsageCollector + ); }); it('errors during API calls are properly handled', async () => { // @ts-ignore connector.request = mockError; - await expect(connector.invokeAI(sampleOpenAiBody)).rejects.toThrow('API Error'); + await expect(connector.invokeAI(sampleOpenAiBody, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); }); }); describe('invokeAsyncIterator', () => { it('the API call is successful with correct request parameters', async () => { - await connector.invokeAsyncIterator(sampleOpenAiBody); + await connector.invokeAsyncIterator(sampleOpenAiBody, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(0); expect(mockCreate).toHaveBeenCalledWith( { @@ -457,7 +572,10 @@ describe('OpenAIConnector', () => { it('signal and timeout is properly passed', async () => { const timeout = 180000; const signal = jest.fn(); - await connector.invokeAsyncIterator({ ...sampleOpenAiBody, signal, timeout }); + await connector.invokeAsyncIterator( + { ...sampleOpenAiBody, signal, timeout }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(0); expect(mockCreate).toHaveBeenCalledWith( { @@ -478,7 +596,9 @@ describe('OpenAIConnector', () => { throw new Error('API Error'); }); - await expect(connector.invokeAsyncIterator(sampleOpenAiBody)).rejects.toThrow('API Error'); + await expect( + connector.invokeAsyncIterator(sampleOpenAiBody, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); describe('getResponseErrorMessage', () => { @@ -568,16 +688,26 @@ describe('OpenAIConnector', () => { describe('runApi', () => { it('uses the default model if none is supplied', async () => { - const response = await connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }); + const response = await connector.runApi( + { body: JSON.stringify(sampleOpenAiBody) }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - data: JSON.stringify({ ...sampleOpenAiBody, stream: false, model: DEFAULT_OPENAI_MODEL }), - headers: { - Authorization: 'Bearer 123', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + data: JSON.stringify({ + ...sampleOpenAiBody, + stream: false, + model: DEFAULT_OPENAI_MODEL, + }), + headers: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); }); @@ -614,17 +744,23 @@ describe('OpenAIConnector', () => { describe('runApi', () => { it('test the AzureAI API call is successful with correct parameters', async () => { - const response = await connector.runApi({ body: JSON.stringify(sampleAzureAiBody) }); + const response = await connector.runApi( + { body: JSON.stringify(sampleAzureAiBody) }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', - data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), - headers: { - 'api-key': '123', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', + data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); @@ -637,19 +773,25 @@ describe('OpenAIConnector', () => { }, ], }; - const response = await connector.runApi({ - body: JSON.stringify({ ...body, stream: true }), - }); + const response = await connector.runApi( + { + body: JSON.stringify({ ...body, stream: true }), + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - ...mockDefaults, - url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', - data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), - headers: { - 'api-key': '123', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + ...mockDefaults, + url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', + data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); @@ -657,49 +799,61 @@ describe('OpenAIConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.runApi({ body: JSON.stringify(sampleAzureAiBody) })).rejects.toThrow( - 'API Error' - ); + await expect( + connector.runApi({ body: JSON.stringify(sampleAzureAiBody) }, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); describe('streamApi', () => { it('the AzureAI API call is successful with correct parameters when stream = false', async () => { - const response = await connector.streamApi({ - body: JSON.stringify(sampleAzureAiBody), - stream: false, - }); + const response = await connector.streamApi( + { + body: JSON.stringify(sampleAzureAiBody), + stream: false, + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', - method: 'post', - responseSchema: RunActionResponseSchema, - data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), - headers: { - 'api-key': '123', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', + method: 'post', + responseSchema: RunActionResponseSchema, + data: JSON.stringify({ ...sampleAzureAiBody, stream: false }), + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); it('the AzureAI API call is successful with correct parameters when stream = true', async () => { - const response = await connector.streamApi({ - body: JSON.stringify(sampleAzureAiBody), - stream: true, - }); + const response = await connector.streamApi( + { + body: JSON.stringify(sampleAzureAiBody), + stream: true, + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - responseType: 'stream', - url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ ...sampleAzureAiBody, stream: true }), - headers: { - 'api-key': '123', - 'content-type': 'application/json', + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ ...sampleAzureAiBody, stream: true }), + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual({ headers: { 'Content-Type': 'dont-compress-this' }, ...mockResponse.data, @@ -715,25 +869,31 @@ describe('OpenAIConnector', () => { }, ], }; - const response = await connector.streamApi({ - body: JSON.stringify({ ...body, stream: false }), - stream: true, - }); - expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - responseType: 'stream', - url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', - method: 'post', - responseSchema: StreamingResponseSchema, - data: JSON.stringify({ - ...body, + const response = await connector.streamApi( + { + body: JSON.stringify({ ...body, stream: false }), stream: true, - }), - headers: { - 'api-key': '123', - 'content-type': 'application/json', }, - }); + connectorUsageCollector + ); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + { + responseType: 'stream', + url: 'https://My-test-resource-123.openai.azure.com/openai/deployments/NEW-DEPLOYMENT-321/chat/completions?api-version=2023-05-15', + method: 'post', + responseSchema: StreamingResponseSchema, + data: JSON.stringify({ + ...body, + stream: true, + }), + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, + }, + connectorUsageCollector + ); expect(response).toEqual({ headers: { 'Content-Type': 'dont-compress-this' }, ...mockResponse.data, @@ -745,7 +905,10 @@ describe('OpenAIConnector', () => { connector.request = mockError; await expect( - connector.streamApi({ body: JSON.stringify(sampleAzureAiBody), stream: true }) + connector.streamApi( + { body: JSON.stringify(sampleAzureAiBody), stream: true }, + connectorUsageCollector + ) ).rejects.toThrow('API Error'); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.ts b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.ts index 544b6bf7092c2..6cadc322a3d78 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/openai/openai.ts @@ -16,6 +16,7 @@ import { ChatCompletionMessageParam, } from 'openai/resources/chat/completions'; import { Stream } from 'openai/streaming'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { removeEndpointFromUrl } from './lib/openai_utils'; import { RunActionParamsSchema, @@ -156,7 +157,11 @@ export class OpenAIConnector extends SubActionConnector { * responsible for making a POST request to the external API endpoint and returning the response data * @param body The stringified request body to be sent in the POST request. */ - public async runApi({ body, signal, timeout }: RunActionParams): Promise { + + public async runApi( + { body, signal, timeout }: RunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { const sanitizedBody = sanitizeRequest( this.provider, this.url, @@ -164,20 +169,23 @@ export class OpenAIConnector extends SubActionConnector { ...('defaultModel' in this.config ? [this.config.defaultModel] : []) ); const axiosOptions = getAxiosOptions(this.provider, this.key, false); - const response = await this.request({ - url: this.url, - method: 'post', - responseSchema: RunActionResponseSchema, - data: sanitizedBody, - signal, - // give up to 2 minutes for response - timeout: timeout ?? DEFAULT_TIMEOUT_MS, - ...axiosOptions, - headers: { - ...this.config.headers, - ...axiosOptions.headers, + const response = await this.request( + { + url: this.url, + method: 'post', + responseSchema: RunActionResponseSchema, + data: sanitizedBody, + signal, + // give up to 2 minutes for response + timeout: timeout ?? DEFAULT_TIMEOUT_MS, + ...axiosOptions, + headers: { + ...this.config.headers, + ...axiosOptions.headers, + }, }, - }); + connectorUsageCollector + ); return response.data; } @@ -189,12 +197,10 @@ export class OpenAIConnector extends SubActionConnector { * @param body request body for the API request * @param stream flag indicating whether it is a streaming request or not */ - public async streamApi({ - body, - stream, - signal, - timeout, - }: StreamActionParams): Promise { + public async streamApi( + { body, stream, signal, timeout }: StreamActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { const executeBody = getRequestWithStreamOption( this.provider, this.url, @@ -205,19 +211,22 @@ export class OpenAIConnector extends SubActionConnector { const axiosOptions = getAxiosOptions(this.provider, this.key, stream); - const response = await this.request({ - url: this.url, - method: 'post', - responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema, - data: executeBody, - signal, - ...axiosOptions, - headers: { - ...this.config.headers, - ...axiosOptions.headers, + const response = await this.request( + { + url: this.url, + method: 'post', + responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema, + data: executeBody, + signal, + ...axiosOptions, + headers: { + ...this.config.headers, + ...axiosOptions.headers, + }, + timeout, }, - timeout, - }); + connectorUsageCollector + ); return stream ? pipeStreamingResponse(response) : response.data; } @@ -264,15 +273,21 @@ export class OpenAIConnector extends SubActionConnector { * returned directly to the client for streaming * @param body - the OpenAI Invoke request body */ - public async invokeStream(body: InvokeAIActionParams): Promise { + public async invokeStream( + body: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { const { signal, timeout, ...rest } = body; - const res = (await this.streamApi({ - body: JSON.stringify(rest), - stream: true, - signal, - timeout, // do not default if not provided - })) as unknown as IncomingMessage; + const res = (await this.streamApi( + { + body: JSON.stringify(rest), + stream: true, + signal, + timeout, // do not default if not provided + }, + connectorUsageCollector + )) as unknown as IncomingMessage; return res.pipe(new PassThrough()); } @@ -286,7 +301,10 @@ export class OpenAIConnector extends SubActionConnector { * tokenCountStream: Stream; the result for token counting stream * } */ - public async invokeAsyncIterator(body: InvokeAIActionParams): Promise<{ + public async invokeAsyncIterator( + body: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise<{ consumerStream: Stream; tokenCountStream: Stream; }> { @@ -301,6 +319,8 @@ export class OpenAIConnector extends SubActionConnector { rest.model ?? ('defaultModel' in this.config ? this.config.defaultModel : DEFAULT_OPENAI_MODEL), }; + + connectorUsageCollector.addRequestBodyBytes(undefined, requestBody); const stream = await this.openAI.chat.completions.create(requestBody, { signal, timeout, // do not default if not provided @@ -323,9 +343,15 @@ export class OpenAIConnector extends SubActionConnector { * @param body - the OpenAI chat completion request body * @returns an object with the response string and the usage object */ - public async invokeAI(body: InvokeAIActionParams): Promise { + public async invokeAI( + body: InvokeAIActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { const { signal, timeout, ...rest } = body; - const res = await this.runApi({ body: JSON.stringify(rest), signal, timeout }); + const res = await this.runApi( + { body: JSON.stringify(rest), signal, timeout }, + connectorUsageCollector + ); if (res.choices && res.choices.length > 0 && res.choices[0].message?.content) { const result = res.choices[0].message.content.trim(); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.test.ts index fb11174e20ba5..821f2b3032661 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.test.ts @@ -15,6 +15,7 @@ import { MockedLogger } from '@kbn/logging-mocks'; import { OpsgenieConnectorTypeId } from '../../../common'; import { OpsgenieConnector } from './connector'; import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('axios'); @@ -36,6 +37,7 @@ describe('OpsgenieConnector', () => { let mockedActionsConfig: jest.Mocked; let logger: MockedLogger; let services: ReturnType; + let connectorUsageCollector: ConnectorUsageCollector; const defaultCreateAlertExpect = { method: 'post', @@ -75,36 +77,43 @@ describe('OpsgenieConnector', () => { logger, services, }); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); it('calls request with the correct arguments for creating an alert', async () => { - await connector.createAlert({ message: 'hello' }); + await connector.createAlert({ message: 'hello' }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ data: { message: 'hello' }, ...ignoredRequestFields, ...defaultCreateAlertExpect, + connectorUsageCollector, }); }); it('calls request without modifying the alias when it is less than 512 characters when creating an alert', async () => { - await connector.createAlert({ message: 'hello', alias: '111' }); + await connector.createAlert({ message: 'hello', alias: '111' }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ ...ignoredRequestFields, ...defaultCreateAlertExpect, data: { message: 'hello', alias: '111' }, + connectorUsageCollector, }); }); it('calls request without modifying the alias when it is equal to 512 characters when creating an alert', async () => { const alias = 'a'.repeat(512); - await connector.createAlert({ message: 'hello', alias }); + await connector.createAlert({ message: 'hello', alias }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ ...ignoredRequestFields, ...defaultCreateAlertExpect, data: { message: 'hello', alias }, + connectorUsageCollector, }); }); @@ -114,12 +123,13 @@ describe('OpsgenieConnector', () => { const hasher = crypto.createHash('sha256'); const sha256Hash = hasher.update(alias); - await connector.createAlert({ message: 'hello', alias }); + await connector.createAlert({ message: 'hello', alias }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ ...ignoredRequestFields, ...defaultCreateAlertExpect, data: { message: 'hello', alias: `sha-${sha256Hash.digest('hex')}` }, + connectorUsageCollector, }); }); @@ -129,22 +139,24 @@ describe('OpsgenieConnector', () => { const hasher = crypto.createHash('sha256'); const sha256Hash = hasher.update(alias); - await connector.closeAlert({ alias }); + await connector.closeAlert({ alias }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ ...ignoredRequestFields, ...createCloseAlertExpect(`sha-${sha256Hash.digest('hex')}`), data: {}, + connectorUsageCollector, }); }); it('calls request with the correct arguments for closing an alert', async () => { - await connector.closeAlert({ user: 'sam', alias: '111' }); + await connector.closeAlert({ user: 'sam', alias: '111' }, connectorUsageCollector); expect(requestMock.mock.calls[0][0]).toEqual({ ...ignoredRequestFields, ...createCloseAlertExpect('111'), data: { user: 'sam' }, + connectorUsageCollector, }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.ts b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.ts index cd86a8ac7542a..0963ac720c80a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/opsgenie/connector.ts @@ -9,6 +9,7 @@ import crypto from 'crypto'; import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { OpsgenieSubActions } from '../../../common'; import { CreateAlertParamsSchema, CloseAlertParamsSchema, Response } from './schema'; import { CloseAlertParams, Config, CreateAlertParams, FailureResponseType, Secrets } from './types'; @@ -67,14 +68,20 @@ export class OpsgenieConnector extends SubActionConnector { } } - public async createAlert(params: CreateAlertParams) { - const res = await this.request({ - method: 'post', - url: this.concatPathToURL('v2/alerts').toString(), - data: { ...params, ...OpsgenieConnector.createAliasObj(params.alias) }, - headers: this.createHeaders(), - responseSchema: Response, - }); + public async createAlert( + params: CreateAlertParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + const res = await this.request( + { + method: 'post', + url: this.concatPathToURL('v2/alerts').toString(), + data: { ...params, ...OpsgenieConnector.createAliasObj(params.alias) }, + headers: this.createHeaders(), + responseSchema: Response, + }, + connectorUsageCollector + ); return res.data; } @@ -107,7 +114,10 @@ export class OpsgenieConnector extends SubActionConnector { return { Authorization: `GenieKey ${this.secrets.apiKey}` }; } - public async closeAlert(params: CloseAlertParams) { + public async closeAlert( + params: CloseAlertParams, + connectorUsageCollector: ConnectorUsageCollector + ) { const newAlias = OpsgenieConnector.createAlias(params.alias); const fullURL = this.concatPathToURL(`v2/alerts/${newAlias}/close`); @@ -115,13 +125,16 @@ export class OpsgenieConnector extends SubActionConnector { const { alias, ...paramsWithoutAlias } = params; - const res = await this.request({ - method: 'post', - url: fullURL.toString(), - data: paramsWithoutAlias, - headers: this.createHeaders(), - responseSchema: Response, - }); + const res = await this.request( + { + method: 'post', + url: fullURL.toString(), + data: paramsWithoutAlias, + headers: this.createHeaders(), + responseSchema: Response, + }, + connectorUsageCollector + ); return res.data; } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.test.ts index 86cdca4740f6d..38446eefe44f1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.test.ts @@ -10,7 +10,7 @@ import moment from 'moment'; jest.mock('./post_pagerduty', () => ({ postPagerduty: jest.fn(), })); -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { validateConfig, validateSecrets, validateParams } from '@kbn/actions-plugin/server/lib'; import { postPagerduty } from './post_pagerduty'; import { Logger } from '@kbn/core/server'; @@ -31,10 +31,15 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: PagerDutyConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('get()', () => { @@ -269,6 +274,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -350,6 +356,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -458,6 +465,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -535,6 +543,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -578,6 +587,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); expect(actionResponse).toMatchInlineSnapshot(` @@ -608,6 +618,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); expect(actionResponse).toMatchInlineSnapshot(` @@ -638,6 +649,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); expect(actionResponse).toMatchInlineSnapshot(` @@ -668,6 +680,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); expect(actionResponse).toMatchInlineSnapshot(` @@ -708,6 +721,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -771,6 +785,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -837,6 +852,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; @@ -902,6 +918,7 @@ describe('execute()', () => { services, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }; const actionResponse = await connectorType.executor(executorOptions); const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.ts index cfd11d6803df8..c4d2444540cad 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/index.ts @@ -198,8 +198,16 @@ function getPagerDutyApiUrl(config: ConnectorTypeConfigType): string { async function executor( execOptions: PagerDutyConnectorTypeExecutorOptions ): Promise> { - const { actionId, config, secrets, params, services, configurationUtilities, logger } = - execOptions; + const { + actionId, + config, + secrets, + params, + services, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const apiUrl = getPagerDutyApiUrl(config); const headers = { @@ -213,7 +221,8 @@ async function executor( response = await postPagerduty( { apiUrl, data, headers, services }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); } catch (err) { const message = i18n.translate('xpack.stackConnectors.pagerduty.postingErrorMessage', { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/post_pagerduty.ts b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/post_pagerduty.ts index 0ef41637967d2..8b0937f9d857b 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/post_pagerduty.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/pagerduty/post_pagerduty.ts @@ -7,7 +7,7 @@ import axios, { AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; @@ -22,7 +22,8 @@ interface PostPagerdutyOptions { export async function postPagerduty( options: PostPagerdutyOptions, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): Promise { const { apiUrl, data, headers } = options; const axiosInstance = axios.create(); @@ -36,5 +37,6 @@ export async function postPagerduty( headers, configurationUtilities, validateStatus: () => true, + connectorUsageCollector, }); } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts index 4e031bdaafeea..6f3999dc70df7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts @@ -13,6 +13,7 @@ import { ResilientConnector } from './resilient'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { RESILIENT_CONNECTOR_ID } from './constants'; import { PushToServiceIncidentSchema } from './schema'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('axios'); jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { @@ -83,13 +84,15 @@ const mockIncidentUpdate = (withUpdateError = false) => { }) ); }; +let connectorUsageCollector: ConnectorUsageCollector; describe('IBM Resilient connector', () => { + const logger = loggingSystemMock.createLogger(); const connector = new ResilientConnector( { connector: { id: '1', type: RESILIENT_CONNECTOR_ID }, configurationUtilities: actionsConfigMock.create(), - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), config: { orgId, apiUrl }, secrets: { apiKeyId, apiKeySecret }, @@ -107,6 +110,10 @@ describe('IBM Resilient connector', () => { beforeEach(() => { jest.resetAllMocks(); jest.setSystemTime(TIMESTAMP); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('getIncident', () => { @@ -129,12 +136,12 @@ describe('IBM Resilient connector', () => { }); it('returns the incident correctly', async () => { - const res = await connector.getIncident({ id: '1' }); + const res = await connector.getIncident({ id: '1' }, connectorUsageCollector); expect(res).toEqual(incidentMock); }); it('should call request with correct arguments', async () => { - await connector.getIncident({ id: '1' }); + await connector.getIncident({ id: '1' }, connectorUsageCollector); expect(requestMock).toHaveBeenCalledWith({ ...ignoredRequestFields, method: 'GET', @@ -147,6 +154,7 @@ describe('IBM Resilient connector', () => { params: { text_content_output_format: 'objects_convert', }, + connectorUsageCollector, }); }); @@ -154,7 +162,7 @@ describe('IBM Resilient connector', () => { requestMock.mockImplementation(() => { throw new Error('An error has occurred'); }); - await expect(connector.getIncident({ id: '1' })).rejects.toThrow( + await expect(connector.getIncident({ id: '1' }, connectorUsageCollector)).rejects.toThrow( 'Unable to get incident with id 1. Error: An error has occurred' ); }); @@ -183,7 +191,7 @@ describe('IBM Resilient connector', () => { }); it('creates the incident correctly', async () => { - const res = await connector.createIncident(incidentMock); + const res = await connector.createIncident(incidentMock, connectorUsageCollector); expect(res).toEqual({ title: '1', @@ -194,7 +202,7 @@ describe('IBM Resilient connector', () => { }); it('should call request with correct arguments', async () => { - await connector.createIncident(incidentMock); + await connector.createIncident(incidentMock, connectorUsageCollector); expect(requestMock).toHaveBeenCalledWith({ ...ignoredRequestFields, @@ -214,6 +222,7 @@ describe('IBM Resilient connector', () => { Authorization: `Basic ${token}`, 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); @@ -223,12 +232,15 @@ describe('IBM Resilient connector', () => { }); await expect( - connector.createIncident({ - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }) + connector.createIncident( + { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + connectorUsageCollector + ) ).rejects.toThrow( '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' ); @@ -237,7 +249,7 @@ describe('IBM Resilient connector', () => { it('should throw if the required attributes are not received in response', async () => { requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); - await expect(connector.createIncident(incidentMock)).rejects.toThrow( + await expect(connector.createIncident(incidentMock, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to create incident. Error: Response validation failed (Error: [id]: expected value of type [number] but got [undefined]).' ); }); @@ -255,7 +267,7 @@ describe('IBM Resilient connector', () => { }; it('updates the incident correctly', async () => { mockIncidentUpdate(); - const res = await connector.updateIncident(req); + const res = await connector.updateIncident(req, connectorUsageCollector); expect(res).toEqual({ title: '1', @@ -268,15 +280,18 @@ describe('IBM Resilient connector', () => { it('should call request with correct arguments', async () => { mockIncidentUpdate(); - await connector.updateIncident({ - incidentId: '1', - incident: { - name: 'title_updated', - description: 'desc_updated', - incidentTypes: [1001], - severityCode: 5, + await connector.updateIncident( + { + incidentId: '1', + incident: { + name: 'title_updated', + description: 'desc_updated', + incidentTypes: [1001], + severityCode: 5, + }, }, - }); + connectorUsageCollector + ); expect(requestMock.mock.calls[1][0]).toEqual({ ...ignoredRequestFields, @@ -332,13 +347,14 @@ describe('IBM Resilient connector', () => { }, ], }, + connectorUsageCollector, }); }); it('it should throw an error', async () => { mockIncidentUpdate(true); - await expect(connector.updateIncident(req)).rejects.toThrow( + await expect(connector.updateIncident(req, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' ); }); @@ -361,7 +377,7 @@ describe('IBM Resilient connector', () => { ); requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); - await expect(connector.updateIncident(req)).rejects.toThrow( + await expect(connector.updateIncident(req, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to update incident with id 1. Error: Response validation failed (Error: [success]: expected value of type [boolean] but got [undefined]).' ); }); @@ -388,7 +404,7 @@ describe('IBM Resilient connector', () => { }); it('should call request with correct arguments', async () => { - await connector.addComment(req); + await connector.addComment(req, connectorUsageCollector); expect(requestMock).toHaveBeenCalledWith({ ...ignoredRequestFields, @@ -404,6 +420,7 @@ describe('IBM Resilient connector', () => { format: 'text', }, }, + connectorUsageCollector, }); }); @@ -412,7 +429,7 @@ describe('IBM Resilient connector', () => { throw new Error('An error has occurred'); }); - await expect(connector.addComment(req)).rejects.toThrow( + await expect(connector.addComment(req, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred.' ); }); @@ -428,7 +445,7 @@ describe('IBM Resilient connector', () => { }); it('should call request with correct arguments', async () => { - await connector.getIncidentTypes(); + await connector.getIncidentTypes(undefined, connectorUsageCollector); expect(requestMock).toBeCalledTimes(1); expect(requestMock).toHaveBeenCalledWith({ ...ignoredRequestFields, @@ -439,11 +456,12 @@ describe('IBM Resilient connector', () => { Authorization: `Basic ${token}`, 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); it('returns incident types correctly', async () => { - const res = await connector.getIncidentTypes(); + const res = await connector.getIncidentTypes(undefined, connectorUsageCollector); expect(res).toEqual([ { id: '17', name: 'Communication error (fax; email)' }, @@ -456,7 +474,7 @@ describe('IBM Resilient connector', () => { throw new Error('An error has occurred'); }); - await expect(connector.getIncidentTypes()).rejects.toThrow( + await expect(connector.getIncidentTypes(undefined, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' ); }); @@ -466,7 +484,7 @@ describe('IBM Resilient connector', () => { createAxiosResponse({ data: { id: '1001', name: 'Custom type' } }) ); - await expect(connector.getIncidentTypes()).rejects.toThrow( + await expect(connector.getIncidentTypes(undefined, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to get incident types. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).' ); }); @@ -484,7 +502,7 @@ describe('IBM Resilient connector', () => { }); it('should call request with correct arguments', async () => { - await connector.getSeverity(); + await connector.getSeverity(undefined, connectorUsageCollector); expect(requestMock).toBeCalledTimes(1); expect(requestMock).toHaveBeenCalledWith({ ...ignoredRequestFields, @@ -495,11 +513,12 @@ describe('IBM Resilient connector', () => { Authorization: `Basic ${token}`, 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); it('returns severity correctly', async () => { - const res = await connector.getSeverity(); + const res = await connector.getSeverity(undefined, connectorUsageCollector); expect(res).toEqual([ { @@ -522,7 +541,7 @@ describe('IBM Resilient connector', () => { throw new Error('An error has occurred'); }); - await expect(connector.getSeverity()).rejects.toThrow( + await expect(connector.getSeverity(undefined, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.' ); }); @@ -532,7 +551,7 @@ describe('IBM Resilient connector', () => { createAxiosResponse({ data: { id: '10', name: 'Critical' } }) ); - await expect(connector.getSeverity()).rejects.toThrow( + await expect(connector.getSeverity(undefined, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to get severity. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).' ); }); @@ -547,7 +566,7 @@ describe('IBM Resilient connector', () => { ); }); it('should call request with correct arguments', async () => { - await connector.getFields(); + await connector.getFields(undefined, connectorUsageCollector); expect(requestMock).toBeCalledTimes(1); expect(requestMock).toHaveBeenCalledWith({ @@ -559,11 +578,12 @@ describe('IBM Resilient connector', () => { Authorization: `Basic ${token}`, 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); it('returns common fields correctly', async () => { - const res = await connector.getFields(); + const res = await connector.getFields(undefined, connectorUsageCollector); expect(res).toEqual(resilientFields); }); @@ -571,7 +591,7 @@ describe('IBM Resilient connector', () => { requestMock.mockImplementation(() => { throw new Error('An error has occurred'); }); - await expect(connector.getFields()).rejects.toThrow( + await expect(connector.getFields(undefined, connectorUsageCollector)).rejects.toThrow( 'Unable to get fields. Error: An error has occurred' ); }); @@ -579,7 +599,7 @@ describe('IBM Resilient connector', () => { it('should throw if the required attributes are not received in response', async () => { requestMock.mockImplementation(() => createAxiosResponse({ data: { someField: 'test' } })); - await expect(connector.getFields()).rejects.toThrow( + await expect(connector.getFields(undefined, connectorUsageCollector)).rejects.toThrow( '[Action][IBM Resilient]: Unable to get fields. Error: Response validation failed (Error: expected value of type [array] but got [Object]).' ); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts index 1351488dbf892..da297369ae024 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts @@ -10,6 +10,7 @@ import { omitBy, isNil } from 'lodash/fp'; import { CaseConnector, getBasicAuthHeader, ServiceParams } from '@kbn/actions-plugin/server'; import { schema, Type } from '@kbn/config-schema'; import { getErrorMessage } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { CreateIncidentData, ExternalServiceIncidentResponse, @@ -117,7 +118,10 @@ export class ResilientConnector extends CaseConnector< return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; } - public async createIncident(incident: Incident): Promise { + public async createIncident( + incident: Incident, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { try { let data: CreateIncidentData = { name: incident.name, @@ -150,19 +154,22 @@ export class ResilientConnector extends CaseConnector< }; } - const res = await this.request({ - url: `${this.urls.incident}?text_content_output_format=objects_convert`, - method: 'POST', - data, - headers: this.getAuthHeaders(), - responseSchema: schema.object( - { - id: schema.number(), - create_date: schema.number(), - }, - { unknowns: 'allow' } - ), - }); + const res = await this.request( + { + url: `${this.urls.incident}?text_content_output_format=objects_convert`, + method: 'POST', + data, + headers: this.getAuthHeaders(), + responseSchema: schema.object( + { + id: schema.number(), + create_date: schema.number(), + }, + { unknowns: 'allow' } + ), + }, + connectorUsageCollector + ); const { id, create_date: createDate } = res.data; @@ -179,30 +186,33 @@ export class ResilientConnector extends CaseConnector< } } - public async updateIncident({ - incidentId, - incident, - }: UpdateIncidentParams): Promise { + public async updateIncident( + { incidentId, incident }: UpdateIncidentParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { try { - const latestIncident = await this.getIncident({ id: incidentId }); + const latestIncident = await this.getIncident({ id: incidentId }, connectorUsageCollector); // Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty. const newIncident = omitBy(isNil, incident); const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident }); - const res = await this.request({ - method: 'PATCH', - url: `${this.urls.incident}/${incidentId}`, - data, - headers: this.getAuthHeaders(), - responseSchema: schema.object({ success: schema.boolean() }, { unknowns: 'allow' }), - }); + const res = await this.request( + { + method: 'PATCH', + url: `${this.urls.incident}/${incidentId}`, + data, + headers: this.getAuthHeaders(), + responseSchema: schema.object({ success: schema.boolean() }, { unknowns: 'allow' }), + }, + connectorUsageCollector + ); if (!res.data.success) { throw new Error('Error while updating incident'); } - const updatedIncident = await this.getIncident({ id: incidentId }); + const updatedIncident = await this.getIncident({ id: incidentId }, connectorUsageCollector); return { title: `${updatedIncident.id}`, @@ -220,15 +230,21 @@ export class ResilientConnector extends CaseConnector< } } - public async addComment({ incidentId, comment }: { incidentId: string; comment: string }) { + public async addComment( + { incidentId, comment }: { incidentId: string; comment: string }, + connectorUsageCollector: ConnectorUsageCollector + ) { try { - await this.request({ - method: 'POST', - url: this.urls.comment.replace('{inc_id}', incidentId), - data: { text: { format: 'text', content: comment } }, - headers: this.getAuthHeaders(), - responseSchema: schema.object({}, { unknowns: 'allow' }), - }); + await this.request( + { + method: 'POST', + url: this.urls.comment.replace('{inc_id}', incidentId), + data: { text: { format: 'text', content: comment } }, + headers: this.getAuthHeaders(), + responseSchema: schema.object({}, { unknowns: 'allow' }), + }, + connectorUsageCollector + ); } catch (error) { throw new Error( getErrorMessage( @@ -239,17 +255,23 @@ export class ResilientConnector extends CaseConnector< } } - public async getIncident({ id }: { id: string }): Promise { + public async getIncident( + { id }: { id: string }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { try { - const res = await this.request({ - method: 'GET', - url: `${this.urls.incident}/${id}`, - params: { - text_content_output_format: 'objects_convert', + const res = await this.request( + { + method: 'GET', + url: `${this.urls.incident}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, + headers: this.getAuthHeaders(), + responseSchema: GetIncidentResponseSchema, }, - headers: this.getAuthHeaders(), - responseSchema: GetIncidentResponseSchema, - }); + connectorUsageCollector + ); return res.data; } catch (error) { @@ -259,14 +281,20 @@ export class ResilientConnector extends CaseConnector< } } - public async getIncidentTypes(): Promise { + public async getIncidentTypes( + params: unknown, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { try { - const res = await this.request({ - method: 'GET', - url: this.urls.incidentTypes, - headers: this.getAuthHeaders(), - responseSchema: GetIncidentTypesResponseSchema, - }); + const res = await this.request( + { + method: 'GET', + url: this.urls.incidentTypes, + headers: this.getAuthHeaders(), + responseSchema: GetIncidentTypesResponseSchema, + }, + connectorUsageCollector + ); const incidentTypes = res.data?.values ?? []; @@ -281,14 +309,20 @@ export class ResilientConnector extends CaseConnector< } } - public async getSeverity(): Promise { + public async getSeverity( + params: unknown, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { try { - const res = await this.request({ - method: 'GET', - url: this.urls.severity, - headers: this.getAuthHeaders(), - responseSchema: GetSeverityResponseSchema, - }); + const res = await this.request( + { + method: 'GET', + url: this.urls.severity, + headers: this.getAuthHeaders(), + responseSchema: GetSeverityResponseSchema, + }, + connectorUsageCollector + ); const severities = res.data?.values ?? []; return severities.map((type: { value: number; label: string }) => ({ @@ -302,14 +336,17 @@ export class ResilientConnector extends CaseConnector< } } - public async getFields() { + public async getFields(params: unknown, connectorUsageCollector: ConnectorUsageCollector) { try { - const res = await this.request({ - method: 'GET', - url: this.getIncidentFieldsUrl(), - headers: this.getAuthHeaders(), - responseSchema: GetCommonFieldsResponseSchema, - }); + const res = await this.request( + { + method: 'GET', + url: this.getIncidentFieldsUrl(), + headers: this.getAuthHeaders(), + responseSchema: GetCommonFieldsResponseSchema, + }, + connectorUsageCollector + ); const fields = res.data.map((field) => { return { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.test.ts index ced2784f057a6..8a13a48e47be1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.test.ts @@ -13,12 +13,20 @@ import { } from '../../../common/sentinelone/types'; import { API_PATH } from './sentinelone'; import { SentinelOneGetActivitiesResponseSchema } from '../../../common/sentinelone/schema'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; describe('SentinelOne Connector', () => { let connectorInstance: ReturnType; + let connectorUsageCollector: ConnectorUsageCollector; + const logger = loggingSystemMock.createLogger(); beforeEach(() => { connectorInstance = sentinelOneConnectorMocks.create(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('#fetchAgentFiles()', () => { @@ -35,15 +43,17 @@ describe('SentinelOne Connector', () => { it('should error if no agent id provided', async () => { fetchAgentFilesParams.agentId = ''; - await expect(connectorInstance.fetchAgentFiles(fetchAgentFilesParams)).rejects.toHaveProperty( - 'message', - "'agentId' parameter is required" - ); + await expect( + connectorInstance.fetchAgentFiles(fetchAgentFilesParams, connectorUsageCollector) + ).rejects.toHaveProperty('message', "'agentId' parameter is required"); }); it('should call SentinelOne fetch-files API with expected data', async () => { const fetchFilesUrl = `${connectorInstance.constructorParams.config.url}${API_PATH}/agents/${fetchAgentFilesParams.agentId}/actions/fetch-files`; - const response = await connectorInstance.fetchAgentFiles(fetchAgentFilesParams); + const response = await connectorInstance.fetchAgentFiles( + fetchAgentFilesParams, + connectorUsageCollector + ); expect(response).toEqual({ data: { success: true }, errors: null }); expect(connectorInstance.requestSpy).toHaveBeenLastCalledWith({ @@ -76,14 +86,14 @@ describe('SentinelOne Connector', () => { it('should error if called with invalid agent id', async () => { downloadAgentFileParams.agentId = ''; await expect( - connectorInstance.downloadAgentFile(downloadAgentFileParams) + connectorInstance.downloadAgentFile(downloadAgentFileParams, connectorUsageCollector) ).rejects.toHaveProperty('message', "'agentId' parameter is required"); }); it('should call SentinelOne api with expected url', async () => { - await expect(connectorInstance.downloadAgentFile(downloadAgentFileParams)).resolves.toEqual( - connectorInstance.mockResponses.downloadAgentFileApiResponse - ); + await expect( + connectorInstance.downloadAgentFile(downloadAgentFileParams, connectorUsageCollector) + ).resolves.toEqual(connectorInstance.mockResponses.downloadAgentFileApiResponse); }); }); @@ -122,7 +132,10 @@ describe('SentinelOne Connector', () => { describe('#downloadRemoteScriptResults()', () => { it('should call SentinelOne api to retrieve task results', async () => { - await connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' }); + await connectorInstance.downloadRemoteScriptResults( + { taskId: 'task-123' }, + connectorUsageCollector + ); expect(connectorInstance.requestSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -136,13 +149,19 @@ describe('SentinelOne Connector', () => { connectorInstance.mockResponses.getRemoteScriptResults.data.download_links = []; await expect( - connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' }) + connectorInstance.downloadRemoteScriptResults( + { taskId: 'task-123' }, + connectorUsageCollector + ) ).rejects.toThrow('Download URL for script results of task id [task-123] not found'); }); it('should return a Stream for downloading the file', async () => { await expect( - connectorInstance.downloadRemoteScriptResults({ taskId: 'task-123' }) + connectorInstance.downloadRemoteScriptResults( + { taskId: 'task-123' }, + connectorUsageCollector + ) ).resolves.toEqual(connectorInstance.mockResponses.downloadRemoteScriptResults); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.ts b/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.ts index 99f486b44a087..dd73bafae8d2f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/sentinelone/sentinelone.ts @@ -8,6 +8,7 @@ import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { Stream } from 'stream'; import type { SentinelOneConfig, @@ -157,67 +158,94 @@ export class SentinelOneConnector extends SubActionConnector< }); } - public async fetchAgentFiles({ files, agentId, zipPassCode }: SentinelOneFetchAgentFilesParams) { + public async fetchAgentFiles( + { files, agentId, zipPassCode }: SentinelOneFetchAgentFilesParams, + connectorUsageCollector: ConnectorUsageCollector + ) { if (!agentId) { throw new Error(`'agentId' parameter is required`); } - return this.sentinelOneApiRequest({ - url: `${this.urls.agents}/${agentId}/actions/fetch-files`, - method: 'post', - data: { + return this.sentinelOneApiRequest( + { + url: `${this.urls.agents}/${agentId}/actions/fetch-files`, + method: 'post', data: { - password: zipPassCode, - files, + data: { + password: zipPassCode, + files, + }, }, + responseSchema: SentinelOneFetchAgentFilesResponseSchema, }, - responseSchema: SentinelOneFetchAgentFilesResponseSchema, - }); + connectorUsageCollector + ); } - public async downloadAgentFile({ agentId, activityId }: SentinelOneDownloadAgentFileParams) { + public async downloadAgentFile( + { agentId, activityId }: SentinelOneDownloadAgentFileParams, + connectorUsageCollector: ConnectorUsageCollector + ) { if (!agentId) { throw new Error(`'agentId' parameter is required`); } - return this.sentinelOneApiRequest({ - url: `${this.urls.agents}/${agentId}/uploads/${activityId}`, - method: 'get', - responseType: 'stream', - responseSchema: SentinelOneDownloadAgentFileResponseSchema, - }); + return this.sentinelOneApiRequest( + { + url: `${this.urls.agents}/${agentId}/uploads/${activityId}`, + method: 'get', + responseType: 'stream', + responseSchema: SentinelOneDownloadAgentFileResponseSchema, + }, + connectorUsageCollector + ); } - public async getActivities(queryParams?: SentinelOneGetActivitiesParams) { - return this.sentinelOneApiRequest({ - url: this.urls.activities, - method: 'get', - params: queryParams, - responseSchema: SentinelOneGetActivitiesResponseSchema, - }); + public async getActivities( + queryParams?: SentinelOneGetActivitiesParams, + connectorUsageCollector?: ConnectorUsageCollector + ) { + return this.sentinelOneApiRequest( + { + url: this.urls.activities, + method: 'get', + params: queryParams, + responseSchema: SentinelOneGetActivitiesResponseSchema, + }, + connectorUsageCollector! + ); } - public async executeScript({ filter, script }: SentinelOneExecuteScriptParams) { + public async executeScript( + { filter, script }: SentinelOneExecuteScriptParams, + connectorUsageCollector: ConnectorUsageCollector + ) { if (!filter.ids && !filter.uuids) { throw new Error(`A filter must be defined; either 'ids' or 'uuids'`); } - return this.sentinelOneApiRequest({ - url: this.urls.remoteScriptsExecute, - method: 'post', - data: { + return this.sentinelOneApiRequest( + { + url: this.urls.remoteScriptsExecute, + method: 'post', data: { - outputDestination: 'SentinelCloud', - ...script, + data: { + outputDestination: 'SentinelCloud', + ...script, + }, + filter, }, - filter, + responseSchema: SentinelOneExecuteScriptResponseSchema, }, - responseSchema: SentinelOneExecuteScriptResponseSchema, - }); + connectorUsageCollector + ); } - public async isolateHost({ alertIds, ...payload }: SentinelOneIsolateHostParams) { - const response = await this.getAgents(payload); + public async isolateHost( + { alertIds, ...payload }: SentinelOneIsolateHostParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + const response = await this.getAgents(payload, connectorUsageCollector); if (response.data.length === 0) { const errorMessage = 'No agents found'; @@ -233,20 +261,26 @@ export class SentinelOneConnector extends SubActionConnector< const agentId = response.data[0].id; - return this.sentinelOneApiRequest({ - url: this.urls.isolateHost, - method: 'post', - data: { - filter: { - ids: agentId, + return this.sentinelOneApiRequest( + { + url: this.urls.isolateHost, + method: 'post', + data: { + filter: { + ids: agentId, + }, }, + responseSchema: SentinelOneIsolateHostResponseSchema, }, - responseSchema: SentinelOneIsolateHostResponseSchema, - }); + connectorUsageCollector + ); } - public async releaseHost({ alertIds, ...payload }: SentinelOneIsolateHostParams) { - const response = await this.getAgents(payload); + public async releaseHost( + { alertIds, ...payload }: SentinelOneIsolateHostParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + const response = await this.getAgents(payload, connectorUsageCollector); if (response.data.length === 0) { throw new Error('No agents found'); @@ -258,57 +292,76 @@ export class SentinelOneConnector extends SubActionConnector< const agentId = response.data[0].id; - return this.sentinelOneApiRequest({ - url: this.urls.releaseHost, - method: 'post', - data: { - filter: { - ids: agentId, + return this.sentinelOneApiRequest( + { + url: this.urls.releaseHost, + method: 'post', + data: { + filter: { + ids: agentId, + }, }, + responseSchema: SentinelOneIsolateHostResponseSchema, }, - responseSchema: SentinelOneIsolateHostResponseSchema, - }); + connectorUsageCollector + ); } public async getAgents( - payload: SentinelOneGetAgentsParams + payload: SentinelOneGetAgentsParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - return this.sentinelOneApiRequest({ - url: this.urls.agents, - params: { - ...payload, + return this.sentinelOneApiRequest( + { + url: this.urls.agents, + params: { + ...payload, + }, + responseSchema: SentinelOneGetAgentsResponseSchema, }, - responseSchema: SentinelOneGetAgentsResponseSchema, - }); + connectorUsageCollector + ); } public async getRemoteScriptStatus( - payload: SentinelOneGetRemoteScriptStatusParams + payload: SentinelOneGetRemoteScriptStatusParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - return this.sentinelOneApiRequest({ - url: this.urls.remoteScriptStatus, - params: { - parent_task_id: payload.parentTaskId, + return this.sentinelOneApiRequest( + { + url: this.urls.remoteScriptStatus, + params: { + parent_task_id: payload.parentTaskId, + }, + responseSchema: SentinelOneGetRemoteScriptStatusResponseSchema, }, - responseSchema: SentinelOneGetRemoteScriptStatusResponseSchema, - }) as unknown as SentinelOneGetRemoteScriptStatusApiResponse; + connectorUsageCollector + ) as unknown as SentinelOneGetRemoteScriptStatusApiResponse; } - public async getRemoteScriptResults({ - taskIds, - }: SentinelOneGetRemoteScriptResultsParams): Promise { - return this.sentinelOneApiRequest({ - url: this.urls.remoteScriptsResults, - method: 'post', - data: { data: { taskIds } }, - responseSchema: SentinelOneGetRemoteScriptResultsResponseSchema, - }) as unknown as SentinelOneGetRemoteScriptResultsApiResponse; + public async getRemoteScriptResults( + { taskIds }: SentinelOneGetRemoteScriptResultsParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + return this.sentinelOneApiRequest( + { + url: this.urls.remoteScriptsResults, + method: 'post', + data: { data: { taskIds } }, + responseSchema: SentinelOneGetRemoteScriptResultsResponseSchema, + }, + connectorUsageCollector + ) as unknown as SentinelOneGetRemoteScriptResultsApiResponse; } - public async downloadRemoteScriptResults({ - taskId, - }: SentinelOneDownloadRemoteScriptResultsParams): Promise { - const scriptResultsInfo = await this.getRemoteScriptResults({ taskIds: [taskId] }); + public async downloadRemoteScriptResults( + { taskId }: SentinelOneDownloadRemoteScriptResultsParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const scriptResultsInfo = await this.getRemoteScriptResults( + { taskIds: [taskId] }, + connectorUsageCollector + ); this.logger.debug( () => `script results for taskId [${taskId}]:\n${JSON.stringify(scriptResultsInfo)}` @@ -327,26 +380,33 @@ export class SentinelOneConnector extends SubActionConnector< throw new Error(`Download URL for script results of task id [${taskId}] not found`); } - const downloadConnection = await this.request({ - url: fileUrl, - method: 'get', - responseType: 'stream', - responseSchema: SentinelOneDownloadRemoteScriptResultsResponseSchema, - }); + const downloadConnection = await this.request( + { + url: fileUrl, + method: 'get', + responseType: 'stream', + responseSchema: SentinelOneDownloadRemoteScriptResultsResponseSchema, + }, + connectorUsageCollector + ); return downloadConnection.data; } private async sentinelOneApiRequest( - req: SubActionRequestParams + req: SubActionRequestParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - const response = await this.request({ - ...req, - params: { - ...req.params, - APIToken: this.secrets.token, + const response = await this.request( + { + ...req, + params: { + ...req.params, + APIToken: this.secrets.token, + }, }, - }); + connectorUsageCollector + ); return response.data; } @@ -374,15 +434,19 @@ export class SentinelOneConnector extends SubActionConnector< } public async getRemoteScripts( - payload: SentinelOneGetRemoteScriptsParams + payload: SentinelOneGetRemoteScriptsParams, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - return this.sentinelOneApiRequest({ - url: this.urls.remoteScripts, - params: { - limit: API_MAX_RESULTS, - ...payload, + return this.sentinelOneApiRequest( + { + url: this.urls.remoteScripts, + params: { + limit: API_MAX_RESULTS, + ...payload, + }, + responseSchema: SentinelOneGetRemoteScriptsResponseSchema, }, - responseSchema: SentinelOneGetRemoteScriptsResponseSchema, - }); + connectorUsageCollector + ); } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/server_log/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/server_log/index.test.ts index 082098023fc3f..29fd5d41adc26 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/server_log/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/server_log/index.test.ts @@ -6,6 +6,7 @@ */ import { validateParams } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { Logger } from '@kbn/core/server'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { getConnectorType, ServerLogConnectorType, ServerLogConnectorTypeExecutorOptions } from '.'; @@ -107,6 +108,10 @@ describe('execute()', () => { secrets: {}, configurationUtilities, logger: mockedLogger, + connectorUsageCollector: new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }), }; await connectorType.executor(executorOptions); expect(mockedLogger.info).toHaveBeenCalledWith('Server log: message text here'); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/index.ts index bbfaf902ba671..d32f52cd698ee 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/index.ts @@ -102,7 +102,15 @@ async function executorITOM( ExecutorParamsITOM > ): Promise> { - const { actionId, config, params, secrets, configurationUtilities, logger } = execOptions; + const { + actionId, + config, + params, + secrets, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const { subAction, subActionParams } = params; const connectorTokenClient = execOptions.services.connectorTokenClient; const externalServiceConfig = snExternalServiceConfig[actionTypeId]; @@ -119,6 +127,7 @@ async function executorITOM( serviceConfig: externalServiceConfig, connectorTokenClient, createServiceFn: createService, + connectorUsageCollector, }); const apiAsRecord = api as unknown as Record; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.test.ts index 01d8ed53478ca..951c2731b526d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { snExternalServiceConfig } from '../lib/servicenow/config'; import { itomEventParams, serviceNowChoices } from '../lib/servicenow/mocks'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -33,8 +34,13 @@ const configurationUtilities = actionsConfigMock.create(); describe('ServiceNow SIR service', () => { let service: ExternalServiceITOM; + let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService({ credentials: { config: { apiUrl: 'https://example.com/', isOAuth: false }, @@ -44,6 +50,7 @@ describe('ServiceNow SIR service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-itom'], axiosInstance: axios, + connectorUsageCollector, }) as ExternalServiceITOM; }); @@ -69,6 +76,7 @@ describe('ServiceNow SIR service', () => { url: 'https://example.com/api/global/em/jsonv2', method: 'post', data: { records: [itomEventParams] }, + connectorUsageCollector, }); }); }); @@ -85,6 +93,7 @@ describe('ServiceNow SIR service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=em_event^element=severity^language=en&sysparm_fields=label,value,dependent_value,element', + connectorUsageCollector, }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.ts index e096b67de7ef4..a6ed020461194 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itom/service.ts @@ -23,6 +23,7 @@ export const createExternalService: ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }): ExternalServiceITOM => { const snService = createExternalServiceCommon({ credentials, @@ -30,6 +31,7 @@ export const createExternalService: ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }); const addEvent = async (params: ExecutorSubActionAddEventParams) => { @@ -41,6 +43,7 @@ export const createExternalService: ServiceFactory = ({ method: 'post', data: { records: [params] }, configurationUtilities, + connectorUsageCollector, }); snService.checkInstance(res); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/index.ts index 0322b0e341844..6ab6bc389ac7a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/index.ts @@ -125,8 +125,16 @@ async function executor( ExecutorParams > ): Promise> { - const { actionId, config, params, secrets, services, configurationUtilities, logger } = - execOptions; + const { + actionId, + config, + params, + secrets, + services, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const { subAction, subActionParams } = params; const connectorTokenClient = services.connectorTokenClient; const externalServiceConfig = snExternalServiceConfig[actionTypeId]; @@ -143,6 +151,7 @@ async function executor( serviceConfig: externalServiceConfig, connectorTokenClient, createServiceFn: createService, + connectorUsageCollector, }); const apiAsRecord = api as unknown as Record; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/service.test.ts index 1c068dc60489d..5590da4cbfbd6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_itsm/service.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from '../lib/servicenow/mocks'; import { snExternalServiceConfig } from '../lib/servicenow/config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios', () => ({ @@ -122,6 +123,7 @@ const expectImportedIncident = (update: boolean) => { configurationUtilities, url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', method: 'get', + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -135,6 +137,7 @@ const expectImportedIncident = (update: boolean) => { u_description: 'desc', ...(update ? { elastic_incident_id: '1' } : {}), }, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -143,14 +146,20 @@ const expectImportedIncident = (update: boolean) => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }; describe('ServiceNow service', () => { let service: ExternalService; + let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService({ credentials: { // The trailing slash at the end of the url is intended. @@ -162,6 +171,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }); }); @@ -177,6 +187,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -217,6 +228,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -381,6 +393,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow'], axiosInstance: axios, + connectorUsageCollector, }) ).toThrow(); }); @@ -408,6 +421,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', + connectorUsageCollector, }); }); @@ -421,6 +435,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -434,6 +449,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); }); @@ -487,6 +503,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-sir'], axiosInstance: axios, + connectorUsageCollector, }); const res = await createIncident(service); @@ -497,6 +514,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -506,6 +524,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc' }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -514,6 +533,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -572,6 +592,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -596,6 +617,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/incident', method: 'post', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); }); @@ -609,6 +631,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); mockIncidentResponse(false); @@ -624,6 +647,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/sn_si_incident', method: 'post', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -660,6 +684,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-sir'], axiosInstance: axios, + connectorUsageCollector, }); const res = await updateIncident(service); @@ -669,6 +694,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(2, { @@ -678,6 +704,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + connectorUsageCollector, }); expect(requestMock).toHaveBeenNthCalledWith(3, { @@ -686,6 +713,7 @@ describe('ServiceNow service', () => { configurationUtilities, url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -747,6 +775,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); }); @@ -772,6 +801,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/incident/1', method: 'patch', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); }); @@ -785,6 +815,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); mockIncidentResponse(false); @@ -801,6 +832,7 @@ describe('ServiceNow service', () => { url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'patch', data: { short_description: 'title', description: 'desc' }, + connectorUsageCollector, }); expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); @@ -820,6 +852,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + connectorUsageCollector, }); }); @@ -841,6 +874,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -853,6 +887,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + connectorUsageCollector, }); }); @@ -889,6 +924,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + connectorUsageCollector, }); }); @@ -910,6 +946,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, axiosInstance: axios, + connectorUsageCollector, }); requestMock.mockImplementation(() => ({ @@ -923,6 +960,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + connectorUsageCollector, }); }); @@ -1015,6 +1053,7 @@ describe('ServiceNow service', () => { configurationUtilities, serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, axiosInstance: axios, + connectorUsageCollector, }); await service.checkIfApplicationIsInstalled(); expect(requestMock).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/index.ts index d564d6bc79d62..8d842c6e6fccf 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/index.ts @@ -116,8 +116,16 @@ async function executor( ExecutorParams > ): Promise> { - const { actionId, config, params, secrets, services, configurationUtilities, logger } = - execOptions; + const { + actionId, + config, + params, + secrets, + services, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const { subAction, subActionParams } = params; const connectorTokenClient = services.connectorTokenClient; const externalServiceConfig = snExternalServiceConfig[actionTypeId]; @@ -134,6 +142,7 @@ async function executor( serviceConfig: externalServiceConfig, connectorTokenClient, createServiceFn: createService, + connectorUsageCollector, }); const apiAsRecord = api as unknown as Record; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.test.ts index 97a0570eb50db..91eb7e4dcd7af 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { observables } from '../lib/servicenow/mocks'; import { snExternalServiceConfig } from '../lib/servicenow/config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -31,6 +32,7 @@ jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); +let connectorUsageCollector: ConnectorUsageCollector; const mockApplicationVersion = () => requestMock.mockImplementationOnce(() => ({ @@ -70,6 +72,7 @@ const expectAddObservables = (single: boolean) => { configurationUtilities, url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); const url = single @@ -85,6 +88,7 @@ const expectAddObservables = (single: boolean) => { url, method: 'post', data, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }); }; @@ -92,6 +96,10 @@ describe('ServiceNow SIR service', () => { let service: ExternalServiceSIR; beforeEach(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService({ credentials: { config: { apiUrl: 'https://example.com/', isOAuth: false }, @@ -101,6 +109,7 @@ describe('ServiceNow SIR service', () => { configurationUtilities, serviceConfig: snExternalServiceConfig['.servicenow-sir'], axiosInstance: axios, + connectorUsageCollector, }) as ExternalServiceSIR; }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.ts index 69836a5b95e29..8fc7249c1d6a1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/servicenow_sir/service.ts @@ -28,6 +28,7 @@ export const createExternalService: ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }): ExternalServiceSIR => { const snService = createExternalServiceCommon({ credentials, @@ -35,6 +36,7 @@ export const createExternalService: ServiceFactory = ({ configurationUtilities, serviceConfig, axiosInstance, + connectorUsageCollector, }); const _addObservable = async (data: Observable | Observable[], url: string) => { @@ -47,6 +49,7 @@ export const createExternalService: ServiceFactory = ({ method: 'post', data, configurationUtilities, + connectorUsageCollector, }); snService.checkInstance(res); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack/index.test.ts index 3f6203b725913..7d897ce5a3b77 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack/index.test.ts @@ -9,6 +9,7 @@ import { Logger } from '@kbn/core/server'; import { Services, ActionTypeExecutorResult as ConnectorTypeExecutorResult, + ConnectorUsageCollector, } from '@kbn/actions-plugin/server/types'; import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { @@ -35,6 +36,7 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: SlackConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); @@ -43,6 +45,10 @@ beforeEach(() => { return { status: 'ok', actionId: options.actionId }; }, }); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connector registration', () => { @@ -181,6 +187,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(response).toMatchInlineSnapshot(` Object { @@ -201,6 +208,7 @@ describe('execute()', () => { params: { message: 'failure: this invocation should fail' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"slack mockExecutor failure: this invocation should fail"` @@ -226,6 +234,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities: configUtils, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' @@ -252,6 +261,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities: configUtils, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.debug).not.toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' @@ -278,6 +288,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities: configUtils, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' @@ -304,6 +315,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities: configUtils, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' @@ -330,6 +342,7 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities: configUtils, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.debug).not.toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack/index.ts index 98573f98f2aa8..489d7c22b0286 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack/index.ts @@ -139,7 +139,8 @@ function validateConnectorTypeConfig( async function slackExecutor( execOptions: SlackConnectorTypeExecutorOptions ): Promise> { - const { actionId, secrets, params, configurationUtilities, logger } = execOptions; + const { actionId, secrets, params, configurationUtilities, logger, connectorUsageCollector } = + execOptions; let result: IncomingWebhookResult; const { webhookUrl } = secrets; @@ -163,6 +164,7 @@ async function slackExecutor( const webhook = new IncomingWebhook(webhookUrl, { agent, }); + connectorUsageCollector.addRequestBodyBytes(undefined, { text: message }); result = await webhook.send(message); } catch (err) { if (err.original == null || err.original.response == null) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts index 59030a71aaa05..84e5b68a41c7e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -7,7 +7,7 @@ import axios from 'axios'; import { Logger } from '@kbn/core/server'; -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { getConnectorType } from '.'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; @@ -39,10 +39,15 @@ const headers = { let connectorType: SlackApiConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connector registration', () => { @@ -198,6 +203,7 @@ describe('execute', () => { params: {} as PostMessageParams, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"[Action][ExternalService] -> [Slack API] Unsupported subAction type undefined."` @@ -296,6 +302,7 @@ describe('execute', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(requestMock).toHaveBeenCalledWith({ @@ -306,6 +313,7 @@ describe('execute', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'general', text: 'some text' }, + connectorUsageCollector, }); expect(response).toEqual({ @@ -386,6 +394,7 @@ describe('execute', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(requestMock).toHaveBeenCalledWith({ @@ -396,6 +405,7 @@ describe('execute', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'LKJHGF345', text: 'some text' }, + connectorUsageCollector, }); expect(response).toEqual({ @@ -476,6 +486,7 @@ describe('execute', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(requestMock).toHaveBeenCalledWith({ @@ -486,6 +497,7 @@ describe('execute', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'LKJHGF345', blocks: testBlock.blocks }, + connectorUsageCollector, }); expect(response).toEqual({ @@ -525,6 +537,7 @@ describe('execute', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(requestMock).toHaveBeenCalledWith({ @@ -534,6 +547,7 @@ describe('execute', () => { logger: mockedLogger, method: 'get', url: 'https://slack.com/api/conversations.info?channel=ZXCVBNM567', + connectorUsageCollector, }); expect(response).toEqual({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts index 35e85e98e6645..b816a1b014678 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -107,6 +107,7 @@ const slackApiExecutor = async ({ secrets, configurationUtilities, logger, + connectorUsageCollector, }: SlackApiExecutorOptions): Promise> => { const subAction = params.subAction; @@ -128,7 +129,8 @@ const slackApiExecutor = async ({ secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); if (subAction === 'validChannelId') { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts index 1389d4a98e9ec..936d4006006d1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts @@ -13,6 +13,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc import { createExternalService } from './service'; import { SlackApiService } from '../../../common/slack_api/types'; import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -28,6 +29,7 @@ jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); +let connectorUsageCollector: ConnectorUsageCollector; const channel = { id: 'channel_id_1', @@ -117,12 +119,17 @@ describe('Slack API service', () => { let service: SlackApiService; beforeAll(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService( { secrets: { token: 'token' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); }); @@ -138,7 +145,8 @@ describe('Slack API service', () => { secrets: { token: '' }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrowErrorMatchingInlineSnapshot(`"[Action][Slack API]: Wrong configuration."`); }); @@ -172,6 +180,7 @@ describe('Slack API service', () => { configurationUtilities, method: 'get', url: 'https://slack.com/api/conversations.info?channel=channel_id_1', + connectorUsageCollector, }); }); @@ -207,6 +216,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'general', text: 'a message' }, + connectorUsageCollector, }); }); @@ -231,6 +241,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'QWEERTYU987', text: 'a message' }, + connectorUsageCollector, }); }); @@ -251,6 +262,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'QWEERTYU987', text: 'a message' }, + connectorUsageCollector, }); }); @@ -291,6 +303,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'general', blocks: testBlock.blocks }, + connectorUsageCollector, }); }); @@ -315,6 +328,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'QWEERTYU987', blocks: testBlock.blocks }, + connectorUsageCollector, }); }); @@ -338,6 +352,7 @@ describe('Slack API service', () => { method: 'post', url: 'https://slack.com/api/chat.postMessage', data: { channel: 'QWEERTYU987', blocks: testBlock.blocks }, + connectorUsageCollector, }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts index 28e9ee8be4b5d..7180b0982d92c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -13,6 +13,7 @@ import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, getOrElse } from 'fp-ts/lib/Option'; import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { SLACK_CONNECTOR_NAME } from './translations'; import type { PostMessageSubActionParams, @@ -111,7 +112,8 @@ export const createExternalService = ( secrets: { token: string }; }, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): SlackApiService => { const { token } = secrets; const { allowedChannels } = config || { allowedChannels: [] }; @@ -139,6 +141,7 @@ export const createExternalService = ( method: 'get', headers, url: `${SLACK_URL}conversations.info?channel=${channelId}`, + connectorUsageCollector, }); }; if (channelId.length === 0) { @@ -207,6 +210,7 @@ export const createExternalService = ( data: { channel: channelToUse, text }, headers, configurationUtilities, + connectorUsageCollector, }); return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); @@ -232,6 +236,7 @@ export const createExternalService = ( data: { channel: channelToUse, blocks: blockJson.blocks }, headers, configurationUtilities, + connectorUsageCollector, }); return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/index.ts index d24febcccaad3..bbe53e86e068e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/index.ts @@ -76,7 +76,15 @@ async function executor( ExecutorParams > ): Promise> { - const { actionId, config, params, secrets, configurationUtilities, logger } = execOptions; + const { + actionId, + config, + params, + secrets, + configurationUtilities, + logger, + connectorUsageCollector, + } = execOptions; const { subAction, subActionParams } = params as ExecutorParams; let data: SwimlaneExecutorResultData | null = null; @@ -86,7 +94,8 @@ async function executor( secrets, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); if (!api[subAction]) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.test.ts index 1aeee9c586fd5..5c04d60bed9c1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.test.ts @@ -14,6 +14,7 @@ import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axi import { createExternalService } from './service'; import { mappings } from './mocks'; import { ExternalService } from './types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -56,8 +57,13 @@ describe('Swimlane Service', () => { }; const url = config.apiUrl.slice(0, -1); + let connectorUsageCollector: ConnectorUsageCollector; beforeAll(() => { + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); service = createExternalService( { // The trailing slash at the end of the url is intended. @@ -66,7 +72,8 @@ describe('Swimlane Service', () => { secrets: { apiToken }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); }); beforeEach(() => { @@ -87,7 +94,8 @@ describe('Swimlane Service', () => { secrets: { apiToken }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -104,7 +112,8 @@ describe('Swimlane Service', () => { secrets: { apiToken }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -122,7 +131,8 @@ describe('Swimlane Service', () => { secrets: { apiToken }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ) ).toThrow(); }); @@ -138,7 +148,8 @@ describe('Swimlane Service', () => { }, }, logger, - configurationUtilities + configurationUtilities, + connectorUsageCollector ); }).toThrow(); }); @@ -191,6 +202,7 @@ describe('Swimlane Service', () => { url: `${url}/api/app/${config.appId}/record`, method: 'post', configurationUtilities, + connectorUsageCollector, }); }); @@ -274,6 +286,7 @@ describe('Swimlane Service', () => { url: `${url}/api/app/${config.appId}/record/${incidentId}`, method: 'patch', configurationUtilities, + connectorUsageCollector, }); }); @@ -353,6 +366,7 @@ describe('Swimlane Service', () => { url: `${url}/api/app/${config.appId}/record/${incidentId}/${mappings.commentsConfig.id}/comment`, method: 'post', configurationUtilities, + connectorUsageCollector, }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.ts index 42c4b65408f21..4abe7f08de5c5 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/swimlane/service.ts @@ -14,6 +14,7 @@ import { throwIfResponseIsNotValid, } from '@kbn/actions-plugin/server/lib/axios_utils'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { getBodyForEventAction } from './helpers'; import { CreateCommentParams, @@ -42,7 +43,8 @@ const createErrorMessage = (errorResponse: ResponseError | null | undefined): st export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): ExternalService => { const { apiUrl: url, appId, mappings } = config as SwimlanePublicConfigurationType; const { apiToken } = secrets as SwimlaneSecretConfigurationType; @@ -92,6 +94,7 @@ export const createExternalService = ( logger, method: 'post', url: getPostRecordUrl(appId), + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -132,6 +135,7 @@ export const createExternalService = ( logger, method: 'patch', url: getPostRecordIdUrl(appId, params.incidentId), + connectorUsageCollector, }); throwIfResponseIsNotValid({ @@ -181,6 +185,7 @@ export const createExternalService = ( logger, method: 'post', url: getPostCommentUrl(appId, incidentId, fieldId), + connectorUsageCollector, }); /** diff --git a/x-pack/plugins/stack_connectors/server/connector_types/teams/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/teams/index.test.ts index 0b144bceb05c7..6b1b0cf105d9d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/teams/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/teams/index.test.ts @@ -6,7 +6,7 @@ */ import { Logger } from '@kbn/core/server'; -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import axios from 'axios'; import { getConnectorType, TeamsConnectorType, ConnectorTypeId } from '.'; @@ -34,10 +34,15 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: TeamsConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connector registration', () => { @@ -167,11 +172,42 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": Object { "text": "this invocation should succeed", }, @@ -223,11 +259,42 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": Object { "text": "this invocation should succeed", }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/teams/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/teams/index.ts index 84aa7449725c8..9ab0fe4d428d7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/teams/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/teams/index.ts @@ -119,7 +119,8 @@ function validateConnectorTypeConfig( async function teamsExecutor( execOptions: TeamsConnectorTypeExecutorOptions ): Promise> { - const { actionId, secrets, params, configurationUtilities, logger } = execOptions; + const { actionId, secrets, params, configurationUtilities, logger, connectorUsageCollector } = + execOptions; const { webhookUrl } = secrets; const { message } = params; const data = { text: message }; @@ -134,6 +135,7 @@ async function teamsExecutor( logger, data, configurationUtilities, + connectorUsageCollector, }) ); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.test.ts index 6218d48ae33fa..5972d5da570ef 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.test.ts @@ -18,17 +18,20 @@ import { PushToServiceIncidentSchema, } from '../../../common/thehive/schema'; import type { ExecutorSubActionCreateAlertParams, Incident } from '../../../common/thehive/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const mockTime = new Date('2024-04-03T09:10:30.000'); describe('TheHiveConnector', () => { + const logger = loggingSystemMock.createLogger(); + const connector = new TheHiveConnector( { configurationUtilities: actionsConfigMock.create(), connector: { id: '1', type: THEHIVE_CONNECTOR_ID }, config: { url: 'https://example.com', organisation: null }, secrets: { apiKey: 'test123' }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }, PushToServiceIncidentSchema @@ -36,6 +39,7 @@ describe('TheHiveConnector', () => { let mockRequest: jest.Mock; let mockError: jest.Mock; + let connectorUsageCollector: ConnectorUsageCollector; beforeAll(() => { jest.useFakeTimers(); @@ -51,6 +55,10 @@ describe('TheHiveConnector', () => { throw new Error('API Error'); }); jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('createIncident', () => { @@ -124,18 +132,21 @@ describe('TheHiveConnector', () => { }; it('TheHive API call is successful with correct parameters', async () => { - const response = await connector.createIncident(incident); + const response = await connector.createIncident(incident, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api/v1/case', - method: 'post', - responseSchema: TheHiveIncidentResponseSchema, - data: incident, - headers: { - Authorization: 'Bearer test123', - 'X-Organisation': null, + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api/v1/case', + method: 'post', + responseSchema: TheHiveIncidentResponseSchema, + data: incident, + headers: { + Authorization: 'Bearer test123', + 'X-Organisation': null, + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual({ id: '~172064', url: 'https://example.com/cases/~172064/details', @@ -148,7 +159,9 @@ describe('TheHiveConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.createIncident(incident)).rejects.toThrow('API Error'); + await expect(connector.createIncident(incident, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); }); }); @@ -173,18 +186,24 @@ describe('TheHiveConnector', () => { }; it('TheHive API call is successful with correct parameters', async () => { - const response = await connector.updateIncident({ incidentId: '~172064', incident }); + const response = await connector.updateIncident( + { incidentId: '~172064', incident }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api/v1/case/~172064', - method: 'patch', - responseSchema: TheHiveUpdateIncidentResponseSchema, - data: incident, - headers: { - Authorization: 'Bearer test123', - 'X-Organisation': null, + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api/v1/case/~172064', + method: 'patch', + responseSchema: TheHiveUpdateIncidentResponseSchema, + data: incident, + headers: { + Authorization: 'Bearer test123', + 'X-Organisation': null, + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual({ id: '~172064', url: 'https://example.com/cases/~172064/details', @@ -197,9 +216,9 @@ describe('TheHiveConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.updateIncident({ incidentId: '~172064', incident })).rejects.toThrow( - 'API Error' - ); + await expect( + connector.updateIncident({ incidentId: '~172064', incident }, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); @@ -224,21 +243,27 @@ describe('TheHiveConnector', () => { }); it('TheHive API call is successful with correct parameters', async () => { - await connector.addComment({ - incidentId: '~172064', - comment: 'test comment', - }); + await connector.addComment( + { + incidentId: '~172064', + comment: 'test comment', + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api/v1/case/~172064/comment', - method: 'post', - responseSchema: TheHiveAddCommentResponseSchema, - data: { message: 'test comment' }, - headers: { - Authorization: 'Bearer test123', - 'X-Organisation': null, + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api/v1/case/~172064/comment', + method: 'post', + responseSchema: TheHiveAddCommentResponseSchema, + data: { message: 'test comment' }, + headers: { + Authorization: 'Bearer test123', + 'X-Organisation': null, + }, }, - }); + connectorUsageCollector + ); }); it('errors during API calls are properly handled', async () => { @@ -246,7 +271,10 @@ describe('TheHiveConnector', () => { connector.request = mockError; await expect( - connector.addComment({ incidentId: '~172064', comment: 'test comment' }) + connector.addComment( + { incidentId: '~172064', comment: 'test comment' }, + connectorUsageCollector + ) ).rejects.toThrow('API Error'); }); }); @@ -314,16 +342,19 @@ describe('TheHiveConnector', () => { }); it('TheHive API call is successful with correct parameters', async () => { - const response = await connector.getIncident({ id: '~172064' }); + const response = await connector.getIncident({ id: '~172064' }, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api/v1/case/~172064', - responseSchema: TheHiveIncidentResponseSchema, - headers: { - Authorization: 'Bearer test123', - 'X-Organisation': null, + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api/v1/case/~172064', + responseSchema: TheHiveIncidentResponseSchema, + headers: { + Authorization: 'Bearer test123', + 'X-Organisation': null, + }, }, - }); + connectorUsageCollector + ); expect(response).toEqual(mockResponse.data); }); @@ -331,7 +362,9 @@ describe('TheHiveConnector', () => { // @ts-ignore connector.request = mockError; - await expect(connector.getIncident({ id: '~172064' })).rejects.toThrow('API Error'); + await expect( + connector.getIncident({ id: '~172064' }, connectorUsageCollector) + ).rejects.toThrow('API Error'); }); }); @@ -385,25 +418,30 @@ describe('TheHiveConnector', () => { }; it('TheHive API call is successful with correct parameters', async () => { - await connector.createAlert(alert); + await connector.createAlert(alert, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://example.com/api/v1/alert', - method: 'post', - responseSchema: TheHiveCreateAlertResponseSchema, - data: alert, - headers: { - Authorization: 'Bearer test123', - 'X-Organisation': null, + expect(mockRequest).toHaveBeenCalledWith( + { + url: 'https://example.com/api/v1/alert', + method: 'post', + responseSchema: TheHiveCreateAlertResponseSchema, + data: alert, + headers: { + Authorization: 'Bearer test123', + 'X-Organisation': null, + }, }, - }); + connectorUsageCollector + ); }); it('errors during API calls are properly handled', async () => { // @ts-ignore connector.request = mockError; - await expect(connector.createAlert(alert)).rejects.toThrow('API Error'); + await expect(connector.createAlert(alert, connectorUsageCollector)).rejects.toThrow( + 'API Error' + ); }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.ts b/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.ts index fe0caf8788f28..623a9b8ee73d7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/thehive/thehive.ts @@ -8,6 +8,7 @@ import { ServiceParams, CaseConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; import { Type } from '@kbn/config-schema'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { SUB_ACTION } from '../../../common/thehive/constants'; import { TheHiveIncidentResponseSchema, @@ -68,14 +69,20 @@ export class TheHiveConnector extends CaseConnector< return `API Error: ${error.response?.data?.type} - ${error.response?.data?.message}`; } - public async createIncident(incident: Incident): Promise { - const res = await this.request({ - method: 'post', - url: `${this.url}/api/${API_VERSION}/case`, - data: incident, - headers: this.getAuthHeaders(), - responseSchema: TheHiveIncidentResponseSchema, - }); + public async createIncident( + incident: Incident, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.request( + { + method: 'post', + url: `${this.url}/api/${API_VERSION}/case`, + data: incident, + headers: this.getAuthHeaders(), + responseSchema: TheHiveIncidentResponseSchema, + }, + connectorUsageCollector + ); return { id: res.data._id, @@ -85,30 +92,42 @@ export class TheHiveConnector extends CaseConnector< }; } - public async addComment({ incidentId, comment }: { incidentId: string; comment: string }) { - await this.request({ - method: 'post', - url: `${this.url}/api/${API_VERSION}/case/${incidentId}/comment`, - data: { message: comment }, - headers: this.getAuthHeaders(), - responseSchema: TheHiveAddCommentResponseSchema, - }); + public async addComment( + { incidentId, comment }: { incidentId: string; comment: string }, + connectorUsageCollector: ConnectorUsageCollector + ) { + await this.request( + { + method: 'post', + url: `${this.url}/api/${API_VERSION}/case/${incidentId}/comment`, + data: { message: comment }, + headers: this.getAuthHeaders(), + responseSchema: TheHiveAddCommentResponseSchema, + }, + connectorUsageCollector + ); } - public async updateIncident({ - incidentId, - incident, - }: { - incidentId: string; - incident: Incident; - }): Promise { - await this.request({ - method: 'patch', - url: `${this.url}/api/${API_VERSION}/case/${incidentId}`, - data: incident, - headers: this.getAuthHeaders(), - responseSchema: TheHiveUpdateIncidentResponseSchema, - }); + public async updateIncident( + { + incidentId, + incident, + }: { + incidentId: string; + incident: Incident; + }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + await this.request( + { + method: 'patch', + url: `${this.url}/api/${API_VERSION}/case/${incidentId}`, + data: incident, + headers: this.getAuthHeaders(), + responseSchema: TheHiveUpdateIncidentResponseSchema, + }, + connectorUsageCollector + ); return { id: incidentId, @@ -118,23 +137,35 @@ export class TheHiveConnector extends CaseConnector< }; } - public async getIncident({ id }: { id: string }): Promise { - const res = await this.request({ - url: `${this.url}/api/${API_VERSION}/case/${id}`, - headers: this.getAuthHeaders(), - responseSchema: TheHiveIncidentResponseSchema, - }); + public async getIncident( + { id }: { id: string }, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { + const res = await this.request( + { + url: `${this.url}/api/${API_VERSION}/case/${id}`, + headers: this.getAuthHeaders(), + responseSchema: TheHiveIncidentResponseSchema, + }, + connectorUsageCollector + ); return res.data; } - public async createAlert(alert: ExecutorSubActionCreateAlertParams) { - await this.request({ - method: 'post', - url: `${this.url}/api/${API_VERSION}/alert`, - data: alert, - headers: this.getAuthHeaders(), - responseSchema: TheHiveCreateAlertResponseSchema, - }); + public async createAlert( + alert: ExecutorSubActionCreateAlertParams, + connectorUsageCollector: ConnectorUsageCollector + ) { + await this.request( + { + method: 'post', + url: `${this.url}/api/${API_VERSION}/alert`, + data: alert, + headers: this.getAuthHeaders(), + responseSchema: TheHiveCreateAlertResponseSchema, + }, + connectorUsageCollector + ); } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.test.ts index b30a888f27998..6bbaad9b86a0c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.test.ts @@ -12,6 +12,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { TinesConnector } from './tines'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import { API_MAX_RESULTS, TINES_CONNECTOR_ID } from '../../../common/tines/constants'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; jest.mock('axios'); (axios as jest.Mocked).create.mockImplementation( @@ -78,6 +79,7 @@ const storiesGetRequestExpected = { 'Content-Type': 'application/json', }, params: { per_page: API_MAX_RESULTS }, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }; const agentsGetRequestExpected = { @@ -91,20 +93,28 @@ const agentsGetRequestExpected = { 'Content-Type': 'application/json', }, params: { story_id: story.id, per_page: API_MAX_RESULTS }, + connectorUsageCollector: expect.any(ConnectorUsageCollector), }; +let connectorUsageCollector: ConnectorUsageCollector; + describe('TinesConnector', () => { + const logger = loggingSystemMock.createLogger(); const connector = new TinesConnector({ configurationUtilities: actionsConfigMock.create(), config: { url }, connector: { id: '1', type: TINES_CONNECTOR_ID }, secrets: { email, token }, - logger: loggingSystemMock.createLogger(), + logger, services: actionsMock.createServices(), }); beforeEach(() => { jest.clearAllMocks(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); }); describe('getStories', () => { @@ -113,13 +123,13 @@ describe('TinesConnector', () => { }); it('should request Tines stories', async () => { - await connector.getStories(); + await connector.getStories(undefined, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); expect(mockRequest).toHaveBeenCalledWith(storiesGetRequestExpected); }); it('should return the Tines stories reduced array', async () => { - const { stories } = await connector.getStories(); + const { stories } = await connector.getStories(undefined, connectorUsageCollector); expect(stories).toEqual([storyResult]); }); @@ -127,7 +137,7 @@ describe('TinesConnector', () => { mockRequest.mockReturnValueOnce({ data: { stories: [story], meta: { pages: 1 } }, }); - const response = await connector.getStories(); + const response = await connector.getStories(undefined, connectorUsageCollector); expect(response.incompleteResponse).toEqual(false); }); @@ -135,7 +145,7 @@ describe('TinesConnector', () => { mockRequest.mockReturnValueOnce({ data: { stories: [story], meta: { pages: 2 } }, }); - const response = await connector.getStories(); + const response = await connector.getStories(undefined, connectorUsageCollector); expect(response.incompleteResponse).toEqual(true); }); }); @@ -146,14 +156,17 @@ describe('TinesConnector', () => { }); it('should request Tines webhook actions', async () => { - await connector.getWebhooks({ storyId: story.id }); + await connector.getWebhooks({ storyId: story.id }, connectorUsageCollector); expect(mockRequest).toBeCalledTimes(1); expect(mockRequest).toHaveBeenCalledWith(agentsGetRequestExpected); }); it('should return the Tines webhooks reduced array', async () => { - const { webhooks } = await connector.getWebhooks({ storyId: story.id }); + const { webhooks } = await connector.getWebhooks( + { storyId: story.id }, + connectorUsageCollector + ); expect(webhooks).toEqual([webhookResult]); }); @@ -161,7 +174,7 @@ describe('TinesConnector', () => { mockRequest.mockReturnValueOnce({ data: { agents: [webhookAgent], meta: { pages: 1 } }, }); - const response = await connector.getWebhooks({ storyId: story.id }); + const response = await connector.getWebhooks({ storyId: story.id }, connectorUsageCollector); expect(response.incompleteResponse).toEqual(false); }); @@ -169,7 +182,7 @@ describe('TinesConnector', () => { mockRequest.mockReturnValueOnce({ data: { agents: [webhookAgent], meta: { pages: 2 } }, }); - const response = await connector.getWebhooks({ storyId: story.id }); + const response = await connector.getWebhooks({ storyId: story.id }, connectorUsageCollector); expect(response.incompleteResponse).toEqual(true); }); }); @@ -180,10 +193,13 @@ describe('TinesConnector', () => { }); it('should send data to Tines webhook using selected webhook parameter', async () => { - await connector.runWebhook({ - webhook: webhookResult, - body: '[]', - }); + await connector.runWebhook( + { + webhook: webhookResult, + body: '[]', + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); expect(mockRequest).toHaveBeenCalledWith({ @@ -194,14 +210,18 @@ describe('TinesConnector', () => { headers: { 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); it('should send data to Tines webhook using webhook url parameter', async () => { - await connector.runWebhook({ - webhookUrl, - body: '[]', - }); + await connector.runWebhook( + { + webhookUrl, + body: '[]', + }, + connectorUsageCollector + ); expect(mockRequest).toBeCalledTimes(1); expect(mockRequest).toHaveBeenCalledWith({ @@ -212,6 +232,7 @@ describe('TinesConnector', () => { headers: { 'Content-Type': 'application/json', }, + connectorUsageCollector, }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.ts b/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.ts index cb46a9abea3cc..ed98ec382af86 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/tines/tines.ts @@ -6,6 +6,7 @@ */ import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import type { AxiosError } from 'axios'; import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; import { @@ -108,12 +109,16 @@ export class TinesConnector extends SubActionConnector( req: SubActionRequestParams, - reducer: (response: R) => T + reducer: (response: R) => T, + connectorUsageCollector: ConnectorUsageCollector ): Promise { - const response = await this.request({ - ...req, - params: { ...req.params, per_page: API_MAX_RESULTS }, - }); + const response = await this.request( + { + ...req, + params: { ...req.params, per_page: API_MAX_RESULTS }, + }, + connectorUsageCollector + ); return { ...reducer(response.data), incompleteResponse: response.data.meta.pages > 1, @@ -130,20 +135,25 @@ export class TinesConnector extends SubActionConnector { + public async getStories( + params: unknown, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { return this.tinesApiRequest( { url: this.urls.stories, headers: this.getAuthHeaders(), responseSchema: TinesStoriesApiResponseSchema, }, - storiesReducer + storiesReducer, + connectorUsageCollector ); } - public async getWebhooks({ - storyId, - }: TinesWebhooksActionParams): Promise { + public async getWebhooks( + { storyId }: TinesWebhooksActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { return this.tinesApiRequest( { url: this.urls.agents, @@ -151,24 +161,27 @@ export class TinesConnector extends SubActionConnector { + public async runWebhook( + { webhook, webhookUrl, body }: TinesRunActionParams, + connectorUsageCollector: ConnectorUsageCollector + ): Promise { if (!webhook && !webhookUrl) { throw Error('Invalid subActionsParams: [webhook] or [webhookUrl] expected but got none'); } - const response = await this.request({ - url: webhookUrl ? webhookUrl : this.urls.getRunWebhookURL(webhook!), - method: 'post', - responseSchema: TinesRunApiResponseSchema, - data: body, - }); + const response = await this.request( + { + url: webhookUrl ? webhookUrl : this.urls.getRunWebhookURL(webhook!), + method: 'post', + responseSchema: TinesRunApiResponseSchema, + data: body, + }, + connectorUsageCollector + ); return response.data; } } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/torq/__snapshots__/index.test.ts.snap b/x-pack/plugins/stack_connectors/server/connector_types/torq/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000000..e8260c86f3e00 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/torq/__snapshots__/index.test.ts.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute Torq action execute with token happy flow 1`] = ` +Object { + "axios": Any, + "connectorUsageCollector": Object { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from Torq action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": Object { + "msg": "some data", + }, + "headers": Object { + "Content-Type": "application/json", + "X-Torq-Token": "1234", + }, + "logger": Any, + "method": "post", + "url": "https://hooks.torq.io/v1/test", + "validateStatus": Any, +} +`; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/torq/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/torq/index.test.ts index 4bf60d10eb789..39bbdd44a918d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/torq/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/torq/index.test.ts @@ -12,6 +12,7 @@ import { ActionTypeConfigType, getActionType, TorqActionType } from '.'; import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { Services } from '@kbn/actions-plugin/server/types'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; @@ -37,10 +38,15 @@ const services: Services = actionsMock.createServices(); let actionType: TorqActionType; const mockedLogger: jest.Mocked = loggerMock.create(); let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeAll(() => { actionType = getActionType(); configurationUtilities = actionsConfigMock.create(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('actionType', () => { @@ -171,48 +177,29 @@ describe('execute Torq action', () => { params: { body: '{"msg": "some data"}' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; - expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": [MockFunction], - "data": Object { - "msg": "some data", - }, - "headers": Object { - "Content-Type": "application/json", - "X-Torq-Token": "1234", + expect(requestMock.mock.calls[0][0]).toMatchSnapshot({ + axios: expect.any(Function), + connectorUsageCollector: { + usage: { + requestBodyBytes: 0, }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from Torq action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "isLevelEnabled": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "url": "https://hooks.torq.io/v1/test", - "validateStatus": [Function], - } - `); + }, + data: { + msg: 'some data', + }, + headers: { + 'Content-Type': 'application/json', + 'X-Torq-Token': '1234', + }, + logger: expect.any(Object), + method: 'post', + url: 'https://hooks.torq.io/v1/test', + validateStatus: expect.any(Function), + }); }); test('renders parameter templates as expected', async () => { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/torq/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/torq/index.ts index c3e7dc4f1533c..b60237dd7991e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/torq/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/torq/index.ts @@ -146,6 +146,7 @@ export async function executor( const { webhookIntegrationUrl } = execOptions.config; const { body: data } = execOptions.params; const configurationUtilities = execOptions.configurationUtilities; + const connectorUsageCollector = execOptions.connectorUsageCollector; const secrets: ActionTypeSecretsType = execOptions.secrets; const token = secrets.token; @@ -171,6 +172,7 @@ export async function executor( configurationUtilities, logger: execOptions.logger, validateStatus: (status: number) => status >= 200 && status < 300, + connectorUsageCollector, }) ); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/webhook/__snapshots__/index.test.ts.snap b/x-pack/plugins/stack_connectors/server/connector_types/webhook/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000000..f52e34d90dbf0 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/webhook/__snapshots__/index.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute() execute with username/password sends request with basic auth 1`] = ` +Object { + "axios": undefined, + "connectorUsageCollector": Object { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": "some data", + "headers": Object { + "Authorization": "Basic YWJjOjEyMw==", + "aheader": "a value", + }, + "logger": Any, + "method": "post", + "sslOverrides": Object {}, + "url": "https://abc.def/my-webhook", +} +`; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.test.ts index 6c51fe11e97de..724daa852019f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; @@ -41,10 +41,15 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: WebhookConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connectorType', () => { @@ -339,46 +344,27 @@ describe('execute()', () => { params: { body: 'some data' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; - expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": "some data", - "headers": Object { - "Authorization": "Basic YWJjOjEyMw==", - "aheader": "a value", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from webhook action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "isLevelEnabled": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], + expect(requestMock.mock.calls[0][0]).toMatchSnapshot({ + axios: undefined, + connectorUsageCollector: { + usage: { + requestBodyBytes: 0, }, - "method": "post", - "sslOverrides": Object {}, - "url": "https://abc.def/my-webhook", - } - `); + }, + data: 'some data', + headers: { + Authorization: 'Basic YWJjOjEyMw==', + aheader: 'a value', + }, + logger: expect.any(Object), + method: 'post', + sslOverrides: {}, + url: 'https://abc.def/my-webhook', + }); }); test('execute with ssl adds ssl settings to sslOverrides', async () => { @@ -400,6 +386,7 @@ describe('execute()', () => { params: { body: 'some data' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; @@ -407,6 +394,36 @@ describe('execute()', () => { expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": "some data", "headers": Object { "aheader": "a value", @@ -588,6 +605,7 @@ describe('execute()', () => { params: { body: 'some data' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.error).toBeCalledWith( 'error on some-id webhook event: maxContentLength size of 1000000 exceeded' @@ -618,12 +636,43 @@ describe('execute()', () => { params: { body: 'some data' }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, + "connectorUsageCollector": ConnectorUsageCollector { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, "data": "some data", "headers": Object { "aheader": "a value", diff --git a/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.ts index 78f02d24b9b87..f7c7fd4f6d61e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/webhook/index.ts @@ -128,7 +128,8 @@ function validateConnectorTypeConfig( export async function executor( execOptions: WebhookConnectorTypeExecutorOptions ): Promise> { - const { actionId, config, params, configurationUtilities, logger } = execOptions; + const { actionId, config, params, configurationUtilities, logger, connectorUsageCollector } = + execOptions; const { method, url, headers = {}, hasAuth, authType, ca, verificationMode } = config; const { body: data } = params; @@ -159,6 +160,7 @@ export async function executor( data, configurationUtilities, sslOverrides, + connectorUsageCollector, }) ); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.test.ts index 9205c3fef91c9..b357faffc8a0b 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.test.ts @@ -14,7 +14,7 @@ import { XmattersConnectorType, } from '.'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; -import { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector, Services } from '@kbn/actions-plugin/server/types'; import { validateConfig, validateConnector, @@ -45,10 +45,15 @@ const mockedLogger: jest.Mocked = loggerMock.create(); let connectorType: XmattersConnectorType; let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; beforeEach(() => { configurationUtilities = actionsConfigMock.create(); connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); }); describe('connectorType', () => { @@ -423,6 +428,7 @@ describe('execute()', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); const { method, url, headers, data } = requestMock.mock.calls[0][0]; @@ -472,6 +478,7 @@ describe('execute()', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); expect(mockedLogger.warn).toBeCalledWith( 'Error thrown triggering xMatters workflow: maxContentLength size of 1000000 exceeded' @@ -504,6 +511,7 @@ describe('execute()', () => { }, configurationUtilities, logger: mockedLogger, + connectorUsageCollector, }); const { method, url, headers, data } = requestMock.mock.calls[0][0]; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.ts index 880c2278ca923..ad189bda9defb 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/index.ts @@ -8,7 +8,7 @@ import { isString } from 'lodash'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; -import type { +import { ActionType as ConnectorType, ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, ActionTypeExecutorResult as ConnectorTypeExecutorResult, @@ -247,7 +247,8 @@ function validateConnectorTypeSecrets( export async function executor( execOptions: XmattersConnectorTypeExecutorOptions ): Promise> { - const { actionId, configurationUtilities, config, params, logger } = execOptions; + const { actionId, configurationUtilities, config, params, logger, connectorUsageCollector } = + execOptions; const { configUrl, usesBasic } = config; const data = getPayloadForRequest(params); @@ -263,7 +264,12 @@ export async function executor( if (!url) { throw new Error('Error: no url provided'); } - result = await postXmatters({ url, data, basicAuth }, logger, configurationUtilities); + result = await postXmatters( + { url, data, basicAuth }, + logger, + configurationUtilities, + connectorUsageCollector + ); } catch (err) { const message = i18n.translate('xpack.stackConnectors.xmatters.postingErrorMessage', { defaultMessage: 'Error triggering xMatters workflow', diff --git a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/post_xmatters.ts b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/post_xmatters.ts index 2c2a08901cec3..215d1942c6e8a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/xmatters/post_xmatters.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/xmatters/post_xmatters.ts @@ -10,6 +10,7 @@ import { Logger } from '@kbn/core/server'; import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import { combineHeadersWithBasicAuthHeader } from '@kbn/actions-plugin/server/lib'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; interface PostXmattersOptions { url: string; @@ -34,7 +35,8 @@ interface PostXmattersOptions { export async function postXmatters( options: PostXmattersOptions, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + connectorUsageCollector: ConnectorUsageCollector ): Promise { const { url, data, basicAuth } = options; const axiosInstance = axios.create(); @@ -50,5 +52,6 @@ export async function postXmatters( data, configurationUtilities, validateStatus: () => true, + connectorUsageCollector, }); } diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/sub_action_connector.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/sub_action_connector.ts index 7bd9db83dcc00..302bbf2b06668 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/sub_action_connector.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/sub_action_connector.ts @@ -11,6 +11,7 @@ import type { ServiceParams } from '@kbn/actions-plugin/server'; import { PluginSetupContract as ActionsPluginSetup } from '@kbn/actions-plugin/server/plugin'; import { schema, TypeOf } from '@kbn/config-schema'; import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; const TestConfigSchema = schema.object({ url: schema.string() }); const TestSecretsSchema = schema.object({ @@ -69,7 +70,11 @@ export const getTestSubActionConnector = ( return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; } - public async subActionWithParams({ id }: { id: string }) { + public async subActionWithParams( + { id }: { id: string }, + connectorUsageCollector: ConnectorUsageCollector + ) { + connectorUsageCollector.addRequestBodyBytes(undefined, { id }); return { id }; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts index 1b39410a7bf93..3bc7b665e2c2e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/bedrock.ts @@ -13,8 +13,9 @@ import { } from '@kbn/actions-simulators-plugin/server/bedrock_simulation'; import { DEFAULT_TOKEN_LIMIT } from '@kbn/stack-connectors-plugin/common/bedrock/constants'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { getEventLog, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; const connectorTypeId = '.bedrock'; const name = 'A bedrock action'; @@ -344,6 +345,23 @@ export default function bedrockTest({ getService }: FtrProviderContext) { }, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: bedrockActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(145); }); it('should overwrite the model when a model argument is provided', async () => { @@ -374,6 +392,23 @@ export default function bedrockTest({ getService }: FtrProviderContext) { }, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: bedrockActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 2 }], + ['execute', { gte: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(145); }); it('should invoke AI with assistant AI body argument formatted to bedrock expectations', async () => { @@ -423,6 +458,23 @@ export default function bedrockTest({ getService }: FtrProviderContext) { connector_id: bedrockActionId, data: { message: bedrockClaude2SuccessResponse.completion }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: bedrockActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 3 }], + ['execute', { gte: 3 }], + ]), + }); + }); + + const executeEvent = events[5]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(256); }); describe('Token tracking dashboard', () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts index f26ba86f6fa3a..1ef7b170a4f0d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts @@ -14,13 +14,16 @@ import { ExternalServiceSimulator, } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function casesWebhookTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); const config = { createCommentJson: '{"body":{{{case.comment}}}}', createCommentMethod: 'post', @@ -398,6 +401,23 @@ export default function casesWebhookTest({ getService }: FtrProviderContext) { const { pushedDate, ...dataWithoutTime } = body.data; body.data = dataWithoutTime; + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(125); + expect(body).to.eql({ status: 'ok', connector_id: simulatedActionId, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/d3security.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/d3security.ts index 7e1f0636b3a96..72cea764d0165 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/d3security.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/d3security.ts @@ -12,7 +12,9 @@ import { d3SecuritySuccessResponse, } from '@kbn/actions-simulators-plugin/server/d3security_simulation'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; const connectorTypeId = '.d3security'; const name = 'A D3 Security action'; @@ -24,6 +26,7 @@ const secrets = { export default function d3SecurityTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const createConnector = async (url: string) => { const { body } = await supertest @@ -248,6 +251,24 @@ export default function d3SecurityTest({ getService }: FtrProviderContext) { }, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: d3SecurityActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(99); + expect(body).to.eql({ status: 'ok', connector_id: d3SecurityActionId, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/email.ts index cfffe90126fac..6ed2c89e094f9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/email.ts @@ -11,12 +11,15 @@ import { ExternalServiceSimulator, getExternalServiceSimulatorPath, } from '@kbn/actions-simulators-plugin/server/plugin'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function emailTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); + const retry = getService('retry'); describe('create email action', () => { let createdActionId = ''; @@ -103,7 +106,7 @@ export default function emailTest({ getService }: FtrProviderContext) { }, }) .expect(200) - .then((resp: any) => { + .then(async (resp: any) => { expect(resp.body.data.message.messageId).to.be.a('string'); expect(resp.body.data.messageId).to.be.a('string'); @@ -131,6 +134,23 @@ export default function emailTest({ getService }: FtrProviderContext) { headers: {}, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: createdActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(350); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/es_index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/es_index.ts index 46287db208b87..8d867aea49685 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/es_index.ts @@ -7,7 +7,9 @@ import type { Client } from '@elastic/elasticsearch'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; const ES_TEST_INDEX_NAME = 'functional-test-actions-index'; @@ -16,6 +18,7 @@ export default function indexTest({ getService }: FtrProviderContext) { const es: Client = getService('es'); const supertest = getService('supertest'); const esDeleteAllIndices = getService('esDeleteAllIndices'); + const retry = getService('retry'); describe('index action', () => { beforeEach(() => esDeleteAllIndices(ES_TEST_INDEX_NAME)); @@ -214,6 +217,23 @@ export default function indexTest({ getService }: FtrProviderContext) { } expect(passed1).to.be(true); expect(passed2).to.be(true); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: createdAction.id, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); it('should execute successly with refresh false', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts index 054678996888f..2268e379f441a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { @@ -16,12 +17,14 @@ import { import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { MAX_OTHER_FIELDS_LENGTH } from '@kbn/stack-connectors-plugin/common/jira/constants'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function jiraTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); const mockJira = { config: { @@ -517,6 +520,23 @@ export default function jiraTest({ getService }: FtrProviderContext) { url: `${jiraSimulatorURL}/browse/CK-1`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 2 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(124); }); it('should handle creating an incident with other fields', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts index 716041e939e8a..05dfc61dd59e3 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { OpenAISimulator, @@ -14,6 +15,7 @@ import { import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { getEventLog } from '../../../../../common/lib'; const connectorTypeId = '.gen-ai'; const name = 'A genAi action'; @@ -315,6 +317,23 @@ export default function genAiTest({ getService }: FtrProviderContext) { connector_id: genAiActionId, data: genAiSuccessResponse, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: genAiActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(78); }); describe('Token tracking dashboard', () => { const dashboardId = 'specific-dashboard-id-default'; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/opsgenie.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/opsgenie.ts index 48386480b00da..75913a4182bbd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/opsgenie.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/opsgenie.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { OpsgenieSimulator, @@ -13,11 +14,13 @@ import { } from '@kbn/actions-simulators-plugin/server/opsgenie_simulation'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function opsgenieTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); describe('Opsgenie', () => { describe('action creation', () => { @@ -535,6 +538,23 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { connector_id: opsgenieActionId, data: opsgenieSuccessResponse, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: opsgenieActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(21); }); it('should preserve the alias when it is 512 characters when creating an alert', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/pagerduty.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/pagerduty.ts index 7148ac962c728..2871066b6456a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/pagerduty.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/pagerduty.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { @@ -14,12 +15,14 @@ import { ExternalServiceSimulator, } from '@kbn/actions-simulators-plugin/server/plugin'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function pagerdutyTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); describe('pagerduty action', () => { let simulatedActionId = ''; @@ -173,6 +176,23 @@ export default function pagerdutyTest({ getService }: FtrProviderContext) { status: 'success', }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(142); }); it('should execute successfully with links and customDetails', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts index c9b4e7ecafa3f..6dfb420463e9f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts @@ -6,15 +6,18 @@ */ import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { ResilientSimulator } from '@kbn/actions-simulators-plugin/server/resilient_simulation'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function resilientTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const mockResilient = { config: { @@ -409,6 +412,23 @@ export default function resilientTest({ getService }: FtrProviderContext) { url: `${simulatorUrl}/#incidents/123`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: resilientActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(167); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/server_log.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/server_log.ts index a5f14e512ddb1..a3fedba5d1f69 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/server_log.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/server_log.ts @@ -6,12 +6,15 @@ */ import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function serverLogTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const retry = getService('retry'); describe('server-log action', () => { let serverLogActionId: string; @@ -69,6 +72,23 @@ export default function serverLogTest({ getService }: FtrProviderContext) { .expect(200); expect(result.status).to.eql('ok'); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: serverLogActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itom.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itom.ts index 4702d3bbe6df7..0f1748db4f5ef 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itom.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itom.ts @@ -10,16 +10,19 @@ import expect from '@kbn/expect'; import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function serviceNowITOMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const mockServiceNowCommon = { params: { @@ -500,6 +503,23 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }) .expect(200); expect(result.status).to.eql('ok'); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(317); }); }); @@ -548,6 +568,23 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }, ], }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 2 }], + ['execute', { equal: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts index ec62e6d30f6ff..bc0f48f15caf5 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts @@ -10,17 +10,20 @@ import expect from '@kbn/expect'; import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { MAX_ADDITIONAL_FIELDS_LENGTH } from '@kbn/stack-connectors-plugin/common/servicenow/constants'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function serviceNowITSMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const mockServiceNowCommon = { params: { @@ -708,6 +711,23 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(261); }); }); @@ -755,6 +775,23 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(239); }); }); @@ -803,6 +840,23 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }, ], }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 2 }], + ['execute', { gte: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); }); @@ -829,6 +883,22 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { connector_id: simulatedActionId, data: {}, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 3 }], + ['execute', { gte: 3 }], + ]), + }); + }); + + const executeEvent = events[5]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts index fd4781f6d9e62..717a44a406712 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts @@ -10,17 +10,20 @@ import expect from '@kbn/expect'; import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { MAX_ADDITIONAL_FIELDS_LENGTH } from '@kbn/stack-connectors-plugin/common/servicenow/constants'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function serviceNowSIRTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const mockServiceNowCommon = { config: { @@ -721,6 +724,23 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(645); }); }); @@ -768,6 +788,22 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, }, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(429); }); }); @@ -816,6 +852,23 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }, ], }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 2 }], + ['execute', { gte: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(0); }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts index 4941bec1844ca..457f9edbed2fc 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts @@ -9,14 +9,18 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; import http from 'http'; import getPort from 'get-port'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; + import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getSlackServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function slackTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); describe('Slack webhook action', () => { let simulatedActionId = ''; @@ -175,6 +179,23 @@ export default function slackTest({ getService }: FtrProviderContext) { .expect(200); expect(result.status).to.eql('ok'); expect(proxyHaveBeenCalled).to.equal(true); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(18); }); it('should handle an empty message error', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/swimlane.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/swimlane.ts index 859f4f952fe58..4d91fdddf80dd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/swimlane.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/swimlane.ts @@ -9,16 +9,19 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; import getPort from 'get-port'; import http from 'http'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getSwimlaneServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function swimlaneTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const mockSwimlane = { name: 'A swimlane action', @@ -459,6 +462,23 @@ export default function swimlaneTest({ getService }: FtrProviderContext) { url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(175); }); it('should handle updating an incident', async () => { @@ -490,6 +510,23 @@ export default function swimlaneTest({ getService }: FtrProviderContext) { url: `${swimlaneSimulatorURL}/record/123456asdf/wowzeronza`, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(193); }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/tines.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/tines.ts index 53dd3aaaf026a..04971990f879e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/tines.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/tines.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { TinesSimulator, @@ -16,6 +17,7 @@ import { } from '@kbn/actions-simulators-plugin/server/tines_simulation'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; const connectorTypeId = '.tines'; const name = 'A tines action'; @@ -35,6 +37,7 @@ const webhook = { export default function tinesTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); + const retry = getService('retry'); const createConnector = async (url: string) => { const { body } = await supertest @@ -392,6 +395,23 @@ export default function tinesTest({ getService }: FtrProviderContext) { incompleteResponse: false, }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: tinesActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(2); }); it('should get webhooks', async () => { @@ -422,6 +442,22 @@ export default function tinesTest({ getService }: FtrProviderContext) { incompleteResponse: false, }, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: tinesActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 2 }], + ['execute', { gte: 2 }], + ]), + }); + }); + + const executeEvent = events[3]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(2); }); it('should run the webhook', async () => { @@ -440,6 +476,22 @@ export default function tinesTest({ getService }: FtrProviderContext) { connector_id: tinesActionId, data: tinesWebhookSuccessResponse, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: tinesActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 3 }], + ['execute', { gte: 3 }], + ]), + }); + }); + + const executeEvent = events[5]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(8); }); it('should run the webhook url', async () => { @@ -464,6 +516,22 @@ export default function tinesTest({ getService }: FtrProviderContext) { connector_id: tinesActionId, data: tinesWebhookSuccessResponse, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: tinesActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 3 }], + ['execute', { gte: 3 }], + ]), + }); + }); + + const executeEvent = events[5]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(8); }); }); @@ -506,6 +574,22 @@ export default function tinesTest({ getService }: FtrProviderContext) { errorSource: TaskErrorSource.FRAMEWORK, service_message: 'Status code: 422. Message: API Error: Unprocessable Entity', }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: tinesActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(2); }); it('should return a failure when attempting to get webhooks', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/torq.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/torq.ts index 257378b406da5..b709ef837ad03 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/torq.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/torq.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers/get_proxy_server'; import { @@ -14,12 +15,14 @@ import { ExternalServiceSimulator, } from '@kbn/actions-simulators-plugin/server/plugin'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function torqTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); describe('Torq action', () => { let simulatedActionId = ''; @@ -159,6 +162,22 @@ export default function torqTest({ getService }: FtrProviderContext) { connector_id: simulatedActionId, data: `{"msg": "test"}`, }); + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(14); }); it('should handle a 400 Torq error', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts index c7a8f1c1b2524..f05db95254c1c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts @@ -8,6 +8,8 @@ import httpProxy from 'http-proxy'; import http from 'http'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; + import { URL, format as formatUrl } from 'url'; import getPort from 'get-port'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; @@ -17,6 +19,7 @@ import { getWebhookServer, } from '@kbn/actions-simulators-plugin/server/plugin'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; const defaultValues: Record = { headers: null, @@ -36,6 +39,7 @@ export default function webhookTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); async function createWebhookAction( webhookSimulatorURL: string, @@ -258,6 +262,23 @@ export default function webhookTest({ getService }: FtrProviderContext) { .expect(200); expect(result.status).to.eql('ok'); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: webhookActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(19); }); it('should support the PUT method against webhook target', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/xmatters.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/xmatters.ts index 4efacbf78f951..7cf2320c97933 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/xmatters.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/xmatters.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { @@ -14,12 +15,14 @@ import { ExternalServiceSimulator, } from '@kbn/actions-simulators-plugin/server/plugin'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib'; // eslint-disable-next-line import/no-default-export export default function xmattersTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); const configService = getService('config'); + const retry = getService('retry'); describe('xmatters action', () => { let simulatedActionId = ''; @@ -180,6 +183,23 @@ export default function xmattersTest({ getService }: FtrProviderContext) { spaceId: '', }, }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: simulatedActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.be(130); }); it('should handle a 40x xmatters error', async () => { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts index acfb06e64cff6..f7b62d51d635f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts @@ -652,6 +652,8 @@ export default function ({ getService }: FtrProviderContext) { expect(executeEvent?.message).to.eql(message); expect(startExecuteEvent?.message).to.eql(message.replace('executed', 'started')); + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.eql(0); + if (source) { expect(executeEvent?.kibana?.action?.execution?.source).to.eql(source.toLowerCase()); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts index c8fb3e4b6ce2d..b504f8204c4aa 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/sub_action_framework/index.ts @@ -8,8 +8,9 @@ import type SuperTest from 'supertest'; import expect from '@kbn/expect'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/generated/schemas'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { getEventLog, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; /** * The sub action connector is defined here @@ -79,6 +80,7 @@ const executeSubAction = async ({ // eslint-disable-next-line import/no-default-export export default function createActionTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const retry = getService('retry'); describe('Sub action framework', () => { const objectRemover = new ObjectRemover(supertest); @@ -140,13 +142,35 @@ export default function createActionTests({ getService }: FtrProviderContext) { const res = await createSubActionConnector({ supertest }); objectRemover.add('default', res.body.id, 'action', 'actions'); + const connectorId = res.body.id as string; + const subActionParams = { id: 'test-id' }; + const execRes = await executeSubAction({ supertest, - connectorId: res.body.id as string, + connectorId, subAction: 'subActionWithParams', - subActionParams: { id: 'test-id' }, + subActionParams, + }); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: connectorId, + provider: 'actions', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + }); }); + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.usage?.request_body_bytes).to.eql( + Buffer.byteLength(JSON.stringify(subActionParams)) + ); + expect(execRes.body).to.eql({ status: 'ok', data: { id: 'test-id' },