diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.test.ts new file mode 100644 index 0000000000000..e29c9714be4b6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fakeSyntheticSource } from './fake_synthetic_source'; + +describe('fakeSyntheticSource', () => { + it('flattens and unflattens objects', () => { + const obj = { + service: { + name: 'my-service', + }, + transaction: { + type: 'request', + name: 'GET /api/my-transaction', + }, + }; + + expect(fakeSyntheticSource(obj)).toEqual(obj); + }); + + it('drops null and undefined values', () => { + const obj = { + service: { + name: null, + }, + transaction: { + type: 'request', + name: 'GET /api/my-transaction', + }, + }; + + expect(fakeSyntheticSource(obj)).toEqual({ + transaction: { + type: 'request', + name: 'GET /api/my-transaction', + }, + }); + }); + + it('only wraps multiple values in an array', () => { + const obj = { + span: { + links: [ + { trace: { id: '1' }, span: { id: '1' } }, + { trace: { id: '2' }, span: { id: '2' } }, + ], + }, + }; + + expect(fakeSyntheticSource(obj)).toEqual({ + span: { + links: { + trace: { + id: ['1', '2'], + }, + span: { + id: ['1', '2'], + }, + }, + }, + }); + }); + + it('deduplicates and sorts array values', () => { + const obj = { + span: { + links: [ + { trace: { id: '3' }, span: { id: '1' } }, + { trace: { id: '1' }, span: { id: '1' } }, + { trace: { id: '1' }, span: { id: '4' } }, + ], + }, + }; + + expect(fakeSyntheticSource(obj)).toEqual({ + span: { + links: { + trace: { + id: ['1', '3'], + }, + span: { + id: ['1', '4'], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.ts new file mode 100644 index 0000000000000..4068691296e74 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/fake_synthetic_source.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isArray, isObjectLike, set, uniq } from 'lodash'; + +function flatten( + target: Record, + source: Record, + toArray: boolean, + prefix: string = '' +) { + for (const key in source) { + if (!Object.hasOwn(source, key)) { + continue; + } + const value = source[key]; + if (value === undefined || value === null) { + continue; + } + const nextKey = `${prefix}${key}`; + if (isArray(value)) { + value.forEach((val) => { + flatten(target, val, true, `${nextKey}.`); + }); + } else if (isObjectLike(value)) { + flatten(target, value, toArray, `${nextKey}.`); + } else if (toArray && Array.isArray(target[nextKey])) { + target[nextKey].push(value); + } else if (toArray) { + target[nextKey] = [value]; + } else { + target[nextKey] = value; + } + } + return target; +} + +export function fakeSyntheticSource(object: Record) { + const flattened = flatten({}, object, false, ''); + + const unflattened = {}; + for (const key in flattened) { + if (!Object.hasOwn(flattened, key)) { + continue; + } + let val = flattened[key]; + if (Array.isArray(val)) { + val = uniq(val).sort(); + } + set(unflattened, key, val); + } + return unflattened; +} diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index 9488c993bc08b..40579a270672c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -38,6 +38,7 @@ import { unpackProcessorEvents, processorEventsToIndex, } from './unpack_processor_events'; +import { fakeSyntheticSource } from './fake_synthetic_source'; export type APMEventESSearchRequest = Omit & { apm: { @@ -162,9 +163,48 @@ export class APMEventClient { return this.callAsyncWithDebug({ cb: (opts) => - this.esClient.search(searchParams, opts) as unknown as Promise<{ - body: TypedSearchResponse; - }>, + ( + this.esClient.search(searchParams, opts) as unknown as Promise<{ + body: TypedSearchResponse; + }> + ).then((response) => { + // ensure metric data is compatible with synthetic source + // enabled + const metricEventsOnly = params.apm.events.every( + (event) => event === ProcessorEvent.metric + ); + + if (!response.body?.hits?.hits) { + return response; + } + + const hits = response.body.hits.hits.map((hit) => { + if ( + metricEventsOnly || + // take filter_path etc into account + (hit._source && + 'processor' in hit._source && + hit._source.processor?.event === ProcessorEvent.metric) + ) { + return { + ...hit, + _source: fakeSyntheticSource(hit._source), + }; + } + return hit; + }); + + return { + ...response, + body: { + ...response.body, + hits: { + ...response.body.hits, + hits, + }, + }, + }; + }), operationName, params: searchParams, requestType: 'search',