[APM] Annotations API (#64796)
This commit is contained in:
parent
af8f9fa05f
commit
399eed77bb
|
@ -11,6 +11,6 @@ export enum AnnotationType {
|
|||
export interface Annotation {
|
||||
type: AnnotationType;
|
||||
id: string;
|
||||
time: number;
|
||||
'@timestamp': number;
|
||||
text: string;
|
||||
}
|
||||
|
|
|
@ -16,9 +16,13 @@
|
|||
"taskManager",
|
||||
"actions",
|
||||
"alerting",
|
||||
"observability",
|
||||
"security"
|
||||
],
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"configPath": ["xpack", "apm"]
|
||||
"configPath": [
|
||||
"xpack",
|
||||
"apm"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ const style = {
|
|||
export function AnnotationsPlot(props: Props) {
|
||||
const { plotValues, annotations } = props;
|
||||
|
||||
const tickValues = annotations.map(annotation => annotation.time);
|
||||
const tickValues = annotations.map(annotation => annotation['@timestamp']);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -46,12 +46,12 @@ export function AnnotationsPlot(props: Props) {
|
|||
key={annotation.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: plotValues.x(annotation.time) - 8,
|
||||
left: plotValues.x(annotation['@timestamp']) - 8,
|
||||
top: -2
|
||||
}}
|
||||
>
|
||||
<EuiToolTip
|
||||
title={asAbsoluteDateTime(annotation.time, 'seconds')}
|
||||
title={asAbsoluteDateTime(annotation['@timestamp'], 'seconds')}
|
||||
content={
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={true}>
|
||||
|
|
|
@ -28,7 +28,7 @@ const ChartsSyncContextProvider: React.FC = ({ children }) => {
|
|||
callApmApi => {
|
||||
if (start && end && serviceName) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/annotations',
|
||||
pathname: '/api/apm/services/{serviceName}/annotation/search',
|
||||
params: {
|
||||
path: {
|
||||
serviceName
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"include": [
|
||||
"./plugins/apm/**/*",
|
||||
"./plugins/observability/**/*",
|
||||
"./typings/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
/*
|
||||
* 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 pRetry from 'p-retry';
|
||||
import { IClusterClient, Logger } from 'src/core/server';
|
||||
import { CallCluster } from 'src/legacy/core_plugins/elasticsearch';
|
||||
|
||||
export type Mappings =
|
||||
| {
|
||||
dynamic?: boolean | 'strict';
|
||||
properties: Record<string, Mappings>;
|
||||
dynamic_templates?: any[];
|
||||
}
|
||||
| {
|
||||
type: string;
|
||||
ignore_above?: number;
|
||||
scaling_factor?: number;
|
||||
ignore_malformed?: boolean;
|
||||
coerce?: boolean;
|
||||
fields?: Record<string, Mappings>;
|
||||
};
|
||||
|
||||
export async function createOrUpdateIndex({
|
||||
index,
|
||||
mappings,
|
||||
esClient,
|
||||
logger
|
||||
}: {
|
||||
index: string;
|
||||
mappings: Mappings;
|
||||
esClient: IClusterClient;
|
||||
logger: Logger;
|
||||
}) {
|
||||
try {
|
||||
/*
|
||||
* In some cases we could be trying to create an index before ES is ready.
|
||||
* When this happens, we retry creating the index with exponential backoff.
|
||||
* We use retry's default formula, meaning that the first retry happens after 2s,
|
||||
* the 5th after 32s, and the final attempt after around 17m. If the final attempt fails,
|
||||
* the error is logged to the console.
|
||||
* See https://github.com/sindresorhus/p-retry and https://github.com/tim-kos/node-retry.
|
||||
*/
|
||||
await pRetry(async () => {
|
||||
const { callAsInternalUser } = esClient;
|
||||
const indexExists = await callAsInternalUser('indices.exists', { index });
|
||||
const result = indexExists
|
||||
? await updateExistingIndex({
|
||||
index,
|
||||
callAsInternalUser,
|
||||
mappings
|
||||
})
|
||||
: await createNewIndex({
|
||||
index,
|
||||
callAsInternalUser,
|
||||
mappings
|
||||
});
|
||||
|
||||
if (!result.acknowledged) {
|
||||
const resultError =
|
||||
result && result.error && JSON.stringify(result.error);
|
||||
throw new Error(resultError);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Could not create APM index: '${index}'. Error: ${e.message}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createNewIndex({
|
||||
index,
|
||||
callAsInternalUser,
|
||||
mappings
|
||||
}: {
|
||||
index: string;
|
||||
callAsInternalUser: CallCluster;
|
||||
mappings: Mappings;
|
||||
}) {
|
||||
return callAsInternalUser('indices.create', {
|
||||
index,
|
||||
body: {
|
||||
// auto_expand_replicas: Allows cluster to not have replicas for this index
|
||||
settings: { 'index.auto_expand_replicas': '0-1' },
|
||||
mappings
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateExistingIndex({
|
||||
index,
|
||||
callAsInternalUser,
|
||||
mappings
|
||||
}: {
|
||||
index: string;
|
||||
callAsInternalUser: CallCluster;
|
||||
mappings: Mappings;
|
||||
}) {
|
||||
return callAsInternalUser('indices.putMapping', {
|
||||
index,
|
||||
body: mappings
|
||||
});
|
||||
}
|
|
@ -7,10 +7,10 @@
|
|||
/* eslint-disable no-console */
|
||||
import {
|
||||
IndexDocumentParams,
|
||||
IndicesDeleteParams,
|
||||
SearchParams,
|
||||
IndicesCreateParams,
|
||||
DeleteDocumentResponse
|
||||
DeleteDocumentResponse,
|
||||
DeleteDocumentParams
|
||||
} from 'elasticsearch';
|
||||
import { cloneDeep, isString, merge } from 'lodash';
|
||||
import { KibanaRequest } from 'src/core/server';
|
||||
|
@ -204,7 +204,9 @@ export function getESClient(
|
|||
index: <Body>(params: APMIndexDocumentParams<Body>) => {
|
||||
return callEs('index', params);
|
||||
},
|
||||
delete: (params: IndicesDeleteParams): Promise<DeleteDocumentResponse> => {
|
||||
delete: (
|
||||
params: Omit<DeleteDocumentParams, 'type'>
|
||||
): Promise<DeleteDocumentResponse> => {
|
||||
return callEs('delete', params);
|
||||
},
|
||||
indicesCreate: (params: IndicesCreateParams) => {
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 { isNumber } from 'lodash';
|
||||
import { Annotation, AnnotationType } from '../../../../common/annotations';
|
||||
import { SetupTimeRange, Setup } from '../../helpers/setup_request';
|
||||
import { ESFilter } from '../../../../typings/elasticsearch';
|
||||
import { rangeFilter } from '../../helpers/range_filter';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_NAME,
|
||||
SERVICE_VERSION
|
||||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es';
|
||||
|
||||
export async function getDerivedServiceAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment
|
||||
}: {
|
||||
serviceName: string;
|
||||
environment?: string;
|
||||
setup: Setup & SetupTimeRange;
|
||||
}) {
|
||||
const { start, end, client, indices } = setup;
|
||||
|
||||
const filter: ESFilter[] = [
|
||||
{ term: { [PROCESSOR_EVENT]: 'transaction' } },
|
||||
{ term: { [SERVICE_NAME]: serviceName } }
|
||||
];
|
||||
|
||||
const environmentFilter = getEnvironmentUiFilterES(environment);
|
||||
|
||||
if (environmentFilter) {
|
||||
filter.push(environmentFilter);
|
||||
}
|
||||
|
||||
const versions =
|
||||
(
|
||||
await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat({ range: rangeFilter(start, end) })
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
versions: {
|
||||
terms: {
|
||||
field: SERVICE_VERSION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).aggregations?.versions.buckets.map(bucket => bucket.key) ?? [];
|
||||
|
||||
if (versions.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
const annotations = await Promise.all(
|
||||
versions.map(async version => {
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat({
|
||||
term: {
|
||||
[SERVICE_VERSION]: version
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
first_seen: {
|
||||
min: {
|
||||
field: '@timestamp'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const firstSeen = response.aggregations?.first_seen.value;
|
||||
|
||||
if (!isNumber(firstSeen)) {
|
||||
throw new Error(
|
||||
'First seen for version was unexpectedly undefined or null.'
|
||||
);
|
||||
}
|
||||
|
||||
if (firstSeen < start || firstSeen > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: AnnotationType.VERSION,
|
||||
id: version,
|
||||
'@timestamp': firstSeen,
|
||||
text: version
|
||||
};
|
||||
})
|
||||
);
|
||||
return annotations.filter(Boolean) as Annotation[];
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { APICaller } from 'kibana/server';
|
||||
import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames';
|
||||
import { ESSearchResponse } from '../../../../typings/elasticsearch';
|
||||
import { ScopedAnnotationsClient } from '../../../../../observability/server';
|
||||
import { Annotation, AnnotationType } from '../../../../common/annotations';
|
||||
import { Annotation as ESAnnotation } from '../../../../../observability/common/annotations';
|
||||
import { SetupTimeRange, Setup } from '../../helpers/setup_request';
|
||||
import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es';
|
||||
|
||||
export async function getStoredAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment,
|
||||
apiCaller,
|
||||
annotationsClient
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange;
|
||||
serviceName: string;
|
||||
environment?: string;
|
||||
apiCaller: APICaller;
|
||||
annotationsClient: ScopedAnnotationsClient;
|
||||
}): Promise<Annotation[]> {
|
||||
try {
|
||||
const environmentFilter = getEnvironmentUiFilterES(environment);
|
||||
|
||||
const response: ESSearchResponse<ESAnnotation, any> = (await apiCaller(
|
||||
'search',
|
||||
{
|
||||
index: annotationsClient.index,
|
||||
body: {
|
||||
size: 50,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: setup.start,
|
||||
lt: setup.end
|
||||
}
|
||||
}
|
||||
},
|
||||
{ term: { 'annotation.type': 'deployment' } },
|
||||
{ term: { tags: 'apm' } },
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
...(environmentFilter ? [environmentFilter] : [])
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)) as any;
|
||||
|
||||
return response.hits.hits.map(hit => {
|
||||
return {
|
||||
type: AnnotationType.VERSION,
|
||||
id: hit._id,
|
||||
'@timestamp': new Date(hit._source['@timestamp']).getTime(),
|
||||
text: hit._source.message
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// index is only created when an annotation has been indexed,
|
||||
// so we should handle this error gracefully
|
||||
if (error.body?.error?.type === 'index_not_found_exception') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { getServiceAnnotations } from '.';
|
||||
import { getDerivedServiceAnnotations } from './get_derived_service_annotations';
|
||||
import {
|
||||
SearchParamsMock,
|
||||
inspectSearchParams
|
||||
|
@ -24,7 +24,7 @@ describe('getServiceAnnotations', () => {
|
|||
it('returns no annotations', async () => {
|
||||
mock = await inspectSearchParams(
|
||||
setup =>
|
||||
getServiceAnnotations({
|
||||
getDerivedServiceAnnotations({
|
||||
setup,
|
||||
serviceName: 'foo',
|
||||
environment: 'bar'
|
||||
|
@ -34,7 +34,7 @@ describe('getServiceAnnotations', () => {
|
|||
}
|
||||
);
|
||||
|
||||
expect(mock.response).toEqual({ annotations: [] });
|
||||
expect(mock.response).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -42,7 +42,7 @@ describe('getServiceAnnotations', () => {
|
|||
it('returns no annotations', async () => {
|
||||
mock = await inspectSearchParams(
|
||||
setup =>
|
||||
getServiceAnnotations({
|
||||
getDerivedServiceAnnotations({
|
||||
setup,
|
||||
serviceName: 'foo',
|
||||
environment: 'bar'
|
||||
|
@ -52,7 +52,7 @@ describe('getServiceAnnotations', () => {
|
|||
}
|
||||
);
|
||||
|
||||
expect(mock.response).toEqual({ annotations: [] });
|
||||
expect(mock.response).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -65,7 +65,7 @@ describe('getServiceAnnotations', () => {
|
|||
];
|
||||
mock = await inspectSearchParams(
|
||||
setup =>
|
||||
getServiceAnnotations({
|
||||
getDerivedServiceAnnotations({
|
||||
setup,
|
||||
serviceName: 'foo',
|
||||
environment: 'bar'
|
||||
|
@ -77,22 +77,20 @@ describe('getServiceAnnotations', () => {
|
|||
|
||||
expect(mock.spy.mock.calls.length).toBe(3);
|
||||
|
||||
expect(mock.response).toEqual({
|
||||
annotations: [
|
||||
{
|
||||
id: '8.0.0',
|
||||
text: '8.0.0',
|
||||
time: 1.5281138e12,
|
||||
type: 'version'
|
||||
},
|
||||
{
|
||||
id: '7.5.0',
|
||||
text: '7.5.0',
|
||||
time: 1.5281138e12,
|
||||
type: 'version'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mock.response).toEqual([
|
||||
{
|
||||
id: '8.0.0',
|
||||
text: '8.0.0',
|
||||
'@timestamp': 1.5281138e12,
|
||||
type: 'version'
|
||||
},
|
||||
{
|
||||
id: '7.5.0',
|
||||
text: '7.5.0',
|
||||
'@timestamp': 1.5281138e12,
|
||||
type: 'version'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,112 +3,51 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { isNumber } from 'lodash';
|
||||
import { Annotation, AnnotationType } from '../../../../common/annotations';
|
||||
import { ESFilter } from '../../../../typings/elasticsearch';
|
||||
import {
|
||||
SERVICE_NAME,
|
||||
SERVICE_ENVIRONMENT,
|
||||
PROCESSOR_EVENT
|
||||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { APICaller } from 'kibana/server';
|
||||
import { ScopedAnnotationsClient } from '../../../../../observability/server';
|
||||
import { getDerivedServiceAnnotations } from './get_derived_service_annotations';
|
||||
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
|
||||
import { rangeFilter } from '../../helpers/range_filter';
|
||||
import { SERVICE_VERSION } from '../../../../common/elasticsearch_fieldnames';
|
||||
import { getStoredAnnotations } from './get_stored_annotations';
|
||||
|
||||
export async function getServiceAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment
|
||||
environment,
|
||||
annotationsClient,
|
||||
apiCaller
|
||||
}: {
|
||||
serviceName: string;
|
||||
environment?: string;
|
||||
setup: Setup & SetupTimeRange;
|
||||
annotationsClient?: ScopedAnnotationsClient;
|
||||
apiCaller: APICaller;
|
||||
}) {
|
||||
const { start, end, client, indices } = setup;
|
||||
// start fetching derived annotations (based on transactions), but don't wait on it
|
||||
// it will likely be significantly slower than the stored annotations
|
||||
const derivedAnnotationsPromise = getDerivedServiceAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment
|
||||
});
|
||||
|
||||
const filter: ESFilter[] = [
|
||||
{ term: { [PROCESSOR_EVENT]: 'transaction' } },
|
||||
{ range: rangeFilter(start, end) },
|
||||
{ term: { [SERVICE_NAME]: serviceName } }
|
||||
];
|
||||
const storedAnnotations = annotationsClient
|
||||
? await getStoredAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment,
|
||||
annotationsClient,
|
||||
apiCaller
|
||||
})
|
||||
: [];
|
||||
|
||||
if (environment) {
|
||||
filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } });
|
||||
if (storedAnnotations.length) {
|
||||
derivedAnnotationsPromise.catch(error => {
|
||||
// handle error silently to prevent Kibana from crashing
|
||||
});
|
||||
return { annotations: storedAnnotations };
|
||||
}
|
||||
|
||||
const versions =
|
||||
(
|
||||
await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
track_total_hits: false,
|
||||
query: {
|
||||
bool: {
|
||||
filter
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
versions: {
|
||||
terms: {
|
||||
field: SERVICE_VERSION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).aggregations?.versions.buckets.map(bucket => bucket.key) ?? [];
|
||||
|
||||
if (versions.length > 1) {
|
||||
const annotations = await Promise.all(
|
||||
versions.map(async version => {
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter
|
||||
.filter(esFilter => !Object.keys(esFilter).includes('range'))
|
||||
.concat({
|
||||
term: {
|
||||
[SERVICE_VERSION]: version
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
first_seen: {
|
||||
min: {
|
||||
field: '@timestamp'
|
||||
}
|
||||
}
|
||||
},
|
||||
track_total_hits: false
|
||||
}
|
||||
});
|
||||
|
||||
const firstSeen = response.aggregations?.first_seen.value;
|
||||
|
||||
if (!isNumber(firstSeen)) {
|
||||
throw new Error(
|
||||
'First seen for version was unexpectedly undefined or null.'
|
||||
);
|
||||
}
|
||||
|
||||
if (firstSeen < start || firstSeen > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: AnnotationType.VERSION,
|
||||
id: version,
|
||||
time: firstSeen,
|
||||
text: version
|
||||
};
|
||||
})
|
||||
);
|
||||
return { annotations: annotations.filter(Boolean) as Annotation[] };
|
||||
}
|
||||
return { annotations: [] };
|
||||
return {
|
||||
annotations: await derivedAnnotationsPromise
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
|
||||
import { IClusterClient, Logger } from 'src/core/server';
|
||||
import { APMConfig } from '../../..';
|
||||
import {
|
||||
createOrUpdateIndex,
|
||||
Mappings
|
||||
} from '../../helpers/create_or_update_index';
|
||||
MappingsDefinition
|
||||
} from '../../../../../observability/server';
|
||||
import { APMConfig } from '../../..';
|
||||
import { getApmIndicesConfig } from '../apm_indices/get_apm_indices';
|
||||
|
||||
export async function createApmAgentConfigurationIndex({
|
||||
|
@ -22,10 +22,15 @@ export async function createApmAgentConfigurationIndex({
|
|||
logger: Logger;
|
||||
}) {
|
||||
const index = getApmIndicesConfig(config).apmAgentConfigurationIndex;
|
||||
return createOrUpdateIndex({ index, esClient, logger, mappings });
|
||||
return createOrUpdateIndex({
|
||||
index,
|
||||
apiCaller: esClient.callAsInternalUser,
|
||||
logger,
|
||||
mappings
|
||||
});
|
||||
}
|
||||
|
||||
const mappings: Mappings = {
|
||||
const mappings: MappingsDefinition = {
|
||||
dynamic: 'strict',
|
||||
dynamic_templates: [
|
||||
{
|
||||
|
|
|
@ -16,7 +16,7 @@ export async function deleteConfiguration({
|
|||
const { internalClient, indices } = setup;
|
||||
|
||||
const params = {
|
||||
refresh: 'wait_for',
|
||||
refresh: 'wait_for' as const,
|
||||
index: indices.apmAgentConfigurationIndex,
|
||||
id: configurationId
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { getAllEnvironments } from './get_all_environments';
|
||||
import { Setup } from '../../../helpers/setup_request';
|
||||
import { PromiseReturnType } from '../../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { getExistingEnvironmentsForService } from './get_existing_environments_for_service';
|
||||
|
||||
export type AgentConfigurationEnvironmentsAPIResponse = PromiseReturnType<
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { Setup } from '../../helpers/setup_request';
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_NAME
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import { Setup } from '../../helpers/setup_request';
|
||||
import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types';
|
||||
import { convertConfigSettingsToString } from './convert_settings_to_string';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { merge } from 'lodash';
|
||||
import { Server } from 'hapi';
|
||||
import { SavedObjectsClient } from 'src/core/server';
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import {
|
||||
APM_INDICES_SAVED_OBJECT_TYPE,
|
||||
APM_INDICES_SAVED_OBJECT_ID
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
|
||||
import { IClusterClient, Logger } from 'src/core/server';
|
||||
import { APMConfig } from '../../..';
|
||||
import {
|
||||
createOrUpdateIndex,
|
||||
Mappings
|
||||
} from '../../helpers/create_or_update_index';
|
||||
MappingsDefinition
|
||||
} from '../../../../../observability/server';
|
||||
import { APMConfig } from '../../..';
|
||||
import { getApmIndicesConfig } from '../apm_indices/get_apm_indices';
|
||||
|
||||
export const createApmCustomLinkIndex = async ({
|
||||
|
@ -22,10 +22,15 @@ export const createApmCustomLinkIndex = async ({
|
|||
logger: Logger;
|
||||
}) => {
|
||||
const index = getApmIndicesConfig(config).apmCustomLinkIndex;
|
||||
return createOrUpdateIndex({ index, esClient, logger, mappings });
|
||||
return createOrUpdateIndex({
|
||||
index,
|
||||
apiCaller: esClient.callAsInternalUser,
|
||||
logger,
|
||||
mappings
|
||||
});
|
||||
};
|
||||
|
||||
const mappings: Mappings = {
|
||||
const mappings: MappingsDefinition = {
|
||||
dynamic: 'strict',
|
||||
properties: {
|
||||
'@timestamp': {
|
||||
|
|
|
@ -16,7 +16,7 @@ export async function deleteCustomLink({
|
|||
const { internalClient, indices } = setup;
|
||||
|
||||
const params = {
|
||||
refresh: 'wait_for',
|
||||
refresh: 'wait_for' as const,
|
||||
index: indices.apmCustomLinkIndex,
|
||||
id: customLinkId
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../observability/typings/common';
|
||||
import { Setup, SetupTimeRange } from '../helpers/setup_request';
|
||||
import { getTraceItems } from './get_trace_items';
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '../../../common/elasticsearch_fieldnames';
|
||||
import { getTransactionGroupsProjection } from '../../../common/projections/transaction_groups';
|
||||
import { mergeProjection } from '../../../common/projections/util/merge_projection';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../observability/typings/common';
|
||||
import { SortOptions } from '../../../typings/elasticsearch/aggregations';
|
||||
import { Transaction } from '../../../typings/es_schemas/ui/transaction';
|
||||
import {
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '../helpers/setup_request';
|
||||
import { transactionGroupsFetcher, Options } from './fetcher';
|
||||
import { transactionGroupsTransformer } from './transform';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../observability/typings/common';
|
||||
|
||||
export type TransactionGroupListAPIResponse = PromiseReturnType<
|
||||
typeof getTransactionGroupList
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { ESFilter } from '../../../../typings/elasticsearch';
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_NAME,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { getMlIndex } from '../../../../../common/ml_job_constants';
|
||||
import { PromiseReturnType } from '../../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { Setup, SetupTimeRange } from '../../../helpers/setup_request';
|
||||
|
||||
export type ESResponse = Exclude<
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { getAnomalySeries } from '.';
|
||||
import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response';
|
||||
import { mlBucketSpanResponse } from './mock_responses/ml_bucket_span_response';
|
||||
import { PromiseReturnType } from '../../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { APMConfig } from '../../../..';
|
||||
|
||||
describe('getAnomalySeries', () => {
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
TRANSACTION_RESULT,
|
||||
TRANSACTION_TYPE
|
||||
} from '../../../../../common/elasticsearch_fieldnames';
|
||||
import { PromiseReturnType } from '../../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { getBucketSize } from '../../../helpers/get_bucket_size';
|
||||
import { rangeFilter } from '../../../helpers/range_filter';
|
||||
import {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import {
|
||||
Setup,
|
||||
SetupTimeRange,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PromiseReturnType } from '../../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { Transaction } from '../../../../../typings/es_schemas/ui/transaction';
|
||||
import { bucketFetcher } from './fetcher';
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import {
|
||||
Setup,
|
||||
SetupTimeRange,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { cloneDeep, sortByOrder } from 'lodash';
|
||||
import { UIFilters } from '../../../../typings/ui_filters';
|
||||
import { Projection } from '../../../../common/projections/typings';
|
||||
import { PromiseReturnType } from '../../../../typings/common';
|
||||
import { PromiseReturnType } from '../../../../../observability/typings/common';
|
||||
import { getLocalFilterQuery } from './get_local_filter_query';
|
||||
import { Setup } from '../../helpers/setup_request';
|
||||
import { localUIFilters, LocalUIFilterName } from './config';
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from 'src/core/server';
|
||||
import { Observable, combineLatest } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { ObservabilityPluginSetup } from '../../observability/server';
|
||||
import { SecurityPluginSetup } from '../../security/public';
|
||||
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server';
|
||||
import { TaskManagerSetupContract } from '../../task_manager/server';
|
||||
|
@ -57,6 +58,7 @@ export class APMPlugin implements Plugin<APMPluginSetup> {
|
|||
taskManager?: TaskManagerSetupContract;
|
||||
alerting?: AlertingPlugin['setup'];
|
||||
actions?: ActionsPlugin['setup'];
|
||||
observability?: ObservabilityPluginSetup;
|
||||
features: FeaturesPluginSetup;
|
||||
security?: SecurityPluginSetup;
|
||||
}
|
||||
|
@ -114,6 +116,7 @@ export class APMPlugin implements Plugin<APMPluginSetup> {
|
|||
config$: mergedConfig$,
|
||||
logger: this.logger!,
|
||||
plugins: {
|
||||
observability: plugins.observability,
|
||||
security: plugins.security
|
||||
}
|
||||
});
|
||||
|
|
|
@ -136,13 +136,13 @@ export function createApi() {
|
|||
request,
|
||||
context: {
|
||||
...context,
|
||||
plugins,
|
||||
// Only return values for parameters that have runtime types,
|
||||
// but always include query as _debug is always set even if
|
||||
// it's not defined in the route.
|
||||
params: pick(parsedParams, ...Object.keys(params), 'query'),
|
||||
config,
|
||||
logger,
|
||||
plugins
|
||||
logger
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -19,7 +19,8 @@ import {
|
|||
serviceTransactionTypesRoute,
|
||||
servicesRoute,
|
||||
serviceNodeMetadataRoute,
|
||||
serviceAnnotationsRoute
|
||||
serviceAnnotationsRoute,
|
||||
serviceAnnotationsCreateRoute
|
||||
} from './services';
|
||||
import {
|
||||
agentConfigurationRoute,
|
||||
|
@ -87,6 +88,7 @@ const createApmApi = () => {
|
|||
.add(servicesRoute)
|
||||
.add(serviceNodeMetadataRoute)
|
||||
.add(serviceAnnotationsRoute)
|
||||
.add(serviceAnnotationsCreateRoute)
|
||||
|
||||
// Agent configuration
|
||||
.add(getSingleAgentConfigurationRoute)
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
import Boom from 'boom';
|
||||
import { unique } from 'lodash';
|
||||
import { ScopedAnnotationsClient } from '../../../observability/server';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { getServiceAgentName } from '../lib/services/get_service_agent_name';
|
||||
import { getServices } from '../lib/services/get_services';
|
||||
|
@ -13,6 +16,7 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat
|
|||
import { createRoute } from './create_route';
|
||||
import { uiFiltersRt, rangeRt } from './default_api_types';
|
||||
import { getServiceAnnotations } from '../lib/services/annotations';
|
||||
import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt';
|
||||
|
||||
export const servicesRoute = createRoute(core => ({
|
||||
path: '/api/apm/services',
|
||||
|
@ -74,7 +78,7 @@ export const serviceNodeMetadataRoute = createRoute(() => ({
|
|||
}));
|
||||
|
||||
export const serviceAnnotationsRoute = createRoute(() => ({
|
||||
path: '/api/apm/services/{serviceName}/annotations',
|
||||
path: '/api/apm/services/{serviceName}/annotation/search',
|
||||
params: {
|
||||
path: t.type({
|
||||
serviceName: t.string
|
||||
|
@ -91,10 +95,74 @@ export const serviceAnnotationsRoute = createRoute(() => ({
|
|||
const { serviceName } = context.params.path;
|
||||
const { environment } = context.params.query;
|
||||
|
||||
let annotationsClient: ScopedAnnotationsClient | undefined;
|
||||
|
||||
if (context.plugins.observability) {
|
||||
annotationsClient = await context.plugins.observability.getScopedAnnotationsClient(
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
return getServiceAnnotations({
|
||||
setup,
|
||||
serviceName,
|
||||
environment
|
||||
environment,
|
||||
annotationsClient,
|
||||
apiCaller: context.core.elasticsearch.dataClient.callAsCurrentUser
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
export const serviceAnnotationsCreateRoute = createRoute(() => ({
|
||||
path: '/api/apm/services/{serviceName}/annotation',
|
||||
method: 'POST',
|
||||
options: {
|
||||
tags: ['access:apm', 'access:apm_write']
|
||||
},
|
||||
params: {
|
||||
path: t.type({
|
||||
serviceName: t.string
|
||||
}),
|
||||
body: t.intersection([
|
||||
t.type({
|
||||
'@timestamp': dateAsStringRt,
|
||||
service: t.intersection([
|
||||
t.type({
|
||||
version: t.string
|
||||
}),
|
||||
t.partial({
|
||||
environment: t.string
|
||||
})
|
||||
])
|
||||
}),
|
||||
t.partial({
|
||||
message: t.string,
|
||||
tags: t.array(t.string)
|
||||
})
|
||||
])
|
||||
},
|
||||
handler: async ({ request, context }) => {
|
||||
const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient(
|
||||
request
|
||||
);
|
||||
|
||||
if (!annotationsClient) {
|
||||
throw Boom.notFound();
|
||||
}
|
||||
|
||||
const { body, path } = context.params;
|
||||
|
||||
return annotationsClient.create({
|
||||
message: body.service.version,
|
||||
...body,
|
||||
annotation: {
|
||||
type: 'deployment'
|
||||
},
|
||||
service: {
|
||||
...body.service,
|
||||
name: path.serviceName
|
||||
},
|
||||
tags: unique(['apm'].concat(body.tags ?? []))
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { PickByValue, Optional } from 'utility-types';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Server } from 'hapi';
|
||||
import { ObservabilityPluginSetup } from '../../../observability/server';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { FetchOptions } from '../../public/services/rest/callApi';
|
||||
import { SecurityPluginSetup } from '../../../security/public';
|
||||
|
@ -64,6 +65,7 @@ export type APMRequestHandlerContext<
|
|||
config: APMConfig;
|
||||
logger: Logger;
|
||||
plugins: {
|
||||
observability?: ObservabilityPluginSetup;
|
||||
security?: SecurityPluginSetup;
|
||||
};
|
||||
};
|
||||
|
@ -110,6 +112,7 @@ export interface ServerAPI<TRouteState extends RouteState> {
|
|||
config$: Observable<APMConfig>;
|
||||
logger: Logger;
|
||||
plugins: {
|
||||
observability?: ObservabilityPluginSetup;
|
||||
security?: SecurityPluginSetup;
|
||||
};
|
||||
}
|
||||
|
|
51
x-pack/plugins/observability/common/annotations.ts
Normal file
51
x-pack/plugins/observability/common/annotations.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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 { dateAsStringRt } from '../../apm/common/runtime_types/date_as_string_rt';
|
||||
|
||||
export const createAnnotationRt = t.intersection([
|
||||
t.type({
|
||||
annotation: t.type({
|
||||
type: t.string,
|
||||
}),
|
||||
'@timestamp': dateAsStringRt,
|
||||
message: t.string,
|
||||
}),
|
||||
t.partial({
|
||||
tags: t.array(t.string),
|
||||
service: t.partial({
|
||||
name: t.string,
|
||||
environment: t.string,
|
||||
version: t.string,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const deleteAnnotationRt = t.type({
|
||||
id: t.string,
|
||||
});
|
||||
|
||||
export const getAnnotationByIdRt = t.type({
|
||||
id: t.string,
|
||||
});
|
||||
|
||||
export interface Annotation {
|
||||
annotation: {
|
||||
type: string;
|
||||
};
|
||||
tags?: string[];
|
||||
message: string;
|
||||
service?: {
|
||||
name?: string;
|
||||
environment?: string;
|
||||
version?: string;
|
||||
};
|
||||
event: {
|
||||
created: string;
|
||||
};
|
||||
'@timestamp': string;
|
||||
}
|
|
@ -2,6 +2,10 @@
|
|||
"id": "observability",
|
||||
"version": "8.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "observability"],
|
||||
"ui": true
|
||||
"configPath": [
|
||||
"xpack",
|
||||
"observability"
|
||||
],
|
||||
"ui": true,
|
||||
"server": true
|
||||
}
|
||||
|
|
33
x-pack/plugins/observability/server/index.ts
Normal file
33
x-pack/plugins/observability/server/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { PluginInitializerContext } from 'src/core/server';
|
||||
import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin';
|
||||
import { createOrUpdateIndex, MappingsDefinition } from './utils/create_or_update_index';
|
||||
import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations';
|
||||
|
||||
export const config = {
|
||||
schema: schema.object({
|
||||
enabled: schema.boolean({ defaultValue: true }),
|
||||
annotations: schema.object({
|
||||
enabled: schema.boolean({ defaultValue: true }),
|
||||
index: schema.string({ defaultValue: 'observability-annotations' }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export type ObservabilityConfig = TypeOf<typeof config.schema>;
|
||||
|
||||
export const plugin = (initContext: PluginInitializerContext) =>
|
||||
new ObservabilityPlugin(initContext);
|
||||
|
||||
export {
|
||||
createOrUpdateIndex,
|
||||
MappingsDefinition,
|
||||
ObservabilityPluginSetup,
|
||||
ScopedAnnotationsClient,
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { CoreSetup, PluginInitializerContext, KibanaRequest } from 'kibana/server';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { createAnnotationsClient } from './create_annotations_client';
|
||||
import { registerAnnotationAPIs } from './register_annotation_apis';
|
||||
|
||||
interface Params {
|
||||
index: string;
|
||||
core: CoreSetup;
|
||||
context: PluginInitializerContext;
|
||||
}
|
||||
|
||||
export type ScopedAnnotationsClientFactory = PromiseReturnType<
|
||||
typeof bootstrapAnnotations
|
||||
>['getScopedAnnotationsClient'];
|
||||
|
||||
export type ScopedAnnotationsClient = ReturnType<ScopedAnnotationsClientFactory>;
|
||||
export type AnnotationsAPI = PromiseReturnType<typeof bootstrapAnnotations>;
|
||||
|
||||
export async function bootstrapAnnotations({ index, core, context }: Params) {
|
||||
const logger = context.logger.get('annotations');
|
||||
|
||||
registerAnnotationAPIs({
|
||||
core,
|
||||
index,
|
||||
logger,
|
||||
});
|
||||
|
||||
return {
|
||||
getScopedAnnotationsClient: (request: KibanaRequest) => {
|
||||
return createAnnotationsClient({
|
||||
index,
|
||||
apiCaller: core.elasticsearch.dataClient.asScoped(request).callAsCurrentUser,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { APICaller, Logger } from 'kibana/server';
|
||||
import * as t from 'io-ts';
|
||||
import { Client } from 'elasticsearch';
|
||||
import {
|
||||
createAnnotationRt,
|
||||
deleteAnnotationRt,
|
||||
Annotation,
|
||||
getAnnotationByIdRt,
|
||||
} from '../../../common/annotations';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { createOrUpdateIndex } from '../../utils/create_or_update_index';
|
||||
import { mappings } from './mappings';
|
||||
|
||||
type CreateParams = t.TypeOf<typeof createAnnotationRt>;
|
||||
type DeleteParams = t.TypeOf<typeof deleteAnnotationRt>;
|
||||
type GetByIdParams = t.TypeOf<typeof getAnnotationByIdRt>;
|
||||
|
||||
interface IndexDocumentResponse {
|
||||
_shards: {
|
||||
total: number;
|
||||
failed: number;
|
||||
successful: number;
|
||||
};
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_version: number;
|
||||
_seq_no: number;
|
||||
_primary_term: number;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export function createAnnotationsClient(params: {
|
||||
index: string;
|
||||
apiCaller: APICaller;
|
||||
logger: Logger;
|
||||
}) {
|
||||
const { index, apiCaller, logger } = params;
|
||||
|
||||
const initIndex = () =>
|
||||
createOrUpdateIndex({
|
||||
index,
|
||||
mappings,
|
||||
apiCaller,
|
||||
logger,
|
||||
});
|
||||
|
||||
return {
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
create: async (
|
||||
createParams: CreateParams
|
||||
): Promise<{ _id: string; _index: string; _source: Annotation }> => {
|
||||
const indexExists = await apiCaller('indices.exists', {
|
||||
index,
|
||||
});
|
||||
|
||||
if (!indexExists) {
|
||||
await initIndex();
|
||||
}
|
||||
|
||||
const annotation = {
|
||||
...createParams,
|
||||
event: {
|
||||
created: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const response = (await apiCaller('index', {
|
||||
index,
|
||||
body: annotation,
|
||||
refresh: 'wait_for',
|
||||
})) as IndexDocumentResponse;
|
||||
|
||||
return apiCaller('get', {
|
||||
index,
|
||||
id: response._id,
|
||||
});
|
||||
},
|
||||
getById: async (getByIdParams: GetByIdParams) => {
|
||||
const { id } = getByIdParams;
|
||||
|
||||
return apiCaller('get', {
|
||||
id,
|
||||
index,
|
||||
});
|
||||
},
|
||||
delete: async (deleteParams: DeleteParams) => {
|
||||
const { id } = deleteParams;
|
||||
|
||||
const response = (await apiCaller('delete', {
|
||||
index,
|
||||
id,
|
||||
refresh: 'wait_for',
|
||||
})) as PromiseReturnType<Client['delete']>;
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export const mappings = {
|
||||
dynamic: 'strict',
|
||||
properties: {
|
||||
annotation: {
|
||||
properties: {
|
||||
type: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
message: {
|
||||
type: 'text',
|
||||
},
|
||||
tags: {
|
||||
type: 'keyword',
|
||||
},
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
event: {
|
||||
properties: {
|
||||
created: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'keyword',
|
||||
},
|
||||
environment: {
|
||||
type: 'keyword',
|
||||
},
|
||||
version: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { CoreSetup, RequestHandler, Logger } from 'kibana/server';
|
||||
import { PathReporter } from 'io-ts/lib/PathReporter';
|
||||
import { isLeft } from 'fp-ts/lib/Either';
|
||||
import {
|
||||
getAnnotationByIdRt,
|
||||
createAnnotationRt,
|
||||
deleteAnnotationRt,
|
||||
} from '../../../common/annotations';
|
||||
import { ScopedAnnotationsClient } from './bootstrap_annotations';
|
||||
import { createAnnotationsClient } from './create_annotations_client';
|
||||
|
||||
const unknowns = schema.object({}, { unknowns: 'allow' });
|
||||
|
||||
export function registerAnnotationAPIs({
|
||||
core,
|
||||
index,
|
||||
logger,
|
||||
}: {
|
||||
core: CoreSetup;
|
||||
index: string;
|
||||
logger: Logger;
|
||||
}) {
|
||||
function wrapRouteHandler<TType extends t.Type<any>>(
|
||||
types: TType,
|
||||
handler: (params: { data: t.TypeOf<TType>; client: ScopedAnnotationsClient }) => Promise<any>
|
||||
): RequestHandler {
|
||||
return async (...args: Parameters<RequestHandler>) => {
|
||||
const [, request, response] = args;
|
||||
|
||||
const rt = types;
|
||||
|
||||
const data = {
|
||||
body: request.body,
|
||||
query: request.query,
|
||||
params: request.params,
|
||||
};
|
||||
|
||||
const validation = rt.decode(data);
|
||||
|
||||
if (isLeft(validation)) {
|
||||
return response.badRequest({
|
||||
body: PathReporter.report(validation).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const apiCaller = core.elasticsearch.dataClient.asScoped(request).callAsCurrentUser;
|
||||
|
||||
const client = createAnnotationsClient({
|
||||
index,
|
||||
apiCaller,
|
||||
logger,
|
||||
});
|
||||
|
||||
const res = await handler({
|
||||
data: validation.right,
|
||||
client,
|
||||
});
|
||||
|
||||
return response.ok({
|
||||
body: res,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const router = core.http.createRouter();
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: '/api/observability/annotation',
|
||||
validate: {
|
||||
body: unknowns,
|
||||
},
|
||||
},
|
||||
wrapRouteHandler(t.type({ body: createAnnotationRt }), ({ data, client }) => {
|
||||
return client.create(data.body);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
{
|
||||
path: '/api/observability/annotation/{id}',
|
||||
validate: {
|
||||
params: unknowns,
|
||||
},
|
||||
},
|
||||
wrapRouteHandler(t.type({ params: deleteAnnotationRt }), ({ data, client }) => {
|
||||
return client.delete(data.params);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
{
|
||||
path: '/api/observability/annotation/{id}',
|
||||
validate: {
|
||||
params: unknowns,
|
||||
},
|
||||
},
|
||||
wrapRouteHandler(t.type({ params: getAnnotationByIdRt }), ({ data, client }) => {
|
||||
return client.getById(data.params);
|
||||
})
|
||||
);
|
||||
}
|
59
x-pack/plugins/observability/server/plugin.ts
Normal file
59
x-pack/plugins/observability/server/plugin.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ObservabilityConfig } from '.';
|
||||
import {
|
||||
bootstrapAnnotations,
|
||||
ScopedAnnotationsClient,
|
||||
ScopedAnnotationsClientFactory,
|
||||
AnnotationsAPI,
|
||||
} from './lib/annotations/bootstrap_annotations';
|
||||
|
||||
type LazyScopedAnnotationsClientFactory = (
|
||||
...args: Parameters<ScopedAnnotationsClientFactory>
|
||||
) => Promise<ScopedAnnotationsClient | undefined>;
|
||||
|
||||
export interface ObservabilityPluginSetup {
|
||||
getScopedAnnotationsClient: LazyScopedAnnotationsClientFactory;
|
||||
}
|
||||
|
||||
export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
|
||||
constructor(private readonly initContext: PluginInitializerContext) {
|
||||
this.initContext = initContext;
|
||||
}
|
||||
|
||||
public async setup(core: CoreSetup, plugins: {}): Promise<ObservabilityPluginSetup> {
|
||||
const config$ = this.initContext.config.create<ObservabilityConfig>();
|
||||
|
||||
const config = await config$.pipe(take(1)).toPromise();
|
||||
|
||||
let annotationsApiPromise: Promise<AnnotationsAPI> | undefined;
|
||||
|
||||
if (config.annotations.enabled) {
|
||||
annotationsApiPromise = bootstrapAnnotations({
|
||||
core,
|
||||
index: config.annotations.index,
|
||||
context: this.initContext,
|
||||
}).catch(err => {
|
||||
const logger = this.initContext.logger.get('annotations');
|
||||
logger.warn(err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getScopedAnnotationsClient: async (...args) => {
|
||||
const api = await annotationsApiPromise;
|
||||
return api?.getScopedAnnotationsClient(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public start() {}
|
||||
|
||||
public stop() {}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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 pRetry from 'p-retry';
|
||||
import { Logger, APICaller } from 'src/core/server';
|
||||
|
||||
export interface MappingsObject {
|
||||
type: string;
|
||||
ignore_above?: number;
|
||||
scaling_factor?: number;
|
||||
ignore_malformed?: boolean;
|
||||
coerce?: boolean;
|
||||
fields?: Record<string, MappingsObject>;
|
||||
}
|
||||
|
||||
export interface MappingsDefinition {
|
||||
dynamic?: boolean | 'strict';
|
||||
properties: Record<string, MappingsDefinition | MappingsObject>;
|
||||
dynamic_templates?: any[];
|
||||
}
|
||||
|
||||
export async function createOrUpdateIndex({
|
||||
index,
|
||||
mappings,
|
||||
apiCaller,
|
||||
logger,
|
||||
}: {
|
||||
index: string;
|
||||
mappings: MappingsDefinition;
|
||||
apiCaller: APICaller;
|
||||
logger: Logger;
|
||||
}) {
|
||||
try {
|
||||
/*
|
||||
* In some cases we could be trying to create an index before ES is ready.
|
||||
* When this happens, we retry creating the index with exponential backoff.
|
||||
* We use retry's default formula, meaning that the first retry happens after 2s,
|
||||
* the 5th after 32s, and the final attempt after around 17m. If the final attempt fails,
|
||||
* the error is logged to the console.
|
||||
* See https://github.com/sindresorhus/p-retry and https://github.com/tim-kos/node-retry.
|
||||
*/
|
||||
await pRetry(
|
||||
async () => {
|
||||
const indexExists = await apiCaller('indices.exists', { index });
|
||||
const result = indexExists
|
||||
? await updateExistingIndex({
|
||||
index,
|
||||
apiCaller,
|
||||
mappings,
|
||||
})
|
||||
: await createNewIndex({
|
||||
index,
|
||||
apiCaller,
|
||||
mappings,
|
||||
});
|
||||
|
||||
if (!result.acknowledged) {
|
||||
const resultError = result && result.error && JSON.stringify(result.error);
|
||||
throw new Error(resultError);
|
||||
}
|
||||
},
|
||||
{
|
||||
onFailedAttempt: e => {
|
||||
logger.warn(`Could not create index: '${index}'. Retrying...`);
|
||||
logger.warn(e);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`Could not create index: '${index}'. Error: ${e.message}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function createNewIndex({
|
||||
index,
|
||||
apiCaller,
|
||||
mappings,
|
||||
}: {
|
||||
index: string;
|
||||
apiCaller: APICaller;
|
||||
mappings: MappingsDefinition;
|
||||
}) {
|
||||
return apiCaller('indices.create', {
|
||||
index,
|
||||
body: {
|
||||
// auto_expand_replicas: Allows cluster to not have replicas for this index
|
||||
settings: { 'index.auto_expand_replicas': '0-1' },
|
||||
mappings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateExistingIndex({
|
||||
index,
|
||||
apiCaller,
|
||||
mappings,
|
||||
}: {
|
||||
index: string;
|
||||
apiCaller: APICaller;
|
||||
mappings: MappingsDefinition;
|
||||
}) {
|
||||
return apiCaller('indices.putMapping', {
|
||||
index,
|
||||
body: mappings,
|
||||
});
|
||||
}
|
9
x-pack/plugins/observability/typings/common.ts
Normal file
9
x-pack/plugins/observability/typings/common.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export type PromiseReturnType<Func> = Func extends (...args: any[]) => Promise<infer Value>
|
||||
? Value
|
||||
: Func;
|
389
x-pack/test/api_integration/apis/apm/annotations.ts
Normal file
389
x-pack/test/api_integration/apis/apm/annotations.ts
Normal file
|
@ -0,0 +1,389 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { merge, cloneDeep, isPlainObject } from 'lodash';
|
||||
import { JsonObject } from 'src/plugins/kibana_utils/common';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
const DEFAULT_INDEX_NAME = 'observability-annotations';
|
||||
|
||||
export default function annotationApiTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
|
||||
function expectContainsObj(source: JsonObject, expected: JsonObject) {
|
||||
expect(source).to.eql(
|
||||
merge(cloneDeep(source), expected, (a, b) => {
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
return undefined;
|
||||
}
|
||||
return b;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) {
|
||||
switch (method.toLowerCase()) {
|
||||
case 'get':
|
||||
return supertest.get(url).set('kbn-xsrf', 'foo');
|
||||
|
||||
case 'post':
|
||||
return supertest
|
||||
.post(url)
|
||||
.send(data)
|
||||
.set('kbn-xsrf', 'foo');
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported methoed ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('APM annotations', () => {
|
||||
describe('when creating an annotation', () => {
|
||||
afterEach(async () => {
|
||||
const indexExists = (await es.indices.exists({ index: DEFAULT_INDEX_NAME })).body;
|
||||
if (indexExists) {
|
||||
await es.indices.delete({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with a 400 bad request if data is missing', async () => {
|
||||
const response = await request({
|
||||
url: '/api/apm/services/opbeans-java/annotation',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(response.status).to.be(400);
|
||||
});
|
||||
|
||||
it('fails with a 400 bad request if data is invalid', async () => {
|
||||
const invalidTimestampResponse = await request({
|
||||
url: '/api/apm/services/opbeans-java/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
'@timestamp': 'foo',
|
||||
message: 'foo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidTimestampResponse.status).to.be(400);
|
||||
|
||||
const missingServiceVersionResponse = await request({
|
||||
url: '/api/apm/services/opbeans-java/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: 'New deployment',
|
||||
},
|
||||
});
|
||||
|
||||
expect(missingServiceVersionResponse.status).to.be(400);
|
||||
});
|
||||
|
||||
it('completes with a 200 and the created annotation if data is complete and valid', async () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const response = await request({
|
||||
url: '/api/apm/services/opbeans-java/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
'@timestamp': timestamp,
|
||||
message: 'New deployment',
|
||||
tags: ['foo'],
|
||||
service: {
|
||||
version: '1.1',
|
||||
environment: 'production',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
expectContainsObj(response.body, {
|
||||
_source: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
tags: ['apm', 'foo'],
|
||||
message: 'New deployment',
|
||||
'@timestamp': timestamp,
|
||||
service: {
|
||||
name: 'opbeans-java',
|
||||
version: '1.1',
|
||||
environment: 'production',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefills `message` and `tags`', async () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const response = await request({
|
||||
url: '/api/apm/services/opbeans-java/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
'@timestamp': timestamp,
|
||||
service: {
|
||||
version: '1.1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectContainsObj(response.body, {
|
||||
_source: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
tags: ['apm'],
|
||||
message: '1.1',
|
||||
'@timestamp': timestamp,
|
||||
service: {
|
||||
name: 'opbeans-java',
|
||||
version: '1.1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when mixing stored and derived annotations', () => {
|
||||
const transactionIndexName = 'apm-8.0.0-transaction';
|
||||
const serviceName = 'opbeans-java';
|
||||
|
||||
beforeEach(async () => {
|
||||
await es.indices.create({
|
||||
index: transactionIndexName,
|
||||
body: {
|
||||
mappings: {
|
||||
properties: {
|
||||
service: {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'keyword',
|
||||
},
|
||||
version: {
|
||||
type: 'keyword',
|
||||
},
|
||||
environment: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
observer: {
|
||||
properties: {
|
||||
version_major: {
|
||||
type: 'long',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await es.index({
|
||||
index: transactionIndexName,
|
||||
body: {
|
||||
'@timestamp': new Date(2020, 4, 2, 18, 30).toISOString(),
|
||||
processor: {
|
||||
event: 'transaction',
|
||||
},
|
||||
service: {
|
||||
name: serviceName,
|
||||
version: '1.1',
|
||||
},
|
||||
observer: {
|
||||
version_major: 8,
|
||||
},
|
||||
},
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
|
||||
await es.index({
|
||||
index: transactionIndexName,
|
||||
body: {
|
||||
'@timestamp': new Date(2020, 4, 2, 19, 30).toISOString(),
|
||||
processor: {
|
||||
event: 'transaction',
|
||||
},
|
||||
service: {
|
||||
name: serviceName,
|
||||
version: '1.2',
|
||||
environment: 'production',
|
||||
},
|
||||
observer: {
|
||||
version_major: 8,
|
||||
},
|
||||
},
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await es.indices.delete({
|
||||
index: transactionIndexName,
|
||||
});
|
||||
|
||||
const annotationIndexExists = (
|
||||
await es.indices.exists({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
})
|
||||
).body;
|
||||
|
||||
if (annotationIndexExists) {
|
||||
await es.indices.delete({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the derived annotations if there are no stored annotations', async () => {
|
||||
const range = {
|
||||
start: new Date(2020, 4, 2, 18).toISOString(),
|
||||
end: new Date(2020, 4, 2, 20).toISOString(),
|
||||
};
|
||||
|
||||
const response = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${range.start}&end=${range.end}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
expect(response.body.annotations.length).to.be(2);
|
||||
expect(response.body.annotations[0].text).to.be('1.1');
|
||||
expect(response.body.annotations[1].text).to.be('1.2');
|
||||
});
|
||||
|
||||
it('returns the stored annotations only if there are any', async () => {
|
||||
const range = {
|
||||
start: new Date(2020, 4, 2, 18).toISOString(),
|
||||
end: new Date(2020, 4, 2, 23).toISOString(),
|
||||
};
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
service: {
|
||||
version: '1.3',
|
||||
},
|
||||
'@timestamp': new Date(2020, 4, 2, 21, 30).toISOString(),
|
||||
},
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
const response = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${range.start}&end=${range.end}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.body.annotations.length).to.be(1);
|
||||
expect(response.body.annotations[0].text).to.be('1.3');
|
||||
|
||||
const earlierRange = {
|
||||
start: new Date(2020, 4, 2, 18).toISOString(),
|
||||
end: new Date(2020, 4, 2, 20).toISOString(),
|
||||
};
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
service: {
|
||||
version: '1.3',
|
||||
},
|
||||
'@timestamp': new Date(2020, 4, 2, 21, 30).toISOString(),
|
||||
},
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
const responseFromEarlierRange = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${earlierRange.start}&end=${earlierRange.end}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(responseFromEarlierRange.body.annotations.length).to.be(2);
|
||||
expect(responseFromEarlierRange.body.annotations[0].text).to.be('1.1');
|
||||
expect(responseFromEarlierRange.body.annotations[1].text).to.be('1.2');
|
||||
});
|
||||
|
||||
it('returns stored annotations for the given environment', async () => {
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
service: {
|
||||
version: '1.3',
|
||||
},
|
||||
'@timestamp': new Date(2020, 4, 2, 21, 30).toISOString(),
|
||||
},
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
service: {
|
||||
version: '1.4',
|
||||
environment: 'production',
|
||||
},
|
||||
'@timestamp': new Date(2020, 4, 2, 21, 31).toISOString(),
|
||||
},
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
const range = {
|
||||
start: new Date(2020, 4, 2, 18).toISOString(),
|
||||
end: new Date(2020, 4, 2, 23).toISOString(),
|
||||
};
|
||||
|
||||
const allEnvironmentsResponse = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${range.start}&end=${range.end}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(allEnvironmentsResponse.body.annotations.length).to.be(2);
|
||||
|
||||
const productionEnvironmentResponse = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${range.start}&end=${range.end}&environment=production`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(productionEnvironmentResponse.body.annotations.length).to.be(1);
|
||||
expect(productionEnvironmentResponse.body.annotations[0].text).to.be('1.4');
|
||||
|
||||
const missingEnvironmentsResponse = await request({
|
||||
url: `/api/apm/services/${serviceName}/annotation/search?start=${range.start}&end=${range.end}&environment=ENVIRONMENT_NOT_DEFINED`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(missingEnvironmentsResponse.body.annotations.length).to.be(1);
|
||||
expect(missingEnvironmentsResponse.body.annotations[0].text).to.be('1.3');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -42,7 +42,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
|
|||
{
|
||||
// this doubles as a smoke test for the _debug query parameter
|
||||
req: {
|
||||
url: `/api/apm/services/foo/errors?start=${start}&end=${end}&uiFilters=%7B%7D_debug=true`,
|
||||
url: `/api/apm/services/foo/errors?start=${start}&end=${end}&uiFilters=%7B%7D&_debug=true`,
|
||||
},
|
||||
expectForbidden: expect404,
|
||||
expectResponse: expect200,
|
||||
|
|
|
@ -8,6 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
|
|||
|
||||
export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('APM specs', () => {
|
||||
loadTestFile(require.resolve('./annotations'));
|
||||
loadTestFile(require.resolve('./feature_controls'));
|
||||
loadTestFile(require.resolve('./agent_configuration'));
|
||||
loadTestFile(require.resolve('./custom_link'));
|
||||
|
|
|
@ -31,5 +31,6 @@ export default function({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./ingest'));
|
||||
loadTestFile(require.resolve('./endpoint'));
|
||||
loadTestFile(require.resolve('./ml'));
|
||||
loadTestFile(require.resolve('./observability'));
|
||||
});
|
||||
}
|
||||
|
|
290
x-pack/test/api_integration/apis/observability/annotations.ts
Normal file
290
x-pack/test/api_integration/apis/observability/annotations.ts
Normal file
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { JsonObject } from 'src/plugins/kibana_utils/common';
|
||||
import { Annotation } from '../../../../plugins/observability/common/annotations';
|
||||
import { ESSearchHit } from '../../../../plugins/apm/typings/elasticsearch';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
const DEFAULT_INDEX_NAME = 'observability-annotations';
|
||||
|
||||
export default function annotationApiTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
|
||||
function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) {
|
||||
switch (method.toLowerCase()) {
|
||||
case 'get':
|
||||
return supertest.get(url).set('kbn-xsrf', 'foo');
|
||||
|
||||
case 'post':
|
||||
return supertest
|
||||
.post(url)
|
||||
.send(data)
|
||||
.set('kbn-xsrf', 'foo');
|
||||
|
||||
case 'delete':
|
||||
return supertest
|
||||
.delete(url)
|
||||
.send(data)
|
||||
.set('kbn-xsrf', 'foo');
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported methoed ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Observability annotations', () => {
|
||||
describe('when creating an annotation', () => {
|
||||
afterEach(async () => {
|
||||
const indexExists = (await es.indices.exists({ index: DEFAULT_INDEX_NAME })).body;
|
||||
if (indexExists) {
|
||||
await es.indices.delete({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with a 400 bad request if data is missing', async () => {
|
||||
const response = await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(response.status).to.be(400);
|
||||
});
|
||||
|
||||
it('fails with a 400 bad request if data is invalid', async () => {
|
||||
const invalidTimestampResponse = await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': 'foo',
|
||||
message: 'foo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidTimestampResponse.status).to.be(400);
|
||||
|
||||
const missingMessageResponse = await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(missingMessageResponse.status).to.be(400);
|
||||
});
|
||||
|
||||
it('completes with a 200 and the created annotation if data is complete and valid', async () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const response = await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': timestamp,
|
||||
message: 'test message',
|
||||
tags: ['apm'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
const { _source, _id, _index } = response.body;
|
||||
|
||||
expect(response.body).to.eql({
|
||||
_index,
|
||||
_id,
|
||||
_primary_term: 1,
|
||||
_seq_no: 0,
|
||||
_version: 1,
|
||||
found: true,
|
||||
_source: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': timestamp,
|
||||
message: 'test message',
|
||||
tags: ['apm'],
|
||||
event: {
|
||||
created: _source.event.created,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(_id).to.be.a('string');
|
||||
|
||||
expect(_source.event.created).to.be.a('string');
|
||||
|
||||
const created = new Date(_source.event.created).getTime();
|
||||
expect(created).to.be.greaterThan(0);
|
||||
expect(_index).to.be(DEFAULT_INDEX_NAME);
|
||||
});
|
||||
|
||||
it('indexes the annotation', async () => {
|
||||
const response = await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: 'test message',
|
||||
tags: ['apm'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
|
||||
const search = await es.search({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
expect(search.body.hits.total.value).to.be(1);
|
||||
|
||||
expect(search.body.hits.hits[0]._source).to.eql(response.body._source);
|
||||
expect(search.body.hits.hits[0]._id).to.eql(response.body._id);
|
||||
});
|
||||
|
||||
it('returns the annotation', async () => {
|
||||
const { _id: id1 } = (
|
||||
await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: '1',
|
||||
tags: ['apm'],
|
||||
},
|
||||
})
|
||||
).body;
|
||||
|
||||
const { _id: id2 } = (
|
||||
await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: '2',
|
||||
tags: ['apm'],
|
||||
},
|
||||
})
|
||||
).body;
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/observability/annotation/${id1}`,
|
||||
method: 'GET',
|
||||
})
|
||||
).body._source.message
|
||||
).to.be('1');
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/observability/annotation/${id2}`,
|
||||
method: 'GET',
|
||||
})
|
||||
).body._source.message
|
||||
).to.be('2');
|
||||
});
|
||||
|
||||
it('deletes the annotation', async () => {
|
||||
await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: 'test message',
|
||||
tags: ['apm'],
|
||||
},
|
||||
});
|
||||
|
||||
await request({
|
||||
url: '/api/observability/annotation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
annotation: {
|
||||
type: 'deployment',
|
||||
},
|
||||
'@timestamp': new Date().toISOString(),
|
||||
message: 'test message 2',
|
||||
tags: ['apm'],
|
||||
},
|
||||
});
|
||||
|
||||
const initialSearch = await es.search({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
expect(initialSearch.body.hits.total.value).to.be(2);
|
||||
|
||||
const [id1, id2] = initialSearch.body.hits.hits.map(
|
||||
(hit: ESSearchHit<Annotation>) => hit._id
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/observability/annotation/${id1}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
const searchAfterFirstDelete = await es.search({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
expect(searchAfterFirstDelete.body.hits.total.value).to.be(1);
|
||||
|
||||
expect(searchAfterFirstDelete.body.hits.hits[0]._id).to.be(id2);
|
||||
|
||||
expect(
|
||||
(
|
||||
await request({
|
||||
url: `/api/observability/annotation/${id2}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
).status
|
||||
).to.be(200);
|
||||
|
||||
const searchAfterSecondDelete = await es.search({
|
||||
index: DEFAULT_INDEX_NAME,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
expect(searchAfterSecondDelete.body.hits.total.value).to.be(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
13
x-pack/test/api_integration/apis/observability/index.ts
Normal file
13
x-pack/test/api_integration/apis/observability/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function observabilityApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Observability specs', () => {
|
||||
loadTestFile(require.resolve('./annotations'));
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue