Metering connector actions request body bytes (#186804)

Towards: https://github.com/elastic/response-ops-team/issues/209

This PR collects body-bytes from the requests made by the connectors to
the 3rd parties and saves them in the event-log.

There is a new metric collector: `ConnectorMetricsCollector`
Action TaskRunner, creates a new instance of it on each action execution
and passes it to the actionType.executor.
Then the actionType.executor passes it to the request function provided
by the actions plugin.
Request function passes the response (or the error) from axios to
`addRequestBodyBytes` method of the `ConnectorMetricsCollector`.
Since axios always returns `request.headers['Content-Length']` either in
success result or error, metric collector uses its value to get the
request body bytes.

In case there is no `Content-Length` header, `addRequestBodyBytes`
method fallbacks to the body object that we pass as the second param. So
It calculates the body bytes by using `Buffer.byteLength(body,
'utf8');`, which is also used by axios to populate
`request.headers['Content-Length']`

For the connectors or the subActions that we don't use the request
function or axios:
addRequestBodyBytes method is called just before making the request only
with the body param in order to force it to use the fallback.

Note: If there are more than one requests in an execution, the bytes are
summed.

## To verify:
Create a rule with a connector that you would like to test.
Let the rule run and check the event-log of your connector, request body
bytes should be saved in:
`kibana.action.execution.metrics.request_body_bytes`

Alternatively:
You can create a connector and run it on its test tab.

You can use the below query to check the event-log:
```
 {
    "query": {
      "bool": { 
        "must": [
          { "match": { "event.provider":"actions"}},
          { "match": { "kibana.action.type_id":"{**your-action-type-id**}"}} 
        ],
        "filter": [ 
          { "term":  { "event.action": "execute" }}
        ]
      }
    },
    "size" : 100
}
```
This commit is contained in:
Ersin Erdal 2024-08-27 12:48:29 +03:00 committed by GitHub
parent 8431033910
commit 9372027e6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 4348 additions and 1691 deletions

View file

@ -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: {

View file

@ -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');

View file

@ -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: {

View file

@ -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 <T = unknown>({
@ -30,6 +30,7 @@ export const request = async <T = unknown>({
headers,
sslOverrides,
timeout,
connectorUsageCollector,
...config
}: {
axios: AxiosInstance;
@ -41,6 +42,7 @@ export const request = async <T = unknown>({
headers?: Record<string, AxiosHeaderValue>;
timeout?: number;
sslOverrides?: SSLSettings;
connectorUsageCollector?: ConnectorUsageCollector;
} & AxiosRequestConfig): Promise<AxiosResponse> => {
if (!isEmpty(axios?.defaults?.baseURL ?? '')) {
throw new Error(
@ -64,18 +66,31 @@ export const request = async <T = unknown>({
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 <T = unknown>({
@ -84,12 +99,14 @@ export const patch = async <T = unknown>({
data,
logger,
configurationUtilities,
connectorUsageCollector,
}: {
axios: AxiosInstance;
url: string;
data: T;
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
connectorUsageCollector?: ConnectorUsageCollector;
}): Promise<AxiosResponse> => {
return request({
axios,
@ -98,6 +115,7 @@ export const patch = async <T = unknown>({
method: 'patch',
data,
configurationUtilities,
connectorUsageCollector,
});
};

View file

@ -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',

View file

@ -37,6 +37,7 @@ interface CreateActionEventLogRecordParams {
relatedSavedObjects?: RelatedSavedObjects;
isInMemory?: boolean;
source?: ActionExecutionSource<unknown>;
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,
},

View file

@ -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<typeof actionsMock.createServices>;
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
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',

View file

@ -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<Incident, GetIncidentResponse> {
addComment: ({ incidentId, comment }: { incidentId: string; comment: string }) => Promise<void>;
createIncident: (incident: Incident) => Promise<ExternalServiceIncidentResponse>;
updateIncident: ({
incidentId,
incident,
}: {
incidentId: string;
incident: Incident;
}) => Promise<ExternalServiceIncidentResponse>;
getIncident: ({ id }: { id: string }) => Promise<GetIncidentResponse>;
pushToService: (params: {
incident: { externalId: string | null } & Incident;
comments: Array<{ commentId: string; comment: string }>;
}) => Promise<PushToServiceResponse>;
addComment: (
{ incidentId, comment }: { incidentId: string; comment: string },
connectorUsageCollector: ConnectorUsageCollector
) => Promise<void>;
createIncident: (
incident: Incident,
connectorUsageCollector: ConnectorUsageCollector
) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (
{
incidentId,
incident,
}: {
incidentId: string;
incident: Incident;
},
connectorUsageCollector: ConnectorUsageCollector
) => Promise<ExternalServiceIncidentResponse>;
getIncident: (
{ id }: { id: string },
connectorUsageCollector: ConnectorUsageCollector
) => Promise<GetIncidentResponse>;
pushToService: (
params: {
incident: { externalId: string | null } & Incident;
comments: Array<{ commentId: string; comment: string }>;
},
connectorUsageCollector: ConnectorUsageCollector
) => Promise<PushToServiceResponse>;
}
export abstract class CaseConnector<Config, Secrets, Incident, GetIncidentResponse>
@ -56,50 +72,71 @@ export abstract class CaseConnector<Config, Secrets, Incident, GetIncidentRespon
});
}
public abstract addComment({
incidentId,
comment,
}: {
incidentId: string;
comment: string;
}): Promise<void>;
public abstract addComment(
{
incidentId,
comment,
}: {
incidentId: string;
comment: string;
},
connectorUsageCollector: ConnectorUsageCollector
): Promise<void>;
public abstract createIncident(incident: Incident): Promise<ExternalServiceIncidentResponse>;
public abstract updateIncident({
incidentId,
incident,
}: {
incidentId: string;
incident: Incident;
}): Promise<ExternalServiceIncidentResponse>;
public abstract getIncident({ id }: { id: string }): Promise<GetIncidentResponse>;
public abstract createIncident(
incident: Incident,
connectorUsageCollector: ConnectorUsageCollector
): Promise<ExternalServiceIncidentResponse>;
public abstract updateIncident(
{
incidentId,
incident,
}: {
incidentId: string;
incident: Incident;
},
connectorUsageCollector: ConnectorUsageCollector
): Promise<ExternalServiceIncidentResponse>;
public abstract getIncident(
{ id }: { id: string },
connectorUsageCollector: ConnectorUsageCollector
): Promise<GetIncidentResponse>;
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 ?? []),

View file

@ -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<typeof actionsMock.createServices>;
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
let connectorUsageCollector: ConnectorUsageCollector;
const createExecutor = (Service: IService<TestConfig, TestSecrets>) => {
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<TestConfig, TestSecrets>) => {
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);
});
});

View file

@ -30,7 +30,15 @@ export const buildExecutor = <
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ExecutorType<Config, Secrets, ExecutorParams, unknown> => {
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 };
};
};

View file

@ -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<TestConfig, TestS
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
}
public async testUrl({ url, data = {} }: { url: string; data?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> }) {
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<string, unknown> },
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<string, unknown> } = {}) {
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<string, unknown> } = {},
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;
}

View file

@ -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<typeof actionsMock.createServices>;
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
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,
});
});
});

View file

@ -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<Config, Secrets> {
protected abstract getResponseErrorMessage(error: AxiosError): string;
protected async request<R>({
url,
data,
method = 'get',
responseSchema,
headers,
timeout,
...config
}: SubActionRequestParams<R>): Promise<AxiosResponse<R>> {
protected async request<R>(
{
url,
data,
method = 'get',
responseSchema,
headers,
timeout,
...config
}: SubActionRequestParams<R>,
connectorUsageCollector: ConnectorUsageCollector
): Promise<AxiosResponse<R>> {
try {
this.assertURL(url);
this.ensureUriAllowed(url);
@ -160,6 +164,7 @@ export abstract class SubActionConnector<Config, Secrets> {
configurationUtilities: this.configurationUtilities,
headers: this.getHeaders(auth, headers as AxiosHeaders),
timeout,
connectorUsageCollector,
});
this.validateResponse(responseSchema, res.data);

View file

@ -39,11 +39,11 @@ export type ActionTypeSecrets = Record<string, unknown>;
export type ActionTypeParams = Record<string, unknown>;
export type ConnectorTokenClientContract = PublicMethodsOf<ConnectorTokenClient>;
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<unknown>;
request?: KibanaRequest;
connectorUsageCollector: ConnectorUsageCollector;
}
export type ActionResult = Connector;

View file

@ -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';

View file

@ -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: ")
);
});
});

View file

@ -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;
}
}

View file

@ -6,3 +6,4 @@
*/
export { registerActionsUsageCollector } from './actions_usage_collector';
export { ConnectorUsageCollector } from './connector_usage_collector';

View file

@ -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"
}
}
}
}
}

View file

@ -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(),
})
),
})
),
})

View file

@ -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',
},
},
},
},
},
},

View file

@ -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 });
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,
});
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 });
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,
});
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', () => {

View file

@ -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<RunActionResponse | InvokeAIRawActionResponse>
params: SubActionRequestParams<RunActionResponse | InvokeAIRawActionResponse>,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse | InvokeAIRawActionResponse> {
const response = await this.request(params);
const response = await this.request(params, connectorUsageCollector);
return response.data;
}
private async runApiLatest(
params: SubActionRequestParams<RunApiLatestResponse>
params: SubActionRequestParams<RunApiLatestResponse>,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse> {
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<RunActionResponse | InvokeAIRawActionResponse> {
public async runApi(
{ body, model: reqModel, signal, timeout, raw }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse | InvokeAIRawActionResponse> {
// 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<StreamingResponse> {
private async streamApi(
{ body, model: reqModel, signal, timeout }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<StreamingResponse> {
// 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<IncomingMessage> {
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<IncomingMessage> {
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<InvokeAIActionResponse> {
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<InvokeAIActionResponse> {
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<InvokeAIRawActionResponse> {
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<InvokeAIRawActionResponse> {
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;
}
}

View file

@ -70,7 +70,7 @@ export async function executor(
CasesWebhookActionParamsType
>
): Promise<ConnectorTypeExecutorResult<CasesWebhookExecutorResultData>> {
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]) {

View file

@ -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({

View file

@ -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({

View file

@ -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);
});
});

View file

@ -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,
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,
},
data: {
ids: payload.ids,
...(payload.actionParameters
? {
action_parameters: Object.entries(payload.actionParameters).map(([name, value]) => ({
name,
value,
})),
}
: {}),
},
paramsSerializer,
responseSchema: CrowdstrikeHostActionsResponseSchema,
});
connectorUsageCollector
);
}
public async getAgentDetails(
payload: CrowdstrikeGetAgentsParams
payload: CrowdstrikeGetAgentsParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<CrowdstrikeGetAgentsResponse> {
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<CrowdstrikeGetAgentsResponse>;
connectorUsageCollector
) as Promise<CrowdstrikeGetAgentsResponse>;
}
public async getAgentOnlineStatus(
payload: CrowdstrikeGetAgentsParams
payload: CrowdstrikeGetAgentsParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<CrowdstrikeGetAgentOnlineStatusResponse> {
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<CrowdstrikeGetAgentOnlineStatusResponse>;
connectorUsageCollector
) as Promise<CrowdstrikeGetAgentOnlineStatusResponse>;
}
private async getTokenRequest() {
const response = await this.request<CrowdstrikeGetTokenResponse>({
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<CrowdstrikeGetTokenResponse>(
{
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<R extends RelaxedCrowdstrikeBaseApiResponse>(
req: SubActionRequestParams<R>,
connectorUsageCollector: ConnectorUsageCollector,
retried?: boolean
): Promise<R> {
try {
if (!CrowdstrikeConnector.token) {
CrowdstrikeConnector.token = (await this.getTokenRequest()) as string;
CrowdstrikeConnector.token = (await this.getTokenRequest(
connectorUsageCollector
)) as string;
}
const response = await this.request<R>({
...req,
headers: {
...req.headers,
Authorization: `Bearer ${CrowdstrikeConnector.token}`,
const response = await this.request<R>(
{
...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);
}
}

View file

@ -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'
);
});
});
});

View file

@ -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<D3SecurityConfig, D3
return `API Error: ${error.response?.status} - ${error.response?.statusText}`;
}
public async runApi({
body,
severity,
eventType,
}: D3SecurityRunActionParams): Promise<D3SecurityRunActionResponse> {
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<D3SecurityRunActionResponse> {
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;
}
}

View file

@ -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(() => {

View file

@ -274,8 +274,16 @@ async function executor(
},
execOptions: EmailConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
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',

View file

@ -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<Logger>;
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);

View file

@ -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<unknown> {
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<unknown> {
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<unknown> {
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) {

View file

@ -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.\\"}}"'

View file

@ -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<string, string>;
messageHTML: string;
}
export async function sendEmailGraphApi(
sendEmailOptions: SendEmailGraphApiOptions,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
connectorUsageCollector: ConnectorUsageCollector,
axiosInstance?: AxiosInstance
): Promise<AxiosResponse> {
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<string, string>;
messageHTML: string;
}
function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
const { routing, content } = emailOptions;
const { to, cc, bcc } = routing;

View file

@ -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<Logger> = 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 {

View file

@ -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
);
});
});

View file

@ -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<Config, Secrets> {
* @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<RunActionResponse | RunActionRawResponse> {
public async runApi(
{ body, model: reqModel, signal, timeout, raw }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse | RunActionRawResponse> {
// 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<Config, Secrets> {
responseSchema: raw ? RunActionRawResponseSchema : RunApiResponseSchema,
} as SubActionRequestParams<RunApiResponse>;
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<Config, Secrets> {
return { completion: completionText, usageMetadata };
}
private async streamAPI({
body,
model: reqModel,
signal,
timeout,
}: RunActionParams): Promise<StreamingResponse> {
private async streamAPI(
{ body, model: reqModel, signal, timeout }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<StreamingResponse> {
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<InvokeAIActionResponse> {
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<InvokeAIActionResponse> {
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<InvokeAIRawActionResponse> {
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<InvokeAIRawActionResponse> {
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<Config, Secrets> {
* @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<IncomingMessage> {
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<IncomingMessage> {
return (await this.streamAPI(
{
body: JSON.stringify({ ...formatGeminiPayload(messages, temperature), tools }),
model,
stopSequences,
signal,
timeout,
},
connectorUsageCollector
)) as unknown as IncomingMessage;
}
}

View file

@ -95,7 +95,15 @@ async function executor(
ExecutorParams
>
): Promise<ConnectorTypeExecutorResult<JiraExecutorResultData | {}>> {
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]) {

View file

@ -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<Logger>;
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,
});
});

View file

@ -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({

View file

@ -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<Logger>;
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,
});
});
});

View file

@ -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<T = ExternalService> {
serviceConfig: SNProductsConfigValue;
connectorTokenClient: ConnectorTokenClientContract;
createServiceFn: ServiceFactory<T>;
connectorUsageCollector: ConnectorUsageCollector;
}
export function createServiceWrapper<T = ExternalService>({
@ -31,6 +35,7 @@ export function createServiceWrapper<T = ExternalService>({
serviceConfig,
connectorTokenClient,
createServiceFn,
connectorUsageCollector,
}: CreateServiceWrapperOpts<T>): T {
const { config } = credentials;
const { apiUrl: url } = config as ServiceNowPublicConfigurationType;
@ -50,5 +55,6 @@ export function createServiceWrapper<T = ExternalService>({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
});
}

View file

@ -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<Logger>;
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();

View file

@ -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;

View file

@ -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<T = ExternalService> = ({
@ -313,6 +314,7 @@ export type ServiceFactory<T = ExternalService> = ({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
}: ServiceFactoryOpts) => T;
/**

View file

@ -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,
}),
});
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',
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',
},
},
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,
});
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',
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',
},
},
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,
});
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',
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',
},
},
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 });
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',
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,
},
signal,
});
connectorUsageCollector
);
});
it('timeout is properly passed to streamApi', async () => {
const timeout = 180000;
await connector.invokeStream({ ...sampleOpenAiBody, 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',
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,
},
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 }),
});
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',
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',
},
},
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,
});
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',
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',
},
},
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,
});
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',
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',
},
},
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');
});
});

View file

@ -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<Config, Secrets> {
* 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<RunActionResponse> {
public async runApi(
{ body, signal, timeout }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse> {
const sanitizedBody = sanitizeRequest(
this.provider,
this.url,
@ -164,20 +169,23 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
...('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<Config, Secrets> {
* @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<RunActionResponse> {
public async streamApi(
{ body, stream, signal, timeout }: StreamActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<RunActionResponse> {
const executeBody = getRequestWithStreamOption(
this.provider,
this.url,
@ -205,19 +211,22 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
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<Config, Secrets> {
* returned directly to the client for streaming
* @param body - the OpenAI Invoke request body
*/
public async invokeStream(body: InvokeAIActionParams): Promise<PassThrough> {
public async invokeStream(
body: InvokeAIActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<PassThrough> {
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<Config, Secrets> {
* tokenCountStream: Stream<ChatCompletionChunk>; the result for token counting stream
* }
*/
public async invokeAsyncIterator(body: InvokeAIActionParams): Promise<{
public async invokeAsyncIterator(
body: InvokeAIActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<{
consumerStream: Stream<ChatCompletionChunk>;
tokenCountStream: Stream<ChatCompletionChunk>;
}> {
@ -301,6 +319,8 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
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<Config, Secrets> {
* @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<InvokeAIActionResponse> {
public async invokeAI(
body: InvokeAIActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<InvokeAIActionResponse> {
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();

View file

@ -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<ActionsConfigurationUtilities>;
let logger: MockedLogger;
let services: ReturnType<typeof actionsMock.createServices>;
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,
});
});

View file

@ -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<Config, Secrets> {
}
}
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<Config, Secrets> {
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<Config, Secrets> {
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;
}

View file

@ -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<Logger> = loggerMock.create();
let connectorType: PagerDutyConnectorType;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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];

View file

@ -198,8 +198,16 @@ function getPagerDutyApiUrl(config: ConnectorTypeConfigType): string {
async function executor(
execOptions: PagerDutyConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
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', {

View file

@ -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<AxiosResponse> {
const { apiUrl, data, headers } = options;
const axiosInstance = axios.create();
@ -36,5 +37,6 @@ export async function postPagerduty(
headers,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
}

View file

@ -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]).'
);
});

View file

@ -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<ExternalServiceIncidentResponse> {
public async createIncident(
incident: Incident,
connectorUsageCollector: ConnectorUsageCollector
): Promise<ExternalServiceIncidentResponse> {
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<ExternalServiceIncidentResponse> {
public async updateIncident(
{ incidentId, incident }: UpdateIncidentParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<ExternalServiceIncidentResponse> {
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<GetIncidentResponse> {
public async getIncident(
{ id }: { id: string },
connectorUsageCollector: ConnectorUsageCollector
): Promise<GetIncidentResponse> {
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<GetIncidentTypesResponse> {
public async getIncidentTypes(
params: unknown,
connectorUsageCollector: ConnectorUsageCollector
): Promise<GetIncidentTypesResponse> {
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<GetSeverityResponse> {
public async getSeverity(
params: unknown,
connectorUsageCollector: ConnectorUsageCollector
): Promise<GetSeverityResponse> {
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 {

View file

@ -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<typeof sentinelOneConnectorMocks.create>;
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);
});
});

View file

@ -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<SentinelOneGetAgentsResponse> {
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<SentinelOneGetRemoteScriptStatusApiResponse> {
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<SentinelOneGetRemoteScriptResultsApiResponse> {
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<SentinelOneGetRemoteScriptResultsApiResponse> {
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<Stream> {
const scriptResultsInfo = await this.getRemoteScriptResults({ taskIds: [taskId] });
public async downloadRemoteScriptResults(
{ taskId }: SentinelOneDownloadRemoteScriptResultsParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<Stream> {
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<R extends SentinelOneBaseApiResponse>(
req: SubActionRequestParams<R>
req: SubActionRequestParams<R>,
connectorUsageCollector: ConnectorUsageCollector
): Promise<R> {
const response = await this.request<R>({
...req,
params: {
...req.params,
APIToken: this.secrets.token,
const response = await this.request<R>(
{
...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<SentinelOneGetRemoteScriptsResponse> {
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
);
}
}

View file

@ -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');

View file

@ -102,7 +102,15 @@ async function executorITOM(
ExecutorParamsITOM
>
): Promise<ConnectorTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
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<string, unknown>;

View file

@ -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<Logger>;
@ -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,
});
});
});

View file

@ -23,6 +23,7 @@ export const createExternalService: ServiceFactory<ExternalServiceITOM> = ({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
}): ExternalServiceITOM => {
const snService = createExternalServiceCommon({
credentials,
@ -30,6 +31,7 @@ export const createExternalService: ServiceFactory<ExternalServiceITOM> = ({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
});
const addEvent = async (params: ExecutorSubActionAddEventParams) => {
@ -41,6 +43,7 @@ export const createExternalService: ServiceFactory<ExternalServiceITOM> = ({
method: 'post',
data: { records: [params] },
configurationUtilities,
connectorUsageCollector,
});
snService.checkInstance(res);

View file

@ -125,8 +125,16 @@ async function executor(
ExecutorParams
>
): Promise<ConnectorTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
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<string, unknown>;

View file

@ -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<Logger>;
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();

View file

@ -116,8 +116,16 @@ async function executor(
ExecutorParams
>
): Promise<ConnectorTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
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<string, unknown>;

View file

@ -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<Logger>;
@ -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;
});

View file

@ -28,6 +28,7 @@ export const createExternalService: ServiceFactory<ExternalServiceSIR> = ({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
}): ExternalServiceSIR => {
const snService = createExternalServiceCommon({
credentials,
@ -35,6 +36,7 @@ export const createExternalService: ServiceFactory<ExternalServiceSIR> = ({
configurationUtilities,
serviceConfig,
axiosInstance,
connectorUsageCollector,
});
const _addObservable = async (data: Observable | Observable[], url: string) => {
@ -47,6 +49,7 @@ export const createExternalService: ServiceFactory<ExternalServiceSIR> = ({
method: 'post',
data,
configurationUtilities,
connectorUsageCollector,
});
snService.checkInstance(res);

View file

@ -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<Logger> = loggerMock.create();
let connectorType: SlackConnectorType;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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'

View file

@ -139,7 +139,8 @@ function validateConnectorTypeConfig(
async function slackExecutor(
execOptions: SlackConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
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) {

View file

@ -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<ActionsConfigurationUtilities>;
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({

View file

@ -107,6 +107,7 @@ const slackApiExecutor = async ({
secrets,
configurationUtilities,
logger,
connectorUsageCollector,
}: SlackApiExecutorOptions): Promise<ActionTypeExecutorResult<unknown>> => {
const subAction = params.subAction;
@ -128,7 +129,8 @@ const slackApiExecutor = async ({
secrets,
},
logger,
configurationUtilities
configurationUtilities,
connectorUsageCollector
);
if (subAction === 'validChannelId') {

View file

@ -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<Logger>;
@ -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,
});
});

View file

@ -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 });

View file

@ -76,7 +76,15 @@ async function executor(
ExecutorParams
>
): Promise<ConnectorTypeExecutorResult<SwimlaneExecutorResultData | {}>> {
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]) {

View file

@ -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<Logger>;
@ -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,
});
});

View file

@ -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,
});
/**

View file

@ -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<Logger> = loggerMock.create();
let connectorType: TeamsConnectorType;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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",
},

View file

@ -119,7 +119,8 @@ function validateConnectorTypeConfig(
async function teamsExecutor(
execOptions: TeamsConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
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,
})
);

View file

@ -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',
});
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,
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,
},
},
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'
);
});
});
});

View file

@ -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<ExternalServiceIncidentResponse> {
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<ExternalServiceIncidentResponse> {
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<ExternalServiceIncidentResponse> {
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<ExternalServiceIncidentResponse> {
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<GetIncidentResponse> {
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<GetIncidentResponse> {
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
);
}
}

View file

@ -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<typeof axios>).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,
});
});
});

View file

@ -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<TinesConfig, TinesSecrets
private async tinesApiRequest<R extends TinesBaseApiResponse, T>(
req: SubActionRequestParams<R>,
reducer: (response: R) => T
reducer: (response: R) => T,
connectorUsageCollector: ConnectorUsageCollector
): Promise<T & { incompleteResponse: boolean }> {
const response = await this.request<R>({
...req,
params: { ...req.params, per_page: API_MAX_RESULTS },
});
const response = await this.request<R>(
{
...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<TinesConfig, TinesSecrets
return `API Error: ${error.response?.statusText}`;
}
public async getStories(): Promise<TinesStoriesActionResponse> {
public async getStories(
params: unknown,
connectorUsageCollector: ConnectorUsageCollector
): Promise<TinesStoriesActionResponse> {
return this.tinesApiRequest(
{
url: this.urls.stories,
headers: this.getAuthHeaders(),
responseSchema: TinesStoriesApiResponseSchema,
},
storiesReducer
storiesReducer,
connectorUsageCollector
);
}
public async getWebhooks({
storyId,
}: TinesWebhooksActionParams): Promise<TinesWebhooksActionResponse> {
public async getWebhooks(
{ storyId }: TinesWebhooksActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<TinesWebhooksActionResponse> {
return this.tinesApiRequest(
{
url: this.urls.agents,
@ -151,24 +161,27 @@ export class TinesConnector extends SubActionConnector<TinesConfig, TinesSecrets
headers: this.getAuthHeaders(),
responseSchema: TinesWebhooksApiResponseSchema,
},
webhooksReducer
webhooksReducer,
connectorUsageCollector
);
}
public async runWebhook({
webhook,
webhookUrl,
body,
}: TinesRunActionParams): Promise<TinesRunActionResponse> {
public async runWebhook(
{ webhook, webhookUrl, body }: TinesRunActionParams,
connectorUsageCollector: ConnectorUsageCollector
): Promise<TinesRunActionResponse> {
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;
}
}

View file

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`execute Torq action execute with token happy flow 1`] = `
Object {
"axios": Any<Function>,
"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<Object>,
"method": "post",
"url": "https://hooks.torq.io/v1/test",
"validateStatus": Any<Function>,
}
`;

View file

@ -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<Logger> = loggerMock.create();
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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",
expect(requestMock.mock.calls[0][0]).toMatchSnapshot({
axios: expect.any(Function),
connectorUsageCollector: {
usage: {
requestBodyBytes: 0,
},
"headers": Object {
"Content-Type": "application/json",
"X-Torq-Token": "1234",
},
"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 () => {

View file

@ -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,
})
);

View file

@ -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<Object>,
"method": "post",
"sslOverrides": Object {},
"url": "https://abc.def/my-webhook",
}
`;

View file

@ -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<Logger> = loggerMock.create();
let connectorType: WebhookConnectorType;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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",
expect(requestMock.mock.calls[0][0]).toMatchSnapshot({
axios: undefined,
connectorUsageCollector: {
usage: {
requestBodyBytes: 0,
},
"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],
},
"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",

View file

@ -128,7 +128,8 @@ function validateConnectorTypeConfig(
export async function executor(
execOptions: WebhookConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
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,
})
);

View file

@ -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<Logger> = loggerMock.create();
let connectorType: XmattersConnectorType;
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
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];

View file

@ -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<ConnectorTypeExecutorResult<unknown>> {
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',

View file

@ -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<AxiosResponse> {
const { url, data, basicAuth } = options;
const axiosInstance = axios.create();
@ -50,5 +52,6 @@ export async function postXmatters(
data,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
}

View file

@ -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 };
}

View file

@ -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', () => {

View file

@ -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,

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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';

View file

@ -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 () => {

Some files were not shown because too many files have changed in this diff Show more