[APM] Annotations API (#64796)

This commit is contained in:
Dario Gieselaar 2020-05-05 19:49:39 +02:00 committed by GitHub
parent af8f9fa05f
commit 399eed77bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1639 additions and 265 deletions

View file

@ -11,6 +11,6 @@ export enum AnnotationType {
export interface Annotation {
type: AnnotationType;
id: string;
time: number;
'@timestamp': number;
text: string;
}

View file

@ -16,9 +16,13 @@
"taskManager",
"actions",
"alerting",
"observability",
"security"
],
"server": true,
"ui": true,
"configPath": ["xpack", "apm"]
"configPath": [
"xpack",
"apm"
]
}

View file

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

View file

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

View file

@ -1,6 +1,7 @@
{
"include": [
"./plugins/apm/**/*",
"./plugins/observability/**/*",
"./typings/**/*"
],
"exclude": [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -2,6 +2,10 @@
"id": "observability",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "observability"],
"ui": true
"configPath": [
"xpack",
"observability"
],
"ui": true,
"server": true
}

View 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,
};

View file

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

View file

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

View file

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

View file

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

View 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() {}
}

View file

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

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

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

View file

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

View file

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

View file

@ -31,5 +31,6 @@ export default function({ loadTestFile }) {
loadTestFile(require.resolve('./ingest'));
loadTestFile(require.resolve('./endpoint'));
loadTestFile(require.resolve('./ml'));
loadTestFile(require.resolve('./observability'));
});
}

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

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