diff --git a/x-pack/plugins/apm/common/annotations.ts b/x-pack/plugins/apm/common/annotations.ts index 33122f55d880..264236e22b0c 100644 --- a/x-pack/plugins/apm/common/annotations.ts +++ b/x-pack/plugins/apm/common/annotations.ts @@ -11,6 +11,6 @@ export enum AnnotationType { export interface Annotation { type: AnnotationType; id: string; - time: number; + '@timestamp': number; text: string; } diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 85e376112901..7c267efedf1a 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -16,9 +16,13 @@ "taskManager", "actions", "alerting", + "observability", "security" ], "server": true, "ui": true, - "configPath": ["xpack", "apm"] + "configPath": [ + "xpack", + "apm" + ] } diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx index 6eff4759b2e7..0233282bad1b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx @@ -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 }} > diff --git a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx index afce0811b48f..065e0b873312 100644 --- a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx +++ b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx @@ -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 diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 5e05d3962ecc..34b67c834554 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -1,6 +1,7 @@ { "include": [ "./plugins/apm/**/*", + "./plugins/observability/**/*", "./typings/**/*" ], "exclude": [ diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts deleted file mode 100644 index 4df02786b1fb..000000000000 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ /dev/null @@ -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; - dynamic_templates?: any[]; - } - | { - type: string; - ignore_above?: number; - scaling_factor?: number; - ignore_malformed?: boolean; - coerce?: boolean; - fields?: Record; - }; - -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 - }); -} diff --git a/x-pack/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/plugins/apm/server/lib/helpers/es_client.ts index 45a7eca46cab..5d3cd9464af7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/es_client.ts @@ -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: (params: APMIndexDocumentParams) => { return callEs('index', params); }, - delete: (params: IndicesDeleteParams): Promise => { + delete: ( + params: Omit + ): Promise => { return callEs('delete', params); }, indicesCreate: (params: IndicesCreateParams) => { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts new file mode 100644 index 000000000000..e1f24bc1443f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts @@ -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[]; +} diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts new file mode 100644 index 000000000000..44aa554fa320 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -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 { + try { + const environmentFilter = getEnvironmentUiFilterES(environment); + + const response: ESSearchResponse = (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; + } +} diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts index 614014ee37af..ef70a29728e7 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts @@ -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' + } + ]); }); }); }); diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.ts index c03746ca220e..40e7eb653593 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.ts @@ -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 + }; } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index b2dc22ceb291..356863c5f6e1 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -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: [ { diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts index 293c01d4b61d..be5f9f342557 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts @@ -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 }; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts index c05b4e113deb..2e3e4ae7e22e 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts @@ -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< diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 352bbe1b6a29..316a9551d992 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -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 diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index c44d7b41f532..357d0e7487e1 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index f338ee058842..5ee1f36cbac5 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -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 diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index 42b99b34beea..bc0af3b0bb25 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -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': { diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts index 2f3ea0940cb2..215c30b9581f 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -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 }; diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace.ts b/x-pack/plugins/apm/server/lib/traces/get_trace.ts index a1b9270e0d7b..6d0a3e0a758e 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index fb1aafc2d6c9..18f0726ae406 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -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 { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts index 3656b32c1709..37b61b494297 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts @@ -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 diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts index 8a96a25aef50..b36ae8961806 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts @@ -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, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts index 5f211b142725..a0b6bf40eea6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts @@ -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< diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index 7a3277965ef8..94dd4860883b 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -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', () => { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index e33b98592da2..8d37cfa05e95 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -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 { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts index a6a1a76e1966..45e9bb0db3c3 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts @@ -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, diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts index 9b22e1794f96..6d3e5d12e87c 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts index 9dd29a066432..d304a2a8a563 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts @@ -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, diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts index 1462cf2eeefa..c069113e549f 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 29ab618cbdd0..b17bffea812f 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -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 { taskManager?: TaskManagerSetupContract; alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; + observability?: ObservabilityPluginSetup; features: FeaturesPluginSetup; security?: SecurityPluginSetup; } @@ -114,6 +116,7 @@ export class APMPlugin implements Plugin { config$: mergedConfig$, logger: this.logger!, plugins: { + observability: plugins.observability, security: plugins.security } }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 9b611a0bbd6b..53c3630d5a74 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -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 } }); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 7964d8b0268e..4fd740c4e81c 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -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) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 1c6561ee24c9..474ab1c6082a 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -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 ?? [])) }); } })); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e049255eb8ec..fe6195605fb2 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -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 { config$: Observable; logger: Logger; plugins: { + observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; }; } diff --git a/x-pack/plugins/observability/common/annotations.ts b/x-pack/plugins/observability/common/annotations.ts new file mode 100644 index 000000000000..6aea4d3d92f9 --- /dev/null +++ b/x-pack/plugins/observability/common/annotations.ts @@ -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; +} diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 438b9ddea473..8e2cfe980039 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -2,6 +2,10 @@ "id": "observability", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "observability"], - "ui": true + "configPath": [ + "xpack", + "observability" + ], + "ui": true, + "server": true } diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts new file mode 100644 index 000000000000..78550b781b41 --- /dev/null +++ b/x-pack/plugins/observability/server/index.ts @@ -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; + +export const plugin = (initContext: PluginInitializerContext) => + new ObservabilityPlugin(initContext); + +export { + createOrUpdateIndex, + MappingsDefinition, + ObservabilityPluginSetup, + ScopedAnnotationsClient, +}; diff --git a/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts new file mode 100644 index 000000000000..58639ef084ce --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts @@ -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; +export type AnnotationsAPI = PromiseReturnType; + +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, + }); + }, + }; +} diff --git a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts new file mode 100644 index 000000000000..3f2604468e17 --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts @@ -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; +type DeleteParams = t.TypeOf; +type GetByIdParams = t.TypeOf; + +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; + return response; + }, + }; +} diff --git a/x-pack/plugins/observability/server/lib/annotations/mappings.ts b/x-pack/plugins/observability/server/lib/annotations/mappings.ts new file mode 100644 index 000000000000..db85f2d18df1 --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/mappings.ts @@ -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; diff --git a/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts b/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts new file mode 100644 index 000000000000..3c29822acd6d --- /dev/null +++ b/x-pack/plugins/observability/server/lib/annotations/register_annotation_apis.ts @@ -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>( + types: TType, + handler: (params: { data: t.TypeOf; client: ScopedAnnotationsClient }) => Promise + ): RequestHandler { + return async (...args: Parameters) => { + 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); + }) + ); +} diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts new file mode 100644 index 000000000000..86cac2f340e4 --- /dev/null +++ b/x-pack/plugins/observability/server/plugin.ts @@ -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 +) => Promise; + +export interface ObservabilityPluginSetup { + getScopedAnnotationsClient: LazyScopedAnnotationsClientFactory; +} + +export class ObservabilityPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + } + + public async setup(core: CoreSetup, plugins: {}): Promise { + const config$ = this.initContext.config.create(); + + const config = await config$.pipe(take(1)).toPromise(); + + let annotationsApiPromise: Promise | 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() {} +} diff --git a/x-pack/plugins/observability/server/utils/create_or_update_index.ts b/x-pack/plugins/observability/server/utils/create_or_update_index.ts new file mode 100644 index 000000000000..2c6f3dbefdeb --- /dev/null +++ b/x-pack/plugins/observability/server/utils/create_or_update_index.ts @@ -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; +} + +export interface MappingsDefinition { + dynamic?: boolean | 'strict'; + properties: Record; + 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, + }); +} diff --git a/x-pack/plugins/observability/typings/common.ts b/x-pack/plugins/observability/typings/common.ts new file mode 100644 index 000000000000..b4a90934a9f4 --- /dev/null +++ b/x-pack/plugins/observability/typings/common.ts @@ -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 extends (...args: any[]) => Promise + ? Value + : Func; diff --git a/x-pack/test/api_integration/apis/apm/annotations.ts b/x-pack/test/api_integration/apis/apm/annotations.ts new file mode 100644 index 000000000000..4746a7713f34 --- /dev/null +++ b/x-pack/test/api_integration/apis/apm/annotations.ts @@ -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'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/apm/feature_controls.ts b/x-pack/test/api_integration/apis/apm/feature_controls.ts index 9f76941935bb..5f61c963a69a 100644 --- a/x-pack/test/api_integration/apis/apm/feature_controls.ts +++ b/x-pack/test/api_integration/apis/apm/feature_controls.ts @@ -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, diff --git a/x-pack/test/api_integration/apis/apm/index.ts b/x-pack/test/api_integration/apis/apm/index.ts index 4a4265cfd073..de076e8c4672 100644 --- a/x-pack/test/api_integration/apis/apm/index.ts +++ b/x-pack/test/api_integration/apis/apm/index.ts @@ -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')); diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 0a87dcb4b5bb..75fa90bb4c3f 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -31,5 +31,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./ingest')); loadTestFile(require.resolve('./endpoint')); loadTestFile(require.resolve('./ml')); + loadTestFile(require.resolve('./observability')); }); } diff --git a/x-pack/test/api_integration/apis/observability/annotations.ts b/x-pack/test/api_integration/apis/observability/annotations.ts new file mode 100644 index 000000000000..6d32162bfcc6 --- /dev/null +++ b/x-pack/test/api_integration/apis/observability/annotations.ts @@ -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) => 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); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/observability/index.ts b/x-pack/test/api_integration/apis/observability/index.ts new file mode 100644 index 000000000000..25e3e0693c3c --- /dev/null +++ b/x-pack/test/api_integration/apis/observability/index.ts @@ -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')); + }); +}