[APM] Stabilize agent configuration API (#57767)

This commit is contained in:
Søren Louv-Jansen 2020-02-24 23:43:40 +01:00 committed by GitHub
parent 7e087633d2
commit 13eacb51f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 935 additions and 411 deletions

260
docs/apm/api.asciidoc Normal file
View file

@ -0,0 +1,260 @@
[role="xpack"]
[[apm-api]]
== API
Some APM app features are provided via a REST API:
* <<agent-config-api>>
TIP: Kibana provides additional <<api,REST APIs>>,
and general information on <<using-api,how to use APIs>>.
////
*******************************************************
////
[[agent-config-api]]
=== Agent Configuration API
The Agent configuration API allows you to fine-tune your APM agent configuration,
without needing to redeploy your application.
The following Agent configuration APIs are available:
* <<apm-update-config>> to create or update an Agent configuration
* <<apm-delete-config>> to delete an Agent configuration.
* <<apm-list-config>> to list all Agent configurations.
* <<apm-search-config>> to search for an Agent configuration.
////
*******************************************************
////
[[apm-update-config]]
==== Create or update configuration
[[apm-update-config-req]]
===== Request
`PUT /api/apm/settings/agent-configuration`
[[apm-update-config-req-body]]
===== Request body
`service`::
(required, object) Service identifying the configuration to create or update.
`name` :::
(required, string) Name of service
`environment` :::
(optional, string) Environment of service
`settings`::
(required) Key/value object with settings and their corresponding value.
`agent_name`::
(optional) The agent name is used by the UI to determine which settings to display.
[[apm-update-config-example]]
===== Example
[source,console]
--------------------------------------------------
PUT /api/apm/settings/agent-configuration
{
"service" : {
"name" : "frontend",
"environment" : "production"
},
"settings" : {
"transaction_sample_rate" : 0.4,
"capture_body" : "off",
"transaction_max_spans" : 500
},
"agent_name": "nodejs"
}
--------------------------------------------------
////
*******************************************************
////
[[apm-delete-config]]
==== Delete configuration
[[apm-delete-config-req]]
===== Request
`DELETE /api/apm/settings/agent-configuration`
[[apm-delete-config-req-body]]
===== Request body
`service`::
(required, object) Service identifying the configuration to delete
`name` :::
(required, string) Name of service
`environment` :::
(optional, string) Environment of service
[[apm-delete-config-example]]
===== Example
[source,console]
--------------------------------------------------
DELETE /api/apm/settings/agent-configuration
{
"service" : {
"name" : "frontend",
"environment": "production"
}
}
--------------------------------------------------
////
*******************************************************
////
[[apm-list-config]]
==== List configuration
[[apm-list-config-req]]
===== Request
`GET /api/apm/settings/agent-configuration`
[[apm-list-config-body]]
===== Response body
[source,js]
--------------------------------------------------
[
{
"agent_name": "go",
"service": {
"name": "opbeans-go",
"environment": "production"
},
"settings": {
"transaction_sample_rate": 1,
"capture_body": "off",
"transaction_max_spans": 200
},
"@timestamp": 1581934104843,
"applied_by_agent": false,
"etag": "1e58c178efeebae15c25c539da740d21dee422fc"
},
{
"agent_name": "go",
"service": {
"name": "opbeans-go"
},
"settings": {
"transaction_sample_rate": 1,
"capture_body": "off",
"transaction_max_spans": 300
},
"@timestamp": 1581934111727,
"applied_by_agent": false,
"etag": "3eed916d3db434d9fb7f039daa681c7a04539a64"
},
{
"agent_name": "nodejs",
"service": {
"name": "frontend"
},
"settings": {
"transaction_sample_rate": 1,
},
"@timestamp": 1582031336265,
"applied_by_agent": false,
"etag": "5080ed25785b7b19f32713681e79f46996801a5b"
}
]
--------------------------------------------------
[[apm-list-config-example]]
===== Example
[source,console]
--------------------------------------------------
GET /api/apm/settings/agent-configuration
--------------------------------------------------
////
*******************************************************
////
[[apm-search-config]]
==== Search configuration
[[apm-search-config-req]]
===== Request
`POST /api/apm/settings/agent-configuration/search`
[[apm-search-config-req-body]]
===== Request body
`service`::
(required, object) Service identifying the configuration.
`name` :::
(required, string) Name of service
`environment` :::
(optional, string) Environment of service
`etag`::
(required) etag is sent by the agent to indicate the etag of the last successfully applied configuration. If the etag matches an existing configuration its `applied_by_agent` property will be set to `true`. Every time a configuration is edited `applied_by_agent` is reset to `false`.
[[apm-search-config-body]]
===== Response body
[source,js]
--------------------------------------------------
{
"_index": ".apm-agent-configuration",
"_id": "CIaqXXABmQCdPphWj8EJ",
"_score": 2,
"_source": {
"agent_name": "nodejs",
"service": {
"name": "frontend"
},
"settings": {
"transaction_sample_rate": 1,
},
"@timestamp": 1582031336265,
"applied_by_agent": false,
"etag": "5080ed25785b7b19f32713681e79f46996801a5b"
}
}
--------------------------------------------------
[[apm-search-config-example]]
===== Example
[source,console]
--------------------------------------------------
POST /api/apm/settings/agent-configuration/search
{
"etag" : "1e58c178efeebae15c25c539da740d21dee422fc",
"service" : {
"name" : "frontend",
"environment": "production"
}
}
--------------------------------------------------
////
*******************************************************
////

View file

@ -24,3 +24,5 @@ include::getting-started.asciidoc[]
include::bottlenecks.asciidoc[]
include::using-the-apm-ui.asciidoc[]
include::api.asciidoc[]

View file

@ -51,12 +51,18 @@ async function deleteConfig(
) {
try {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
pathname: '/api/apm/settings/agent-configuration',
method: 'DELETE',
params: {
path: { configurationId: selectedConfig.id }
body: {
service: {
name: selectedConfig.service.name,
environment: selectedConfig.service.environment
}
}
}
});
toasts.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle',

View file

@ -135,8 +135,8 @@ export function AddEditFlyout({
sampleRate,
captureBody,
transactionMaxSpans,
configurationId: selectedConfig ? selectedConfig.id : undefined,
agentName,
isExistingConfig: Boolean(selectedConfig),
toasts,
trackApmEvent
});

View file

@ -27,8 +27,8 @@ export async function saveConfig({
sampleRate,
captureBody,
transactionMaxSpans,
configurationId,
agentName,
isExistingConfig,
toasts,
trackApmEvent
}: {
@ -38,8 +38,8 @@ export async function saveConfig({
sampleRate: string;
captureBody: string;
transactionMaxSpans: string;
configurationId?: string;
agentName?: string;
isExistingConfig: boolean;
toasts: NotificationsStart['toasts'];
trackApmEvent: UiTracker;
}) {
@ -64,24 +64,14 @@ export async function saveConfig({
settings
};
if (configurationId) {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
method: 'PUT',
params: {
path: { configurationId },
body: configuration
}
});
} else {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/new',
method: 'POST',
params: {
body: configuration
}
});
}
await callApmApi({
pathname: '/api/apm/settings/agent-configuration',
method: 'PUT',
params: {
query: { overwrite: isExistingConfig },
body: configuration
}
});
toasts.addSuccess({
title: i18n.translate(

View file

@ -71,6 +71,28 @@ node scripts/jest.js plugins/apm --watch
node scripts/jest.js plugins/apm --updateSnapshot
```
### Functional tests
**Start server**
`node scripts/functional_tests_server --config x-pack/test/functional/config.js`
**Run tests**
`node scripts/functional_test_runner --config x-pack/test/functional/config.js --grep='APM specs'`
APM tests are located in `x-pack/test/functional/apps/apm`.
For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme)
### API integration tests
**Start server**
`node scripts/functional_tests_server --config x-pack/test/api_integration/config.js`
**Run tests**
`node scripts/functional_test_runner --config x-pack/test/api_integration/config.js --grep='APM specs'`
APM tests are located in `x-pack/test/api_integration/apis/apm`.
For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme)
### Linting
_Note: Run the following commands from `kibana/`._

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { agentConfigurationIntakeRt } from './index';
import { isRight } from 'fp-ts/lib/Either';
describe('agentConfigurationIntakeRt', () => {
it('is valid when required parameters are given', () => {
const config = {
service: {},
settings: {}
};
expect(isConfigValid(config)).toBe(true);
});
it('is valid when required and optional parameters are given', () => {
const config = {
service: { name: 'my-service', environment: 'my-environment' },
settings: {
transaction_sample_rate: 0.5,
capture_body: 'foo',
transaction_max_spans: 10
}
};
expect(isConfigValid(config)).toBe(true);
});
it('is invalid when required parameters are not given', () => {
const config = {};
expect(isConfigValid(config)).toBe(false);
});
});
function isConfigValid(config: any) {
return isRight(agentConfigurationIntakeRt.decode(config));
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { transactionSampleRateRt } from '../transaction_sample_rate_rt';
import { transactionMaxSpansRt } from '../transaction_max_spans_rt';
export const serviceRt = t.partial({
name: t.string,
environment: t.string
});
export const agentConfigurationIntakeRt = t.intersection([
t.partial({ agent_name: t.string }),
t.type({
service: serviceRt,
settings: t.partial({
transaction_sample_rate: transactionSampleRateRt,
capture_body: t.string,
transaction_max_spans: transactionMaxSpansRt
})
})
]);

View file

@ -7,9 +7,10 @@
/* eslint-disable no-console */
import {
IndexDocumentParams,
IndicesCreateParams,
IndicesDeleteParams,
SearchParams
SearchParams,
IndicesCreateParams,
DeleteDocumentResponse
} from 'elasticsearch';
import { cloneDeep, isString, merge, uniqueId } from 'lodash';
import { KibanaRequest } from 'src/core/server';
@ -188,7 +189,7 @@ export function getESClient(
index: <Body>(params: APMIndexDocumentParams<Body>) => {
return withTime(() => callMethod('index', params));
},
delete: (params: IndicesDeleteParams) => {
delete: (params: IndicesDeleteParams): Promise<DeleteDocumentResponse> => {
return withTime(() => callMethod('delete', params));
},
indicesCreate: (params: IndicesCreateParams) => {

View file

@ -58,8 +58,8 @@ export async function getServiceNodeMetadata({
const response = await client.search(query);
return {
host: response.aggregations?.host.buckets[0].key || NOT_AVAILABLE_LABEL,
host: response.aggregations?.host.buckets[0]?.key || NOT_AVAILABLE_LABEL,
containerId:
response.aggregations?.containerId.buckets[0].key || NOT_AVAILABLE_LABEL
response.aggregations?.containerId.buckets[0]?.key || NOT_AVAILABLE_LABEL
};
}

View file

@ -1,6 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`agent configuration queries fetches all environments 1`] = `
exports[`agent configuration queries findExactConfiguration find configuration by service.environment 1`] = `
Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"term": Object {
"service.environment": "bar",
},
},
],
},
},
},
"index": "myIndex",
}
`;
exports[`agent configuration queries findExactConfiguration find configuration by service.name 1`] = `
Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"service.name": "foo",
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
},
"index": "myIndex",
}
`;
exports[`agent configuration queries findExactConfiguration find configuration by service.name and service.environment 1`] = `
Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"service.name": "foo",
},
},
Object {
"term": Object {
"service.environment": "bar",
},
},
],
},
},
},
"index": "myIndex",
}
`;
exports[`agent configuration queries getAllEnvironments fetches all environments 1`] = `
Object {
"body": Object {
"aggs": Object {
@ -41,124 +125,36 @@ Object {
}
`;
exports[`agent configuration queries fetches configurations 1`] = `
Object {
"index": "myIndex",
"size": 200,
}
`;
exports[`agent configuration queries fetches filtered configurations with an environment 1`] = `
exports[`agent configuration queries getExistingEnvironmentsForService fetches unavailable environments 1`] = `
Object {
"body": Object {
"aggs": Object {
"environments": Object {
"terms": Object {
"field": "service.environment",
"missing": "ALL_OPTION_VALUE",
"size": 50,
},
},
},
"query": Object {
"bool": Object {
"minimum_should_match": 2,
"should": Array [
"filter": Array [
Object {
"constant_score": Object {
"boost": 2,
"filter": Object {
"term": Object {
"service.name": Object {
"value": "foo",
},
},
},
},
},
Object {
"constant_score": Object {
"boost": 1,
"filter": Object {
"term": Object {
"service.environment": Object {
"value": "bar",
},
},
},
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
"term": Object {
"service.name": "foo",
},
},
],
},
},
"size": 0,
},
"index": "myIndex",
}
`;
exports[`agent configuration queries fetches filtered configurations without an environment 1`] = `
Object {
"body": Object {
"query": Object {
"bool": Object {
"minimum_should_match": 2,
"should": Array [
Object {
"constant_score": Object {
"boost": 2,
"filter": Object {
"term": Object {
"service.name": Object {
"value": "foo",
},
},
},
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
},
"index": "myIndex",
}
`;
exports[`agent configuration queries fetches service names 1`] = `
exports[`agent configuration queries getServiceNames fetches service names 1`] = `
Object {
"body": Object {
"aggs": Object {
@ -194,30 +190,112 @@ Object {
}
`;
exports[`agent configuration queries fetches unavailable environments 1`] = `
exports[`agent configuration queries listConfigurations fetches configurations 1`] = `
Object {
"index": "myIndex",
"size": 200,
}
`;
exports[`agent configuration queries searchConfigurations fetches filtered configurations with an environment 1`] = `
Object {
"body": Object {
"aggs": Object {
"environments": Object {
"terms": Object {
"field": "service.environment",
"missing": "ALL_OPTION_VALUE",
"size": 50,
},
},
},
"query": Object {
"bool": Object {
"filter": Array [
"minimum_should_match": 2,
"should": Array [
Object {
"term": Object {
"service.name": "foo",
"constant_score": Object {
"boost": 2,
"filter": Object {
"term": Object {
"service.name": "foo",
},
},
},
},
Object {
"constant_score": Object {
"boost": 1,
"filter": Object {
"term": Object {
"service.environment": "bar",
},
},
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
},
"index": "myIndex",
}
`;
exports[`agent configuration queries searchConfigurations fetches filtered configurations without an environment 1`] = `
Object {
"body": Object {
"query": Object {
"bool": Object {
"minimum_should_match": 2,
"should": Array [
Object {
"constant_score": Object {
"boost": 2,
"filter": Object {
"term": Object {
"service.name": "foo",
},
},
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
"size": 0,
},
"index": "myIndex",
}

View file

@ -4,18 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface AgentConfiguration {
import t from 'io-ts';
import { agentConfigurationIntakeRt } from '../../../../common/runtime_types/agent_configuration_intake_rt';
export type AgentConfigurationIntake = t.TypeOf<
typeof agentConfigurationIntakeRt
>;
export type AgentConfiguration = {
'@timestamp': number;
applied_by_agent?: boolean;
etag?: string;
agent_name?: string;
service: {
name?: string;
environment?: string;
};
settings: {
transaction_sample_rate?: number;
capture_body?: string;
transaction_max_spans?: number;
};
}
} & AgentConfigurationIntake;

View file

@ -6,19 +6,19 @@
import hash from 'object-hash';
import { Setup } from '../../helpers/setup_request';
import { AgentConfiguration } from './configuration_types';
import {
AgentConfiguration,
AgentConfigurationIntake
} from './configuration_types';
import { APMIndexDocumentParams } from '../../helpers/es_client';
export async function createOrUpdateConfiguration({
configurationId,
configuration,
configurationIntake,
setup
}: {
configurationId?: string;
configuration: Omit<
AgentConfiguration,
'@timestamp' | 'applied_by_agent' | 'etag'
>;
configurationIntake: AgentConfigurationIntake;
setup: Setup;
}) {
const { internalClient, indices } = setup;
@ -27,15 +27,15 @@ export async function createOrUpdateConfiguration({
refresh: true,
index: indices.apmAgentConfigurationIndex,
body: {
agent_name: configuration.agent_name,
agent_name: configurationIntake.agent_name,
service: {
name: configuration.service.name,
environment: configuration.service.environment
name: configurationIntake.service.name,
environment: configurationIntake.service.environment
},
settings: configuration.settings,
settings: configurationIntake.settings,
'@timestamp': Date.now(),
applied_by_agent: false,
etag: hash(configuration)
etag: hash(configurationIntake)
}
};

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../common/elasticsearch_fieldnames';
import { Setup } from '../../helpers/setup_request';
import { AgentConfiguration } from './configuration_types';
import { ESSearchHit } from '../../../../typings/elasticsearch';
export async function findExactConfiguration({
service,
setup
}: {
service: AgentConfiguration['service'];
setup: Setup;
}) {
const { internalClient, indices } = setup;
const serviceNameFilter = service.name
? { term: { [SERVICE_NAME]: service.name } }
: { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } };
const environmentFilter = service.environment
? { term: { [SERVICE_ENVIRONMENT]: service.environment } }
: { bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } };
const params = {
index: indices.apmAgentConfigurationIndex,
body: {
query: {
bool: { filter: [serviceNameFilter, environmentFilter] }
}
}
};
const resp = await internalClient.search<AgentConfiguration, typeof params>(
params
);
return resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined;
}

View file

@ -48,8 +48,6 @@ export async function getAgentNameByService({
};
const { aggregations } = await client.search(params);
const agentName = aggregations?.agent_names.buckets[0].key as
| string
| undefined;
return { agentName };
const agentName = aggregations?.agent_names.buckets[0]?.key;
return agentName as string | undefined;
}

View file

@ -8,11 +8,12 @@ import { getAllEnvironments } from './get_environments/get_all_environments';
import { getExistingEnvironmentsForService } from './get_environments/get_existing_environments_for_service';
import { getServiceNames } from './get_service_names';
import { listConfigurations } from './list_configurations';
import { searchConfigurations } from './search';
import { searchConfigurations } from './search_configurations';
import {
SearchParamsMock,
inspectSearchParams
} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers';
import { findExactConfiguration } from './find_exact_configuration';
describe('agent configuration queries', () => {
let mock: SearchParamsMock;
@ -21,68 +22,117 @@ describe('agent configuration queries', () => {
mock.teardown();
});
it('fetches all environments', async () => {
mock = await inspectSearchParams(setup =>
getAllEnvironments({
serviceName: 'foo',
setup
})
);
describe('getAllEnvironments', () => {
it('fetches all environments', async () => {
mock = await inspectSearchParams(setup =>
getAllEnvironments({
serviceName: 'foo',
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
});
it('fetches unavailable environments', async () => {
mock = await inspectSearchParams(setup =>
getExistingEnvironmentsForService({
serviceName: 'foo',
setup
})
);
describe('getExistingEnvironmentsForService', () => {
it('fetches unavailable environments', async () => {
mock = await inspectSearchParams(setup =>
getExistingEnvironmentsForService({
serviceName: 'foo',
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
});
it('fetches service names', async () => {
mock = await inspectSearchParams(setup =>
getServiceNames({
setup
})
);
describe('getServiceNames', () => {
it('fetches service names', async () => {
mock = await inspectSearchParams(setup =>
getServiceNames({
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
});
it('fetches configurations', async () => {
mock = await inspectSearchParams(setup =>
listConfigurations({
setup
})
);
describe('listConfigurations', () => {
it('fetches configurations', async () => {
mock = await inspectSearchParams(setup =>
listConfigurations({
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
});
it('fetches filtered configurations without an environment', async () => {
mock = await inspectSearchParams(setup =>
searchConfigurations({
serviceName: 'foo',
setup
})
);
describe('searchConfigurations', () => {
it('fetches filtered configurations without an environment', async () => {
mock = await inspectSearchParams(setup =>
searchConfigurations({
service: {
name: 'foo'
},
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
it('fetches filtered configurations with an environment', async () => {
mock = await inspectSearchParams(setup =>
searchConfigurations({
service: {
name: 'foo',
environment: 'bar'
},
setup
})
);
expect(mock.params).toMatchSnapshot();
});
});
it('fetches filtered configurations with an environment', async () => {
mock = await inspectSearchParams(setup =>
searchConfigurations({
serviceName: 'foo',
environment: 'bar',
setup
})
);
describe('findExactConfiguration', () => {
it('find configuration by service.name', async () => {
mock = await inspectSearchParams(setup =>
findExactConfiguration({
service: { name: 'foo' },
setup
})
);
expect(mock.params).toMatchSnapshot();
expect(mock.params).toMatchSnapshot();
});
it('find configuration by service.environment', async () => {
mock = await inspectSearchParams(setup =>
findExactConfiguration({
service: { environment: 'bar' },
setup
})
);
expect(mock.params).toMatchSnapshot();
});
it('find configuration by service.name and service.environment', async () => {
mock = await inspectSearchParams(setup =>
findExactConfiguration({
service: { name: 'foo', environment: 'bar' },
setup
})
);
expect(mock.params).toMatchSnapshot();
});
});
});

View file

@ -12,29 +12,39 @@ import { Setup } from '../../helpers/setup_request';
import { AgentConfiguration } from './configuration_types';
export async function searchConfigurations({
serviceName,
environment,
service,
setup
}: {
serviceName: string;
environment?: string;
service: AgentConfiguration['service'];
setup: Setup;
}) {
const { internalClient, indices } = setup;
const environmentFilter = environment
// In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring).
// Additionally a boost has been added to service.name to ensure it scores higher.
// If there is tie between a config with a matching service.name and a config with a matching environment, the config that matches service.name wins
const serviceNameFilter = service.name
? [
{
constant_score: {
filter: { term: { [SERVICE_ENVIRONMENT]: { value: environment } } },
filter: { term: { [SERVICE_NAME]: service.name } },
boost: 2
}
}
]
: [];
const environmentFilter = service.environment
? [
{
constant_score: {
filter: { term: { [SERVICE_ENVIRONMENT]: service.environment } },
boost: 1
}
}
]
: [];
// In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring)
// Additionally a boost has been added to service.name to ensure it scores higher
// if there is tie between a config with a matching service.name and a config with a matching environment
const params = {
index: indices.apmAgentConfigurationIndex,
body: {
@ -42,12 +52,7 @@ export async function searchConfigurations({
bool: {
minimum_should_match: 2,
should: [
{
constant_score: {
filter: { term: { [SERVICE_NAME]: { value: serviceName } } },
boost: 2
}
},
...serviceNameFilter,
...environmentFilter,
{ bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } },
{ bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }

View file

@ -23,11 +23,10 @@ import {
import {
agentConfigurationRoute,
agentConfigurationSearchRoute,
createAgentConfigurationRoute,
deleteAgentConfigurationRoute,
listAgentConfigurationEnvironmentsRoute,
listAgentConfigurationServicesRoute,
updateAgentConfigurationRoute,
createOrUpdateAgentConfigurationRoute,
agentConfigurationAgentNameRoute
} from './settings/agent_configuration';
import {
@ -83,11 +82,10 @@ const createApmApi = () => {
.add(agentConfigurationAgentNameRoute)
.add(agentConfigurationRoute)
.add(agentConfigurationSearchRoute)
.add(createAgentConfigurationRoute)
.add(deleteAgentConfigurationRoute)
.add(listAgentConfigurationEnvironmentsRoute)
.add(listAgentConfigurationServicesRoute)
.add(updateAgentConfigurationRoute)
.add(createOrUpdateAgentConfigurationRoute)
// APM indices
.add(apmIndexSettingsRoute)

View file

@ -9,15 +9,19 @@ import Boom from 'boom';
import { setupRequest } from '../../lib/helpers/setup_request';
import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names';
import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration';
import { searchConfigurations } from '../../lib/settings/agent_configuration/search';
import { searchConfigurations } from '../../lib/settings/agent_configuration/search_configurations';
import { findExactConfiguration } from '../../lib/settings/agent_configuration/find_exact_configuration';
import { listConfigurations } from '../../lib/settings/agent_configuration/list_configurations';
import { getEnvironments } from '../../lib/settings/agent_configuration/get_environments';
import { deleteConfiguration } from '../../lib/settings/agent_configuration/delete_configuration';
import { createRoute } from '../create_route';
import { transactionSampleRateRt } from '../../../common/runtime_types/transaction_sample_rate_rt';
import { transactionMaxSpansRt } from '../../../common/runtime_types/transaction_max_spans_rt';
import { getAgentNameByService } from '../../lib/settings/agent_configuration/get_agent_name_by_service';
import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_applied_by_agent';
import {
serviceRt,
agentConfigurationIntakeRt
} from '../../../common/runtime_types/agent_configuration_intake_rt';
import { jsonRt } from '../../../common/runtime_types/json_rt';
// get list of configurations
export const agentConfigurationRoute = createRoute(core => ({
@ -31,20 +35,34 @@ export const agentConfigurationRoute = createRoute(core => ({
// delete configuration
export const deleteAgentConfigurationRoute = createRoute(() => ({
method: 'DELETE',
path: '/api/apm/settings/agent-configuration/{configurationId}',
path: '/api/apm/settings/agent-configuration',
options: {
tags: ['access:apm', 'access:apm_write']
},
params: {
path: t.type({
configurationId: t.string
body: t.type({
service: serviceRt
})
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { configurationId } = context.params.path;
const { service } = context.params.body;
const config = await findExactConfiguration({ service, setup });
if (!config) {
context.logger.info(
`Config was not found for ${service.name}/${service.environment}`
);
throw Boom.notFound();
}
context.logger.info(
`Deleting config ${service.name}/${service.environment} (${config._id})`
);
return await deleteConfiguration({
configurationId,
configurationId: config._id,
setup
});
}
@ -62,23 +80,6 @@ export const listAgentConfigurationServicesRoute = createRoute(() => ({
}
}));
const agentPayloadRt = t.intersection([
t.partial({ agent_name: t.string }),
t.type({
service: t.intersection([
t.partial({ name: t.string }),
t.partial({ environment: t.string })
])
}),
t.type({
settings: t.intersection([
t.partial({ transaction_sample_rate: transactionSampleRateRt }),
t.partial({ capture_body: t.string }),
t.partial({ transaction_max_spans: transactionMaxSpansRt })
])
})
]);
// get environments for service
export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({
path: '/api/apm/settings/agent-configuration/environments',
@ -102,55 +103,47 @@ export const agentConfigurationAgentNameRoute = createRoute(() => ({
const setup = await setupRequest(context, request);
const { serviceName } = context.params.query;
const agentName = await getAgentNameByService({ serviceName, setup });
return agentName;
return { agentName };
}
}));
export const createAgentConfigurationRoute = createRoute(() => ({
method: 'POST',
path: '/api/apm/settings/agent-configuration/new',
params: {
body: agentPayloadRt
},
export const createOrUpdateAgentConfigurationRoute = createRoute(() => ({
method: 'PUT',
path: '/api/apm/settings/agent-configuration',
options: {
tags: ['access:apm', 'access:apm_write']
},
params: {
query: t.partial({ overwrite: jsonRt.pipe(t.boolean) }),
body: agentConfigurationIntakeRt
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const configuration = context.params.body;
const { body, query } = context.params;
// TODO: Remove logger. Only added temporarily to debug flaky test (https://github.com/elastic/kibana/issues/51764)
context.logger.info(
`Hitting: /api/apm/settings/agent-configuration/new with ${configuration.service.name}/${configuration.service.environment}`
);
const res = await createOrUpdateConfiguration({
configuration,
// if the config already exists, it is fetched and updated
// this is to avoid creating two configs with identical service params
const config = await findExactConfiguration({
service: body.service,
setup
});
context.logger.info(`Created agent configuration`);
return res;
}
}));
// if the config exists ?overwrite=true is required
if (config && !query.overwrite) {
throw Boom.badRequest(
`A configuration already exists for "${body.service.name}/${body.service.environment}. Use ?overwrite=true to overwrite the existing configuration.`
);
}
context.logger.info(
`${config ? 'Updating' : 'Creating'} config ${body.service.name}/${
body.service.environment
}`
);
export const updateAgentConfigurationRoute = createRoute(() => ({
method: 'PUT',
path: '/api/apm/settings/agent-configuration/{configurationId}',
options: {
tags: ['access:apm', 'access:apm_write']
},
params: {
path: t.type({
configurationId: t.string
}),
body: agentPayloadRt
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { configurationId } = context.params.path;
return await createOrUpdateConfiguration({
configurationId,
configuration: context.params.body,
configurationId: config?._id,
configurationIntake: body,
setup
});
}
@ -162,41 +155,33 @@ export const agentConfigurationSearchRoute = createRoute(core => ({
path: '/api/apm/settings/agent-configuration/search',
params: {
body: t.type({
service: t.intersection([
t.type({ name: t.string }),
t.partial({ environment: t.string })
]),
service: serviceRt,
etag: t.string
})
},
handler: async ({ context, request }) => {
const { body } = context.params;
// TODO: Remove logger. Only added temporarily to debug flaky test (https://github.com/elastic/kibana/issues/51764)
context.logger.info(
`Hitting: /api/apm/settings/agent-configuration/search for ${body.service.name}/${body.service.environment}`
);
const { service, etag } = context.params.body;
const setup = await setupRequest(context, request);
const config = await searchConfigurations({
serviceName: body.service.name,
environment: body.service.environment,
service,
setup
});
if (!config) {
context.logger.info(
`Config was not found for ${body.service.name}/${body.service.environment}`
`Config was not found for ${service.name}/${service.environment}`
);
throw new Boom('Not found', { statusCode: 404 });
throw Boom.notFound();
}
context.logger.info(
`Config was found for ${body.service.name}/${body.service.environment}`
`Config was found for ${service.name}/${service.environment}`
);
// update `applied_by_agent` field if etags match
if (body.etag === config._source.etag && !config._source.applied_by_agent) {
// this happens in the background and doesn't block the response
if (etag === config._source.etag && !config._source.applied_by_agent) {
markAppliedByAgent({ id: config._id, body: config._source, setup });
}

View file

@ -5,6 +5,7 @@
*/
import expect from '@kbn/expect';
import { AgentConfigurationIntake } from '../../../../plugins/apm/server/lib/settings/agent_configuration/configuration_types';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function agentConfigurationTests({ getService }: FtrProviderContext) {
@ -18,108 +19,122 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte
.set('kbn-xsrf', 'foo');
}
let createdConfigIds: any[] = [];
async function createConfiguration(configuration: any) {
async function createConfiguration(config: AgentConfigurationIntake) {
log.debug('creating configuration', config.service);
const res = await supertest
.post(`/api/apm/settings/agent-configuration/new`)
.send(configuration)
.put(`/api/apm/settings/agent-configuration`)
.send(config)
.set('kbn-xsrf', 'foo');
createdConfigIds.push(res.body._id);
throwOnError(res);
return res;
}
function deleteCreatedConfigurations() {
const promises = Promise.all(createdConfigIds.map(deleteConfiguration));
return promises;
async function updateConfiguration(config: AgentConfigurationIntake) {
log.debug('updating configuration', config.service);
const res = await supertest
.put(`/api/apm/settings/agent-configuration?overwrite=true`)
.send(config)
.set('kbn-xsrf', 'foo');
throwOnError(res);
return res;
}
function deleteConfiguration(configurationId: string) {
return supertest
.delete(`/api/apm/settings/agent-configuration/${configurationId}`)
.set('kbn-xsrf', 'foo')
.then((response: any) => {
createdConfigIds = createdConfigIds.filter(id => id === configurationId);
return response;
});
async function deleteConfiguration({ service }: AgentConfigurationIntake) {
log.debug('deleting configuration', service);
const res = await supertest
.delete(`/api/apm/settings/agent-configuration`)
.send({ service })
.set('kbn-xsrf', 'foo');
throwOnError(res);
return res;
}
function throwOnError(res: any) {
const { statusCode, req, body } = res;
if (statusCode !== 200) {
throw new Error(`
Endpoint: ${req.method} ${req.path}
Service: ${JSON.stringify(res.request._data.service)}
Status code: ${statusCode}
Response: ${body.message}`);
}
}
describe('agent configuration', () => {
describe('when creating one configuration', () => {
let createdConfigId: string;
const newConfig = {
service: {},
settings: { transaction_sample_rate: 0.55 },
};
const parameters = {
const searchParams = {
service: { name: 'myservice', environment: 'development' },
etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39',
};
before(async () => {
log.debug('creating agent configuration');
// all / all
const { body } = await createConfiguration({
service: {},
settings: { transaction_sample_rate: 0.1 },
});
createdConfigId = body._id;
await createConfiguration(newConfig);
});
it('returns the created configuration', async () => {
const { statusCode, body } = await searchConfigurations(parameters);
it('can find the created config', async () => {
const { statusCode, body } = await searchConfigurations(searchParams);
expect(statusCode).to.equal(200);
expect(body._id).to.equal(createdConfigId);
expect(body._source.service).to.eql({});
expect(body._source.settings).to.eql({ transaction_sample_rate: 0.55 });
});
it('succesfully deletes the configuration', async () => {
await deleteConfiguration(createdConfigId);
it('can update the created config', async () => {
await updateConfiguration({ service: {}, settings: { transaction_sample_rate: 0.85 } });
const { statusCode } = await searchConfigurations(parameters);
const { statusCode, body } = await searchConfigurations(searchParams);
expect(statusCode).to.equal(200);
expect(body._source.service).to.eql({});
expect(body._source.settings).to.eql({ transaction_sample_rate: 0.85 });
});
it('can delete the created config', async () => {
await deleteConfiguration(newConfig);
const { statusCode } = await searchConfigurations(searchParams);
expect(statusCode).to.equal(404);
});
});
describe('when creating four configurations', () => {
before(async () => {
log.debug('creating agent configuration');
// all / all
await createConfiguration({
describe('when creating multiple configurations', () => {
const configs = [
{
service: {},
settings: { transaction_sample_rate: 0.1 },
});
// my_service / all
await createConfiguration({
},
{
service: { name: 'my_service' },
settings: { transaction_sample_rate: 0.2 },
});
// all / production
await createConfiguration({
service: { environment: 'production' },
settings: { transaction_sample_rate: 0.3 },
});
// all / production
await createConfiguration({
service: { environment: 'development' },
settings: { transaction_sample_rate: 0.4 },
});
// my_service / production
await createConfiguration({
},
{
service: { name: 'my_service', environment: 'development' },
settings: { transaction_sample_rate: 0.3 },
},
{
service: { environment: 'production' },
settings: { transaction_sample_rate: 0.4 },
},
{
service: { environment: 'development' },
settings: { transaction_sample_rate: 0.5 },
});
},
];
before(async () => {
await Promise.all(configs.map(config => createConfiguration(config)));
});
after(async () => {
log.debug('deleting agent configurations');
await deleteCreatedConfigurations();
await Promise.all(configs.map(config => deleteConfiguration(config)));
});
const agentsRequests = [
@ -127,20 +142,24 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte
service: { name: 'non_existing_service', environment: 'non_existing_env' },
expectedSettings: { transaction_sample_rate: 0.1 },
},
{
service: { name: 'my_service', environment: 'non_existing_env' },
expectedSettings: { transaction_sample_rate: 0.2 },
},
{
service: { name: 'my_service', environment: 'production' },
expectedSettings: { transaction_sample_rate: 0.2 },
},
{
service: { name: 'non_existing_service', environment: 'production' },
service: { name: 'my_service', environment: 'development' },
expectedSettings: { transaction_sample_rate: 0.3 },
},
{
service: { name: 'non_existing_service', environment: 'development' },
service: { name: 'non_existing_service', environment: 'production' },
expectedSettings: { transaction_sample_rate: 0.4 },
},
{
service: { name: 'my_service', environment: 'development' },
service: { name: 'non_existing_service', environment: 'development' },
expectedSettings: { transaction_sample_rate: 0.5 },
},
];
@ -159,18 +178,18 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte
});
describe('when an agent retrieves a configuration', () => {
const config = {
service: { name: 'myservice', environment: 'development' },
settings: { transaction_sample_rate: 0.9 },
};
before(async () => {
log.debug('creating agent configuration');
await createConfiguration({
service: { name: 'myservice', environment: 'development' },
settings: { transaction_sample_rate: 0.9 },
});
await createConfiguration(config);
});
after(async () => {
log.debug('deleting agent configurations');
await deleteCreatedConfigurations();
await deleteConfiguration(config);
});
it(`should have 'applied_by_agent=false' on first request`, async () => {

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-console */
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
@ -14,8 +12,8 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
const supertestWithoutAuth = getService('supertestWithoutAuth');
const security = getService('security');
const spaces = getService('spaces');
const log = getService('log');
const es = getService('legacyEs');
const log = getService('log');
const start = encodeURIComponent(new Date(Date.now() - 10000).toISOString());
const end = encodeURIComponent(new Date().toISOString());
@ -33,7 +31,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
interface Endpoint {
req: {
url: string;
method?: 'get' | 'post' | 'delete';
method?: 'get' | 'post' | 'delete' | 'put';
body?: any;
};
expectForbidden: (result: any) => void;
@ -148,7 +146,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
index: '.apm-agent-configuration',
});
console.warn(JSON.stringify(res, null, 2));
log.error(JSON.stringify(res, null, 2));
},
},
];
@ -196,7 +194,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
const { statusCode, req } = response;
if (statusCode !== 200) {
log.debug(`Endpoint: ${req.method} ${req.path}
throw new Error(`Endpoint: ${req.method} ${req.path}
Status code: ${statusCode}
Response: ${response.body.message}`);
}
@ -216,9 +214,9 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
spaceId?: string;
}) {
for (const endpoint of endpoints) {
console.log(`Requesting: ${endpoint.req.url}. Expecting: ${expectation}`);
log.info(`Requesting: ${endpoint.req.url}. Expecting: ${expectation}`);
const result = await executeAsUser(endpoint.req, username, password, spaceId);
console.log(`Responded: ${endpoint.req.url}`);
log.info(`Responded: ${endpoint.req.url}`);
try {
if (expectation === 'forbidden') {
@ -244,26 +242,28 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
}
describe('apm feature controls', () => {
let res: any;
const config = {
service: { name: 'test-service' },
settings: { transaction_sample_rate: 0.5 },
};
before(async () => {
console.log(`Creating agent configuration`);
res = await executeAsAdmin({
method: 'post',
url: '/api/apm/settings/agent-configuration/new',
body: {
service: { name: 'test-service' },
settings: { transaction_sample_rate: 0.5 },
},
log.info(`Creating agent configuration`);
await executeAsAdmin({
method: 'put',
url: '/api/apm/settings/agent-configuration',
body: config,
});
console.log(`Agent configuration created`);
log.info(`Agent configuration created`);
});
after(async () => {
console.log('deleting agent configuration');
const configurationId = res.body._id;
log.info('deleting agent configuration');
await executeAsAdmin({
method: 'delete',
url: `/api/apm/settings/agent-configuration/${configurationId}`,
url: `/api/apm/settings/agent-configuration`,
body: {
service: config.service,
},
});
});

View file

@ -7,7 +7,7 @@
import { FtrProviderContext } from '../../ftr_provider_context';
export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
describe('APM', () => {
describe('APM specs', () => {
loadTestFile(require.resolve('./feature_controls'));
loadTestFile(require.resolve('./agent_configuration'));
});

View file

@ -6,7 +6,7 @@
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ loadTestFile }: FtrProviderContext) {
describe('APM', function() {
describe('APM specs', function() {
this.tags('ciGroup6');
loadTestFile(require.resolve('./feature_controls'));
});