diff --git a/x-pack/plugins/observability_solution/apm_data_access/server/services/get_host_services/index.ts b/x-pack/plugins/observability_solution/apm_data_access/server/services/get_host_services/index.ts new file mode 100644 index 00000000000000..b83320d162f3c9 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm_data_access/server/services/get_host_services/index.ts @@ -0,0 +1,185 @@ +/* + * 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 { rangeQuery, termQuery } from '@kbn/observability-plugin/server'; +import { + AGENT_NAME, + HOST_HOSTNAME, + HOST_NAME, + METRICSET_NAME, + SERVICE_NAME, +} from '@kbn/apm-types/es_fields'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + RollupInterval, + TimeRangeMetadata, + getBucketSize, + getPreferredBucketSizeAndDataSource, +} from '../../../common'; +import { ApmDocumentType } from '../../../common/document_type'; +import type { ApmDataAccessServicesParams } from '../get_services'; + +const MAX_SIZE = 1000; + +export interface HostServicesRequest { + filters: Record; + start: number; + end: number; + size?: number; + documentSources: TimeRangeMetadata['sources']; +} + +const suitableTypes = [ApmDocumentType.TransactionMetric, ApmDocumentType.ErrorEvent]; + +export function createGetHostServices({ apmEventClient }: ApmDataAccessServicesParams) { + return async ({ start, end, size = MAX_SIZE, filters, documentSources }: HostServicesRequest) => { + const sourcesToUse = getPreferredBucketSizeAndDataSource({ + sources: documentSources.filter((s) => suitableTypes.includes(s.documentType)), + bucketSizeInSeconds: getBucketSize({ start, end, numBuckets: 50 }).bucketSize, + }); + + const commonFiltersList: QueryDslQueryContainer[] = [ + ...rangeQuery(start, end), + { + exists: { + field: SERVICE_NAME, + }, + }, + ]; + + if (filters[HOST_NAME]) { + commonFiltersList.push({ + bool: { + should: [ + ...termQuery(HOST_NAME, filters[HOST_NAME]), + ...termQuery(HOST_HOSTNAME, filters[HOST_HOSTNAME]), + ], + minimum_should_match: 1, + }, + }); + } + // get services from transaction metrics + const metricsQuery = await apmEventClient.search('get_apm_host_services_from_metrics', { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionMetric, + rollupInterval: RollupInterval.OneMinute, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + { + bool: { + should: [ + ...termQuery(METRICSET_NAME, 'app'), + { + bool: { + must: [...termQuery(METRICSET_NAME, 'transaction')], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ...commonFiltersList, + ], + }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size, + }, + aggs: { + latestAgent: { + top_metrics: { + metrics: [{ field: AGENT_NAME }], + sort: { + '@timestamp': 'desc', + }, + size: 1, + }, + }, + }, + }, + }, + }, + }); + + // get services from logs + const logsQuery = await apmEventClient.search('get_apm_host_services_from_logs', { + apm: { + sources: [ + { + documentType: ApmDocumentType.ErrorEvent, + rollupInterval: sourcesToUse.source.rollupInterval, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: commonFiltersList, + }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size, + }, + aggs: { + latestAgent: { + top_metrics: { + metrics: [{ field: AGENT_NAME }], + sort: { + '@timestamp': 'desc', + }, + size: 1, + }, + }, + }, + }, + }, + }, + }); + + const servicesListBucketsFromMetrics = metricsQuery.aggregations?.services.buckets || []; + const servicesListBucketsFromLogs = logsQuery.aggregations?.services.buckets || []; + const serviceMap = [...servicesListBucketsFromMetrics, ...servicesListBucketsFromLogs].reduce( + (acc, bucket) => { + const serviceName = bucket.key as string; + const latestAgentEntry = bucket.latestAgent.top[0]; + const latestTimestamp = latestAgentEntry.sort[0] as string; + const agentName = latestAgentEntry.metrics[AGENT_NAME] as string | null; + // dedup and get the latest timestamp + const existingService = acc.get(serviceName); + if (!existingService || existingService.latestTimestamp < latestTimestamp) { + acc.set(serviceName, { latestTimestamp, agentName }); + } + return acc; + }, + new Map() + ); + const services = Array.from(serviceMap) + .slice(0, size) + .map(([serviceName, { agentName }]) => ({ + serviceName, + agentName, + })); + return { services }; + }; +} diff --git a/x-pack/plugins/observability_solution/apm_data_access/server/services/get_services.ts b/x-pack/plugins/observability_solution/apm_data_access/server/services/get_services.ts index 03a31a1cc45347..71ae961b256b6d 100644 --- a/x-pack/plugins/observability_solution/apm_data_access/server/services/get_services.ts +++ b/x-pack/plugins/observability_solution/apm_data_access/server/services/get_services.ts @@ -9,6 +9,7 @@ import { APMEventClient } from '../lib/helpers/create_es_client/create_apm_event import { createGetDocumentSources } from './get_document_sources'; import { getDocumentTypeConfig } from './get_document_type_config'; import { createGetHostNames } from './get_host_names'; +import { createGetHostServices } from './get_host_services'; export interface ApmDataAccessServicesParams { apmEventClient: APMEventClient; @@ -19,5 +20,6 @@ export function getServices(params: ApmDataAccessServicesParams) { getDocumentSources: createGetDocumentSources(params), getHostNames: createGetHostNames(params), getDocumentTypeConfig, + getHostServices: createGetHostServices(params), }; } diff --git a/x-pack/plugins/observability_solution/infra/common/http_api/host_details/get_infra_services.ts b/x-pack/plugins/observability_solution/infra/common/http_api/host_details/get_infra_services.ts index 718513416dad77..9f330567337eb4 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/host_details/get_infra_services.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/host_details/get_infra_services.ts @@ -8,8 +8,7 @@ import { createLiteralValueFromUndefinedRT, inRangeFromStringRt, - dateRt, - datemathStringRt, + isoToEpochRt, } from '@kbn/io-ts-utils'; import * as rt from 'io-ts'; @@ -17,7 +16,6 @@ export const sizeRT = rt.union([ inRangeFromStringRt(1, 100), createLiteralValueFromUndefinedRT(10), ]); -export const assetDateRT = rt.union([dateRt, datemathStringRt]); export const servicesFiltersRT = rt.strict({ ['host.name']: rt.string, @@ -26,7 +24,7 @@ export const servicesFiltersRT = rt.strict({ export type ServicesFilter = rt.TypeOf; export const GetServicesRequestQueryRT = rt.intersection([ - rt.strict({ from: assetDateRT, to: assetDateRT, filters: rt.string }), + rt.strict({ from: isoToEpochRt, to: isoToEpochRt, filters: rt.string }), rt.partial({ size: sizeRT, validatedFilters: servicesFiltersRT, @@ -37,8 +35,8 @@ export type GetServicesRequestQuery = rt.TypeOf { - const { error, metric } = apmIndices; - const { filters, size = 10, from, to } = options; - const commonFiltersList: QueryDslQueryContainer[] = [ - { - range: { - '@timestamp': { - gte: from, - lte: to, - }, - }, - }, - { - exists: { - field: 'service.name', - }, - }, - ]; - - if (filters['host.name']) { - // also query for host.hostname field along with host.name, as some services may use this field - const HOST_HOSTNAME_FIELD = 'host.hostname'; - commonFiltersList.push({ - bool: { - should: [ - ...termQuery(HOST_NAME_FIELD, filters[HOST_NAME_FIELD]), - ...termQuery(HOST_HOSTNAME_FIELD, filters[HOST_NAME_FIELD]), - ], - minimum_should_match: 1, - }, - }); - } - const aggs = { - services: { - terms: { - field: 'service.name', - size, - }, - aggs: { - latestAgent: { - top_metrics: { - metrics: [{ field: 'agent.name' }], - sort: { - '@timestamp': 'desc', - }, - size: 1, - }, - }, - }, - }, - }; - // get services from transaction metrics - const metricsQuery = { - size: 0, - _source: false, - query: { - bool: { - filter: [ - { - term: { - [PROCESSOR_EVENT]: 'metric', - }, - }, - { - bool: { - should: [ - { - term: { - 'metricset.name': 'app', - }, - }, - { - bool: { - must: [ - { - term: { - 'metricset.name': 'transaction', - }, - }, - { - term: { - 'metricset.interval': '1m', // make this dynamic if we start returning time series data - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ...commonFiltersList, - ], - }, - }, - aggs, - }; - // get services from logs - const logsQuery = { - size: 0, - _source: false, - query: { - bool: { - filter: commonFiltersList, - }, - }, - aggs, - }; - - const resultMetrics = await client<{}, ServicesAPIQueryAggregation>({ - body: metricsQuery, - index: [metric], - }); - const resultLogs = await client<{}, ServicesAPIQueryAggregation>({ - body: logsQuery, - index: [error], - }); - - const servicesListBucketsFromMetrics = resultMetrics.aggregations?.services?.buckets || []; - const servicesListBucketsFromLogs = resultLogs.aggregations?.services?.buckets || []; - const serviceMap = [...servicesListBucketsFromMetrics, ...servicesListBucketsFromLogs].reduce( - (acc, bucket) => { - const serviceName = bucket.key; - const latestAgentEntry = bucket.latestAgent.top[0]; - const latestTimestamp = latestAgentEntry.sort[0]; - const agentName = latestAgentEntry.metrics['agent.name']; - // dedup and get the latest timestamp - const existingService = acc.get(serviceName); - if (!existingService || existingService.latestTimestamp < latestTimestamp) { - acc.set(serviceName, { latestTimestamp, agentName }); - } - - return acc; - }, - new Map() - ); - - const services = Array.from(serviceMap) - .slice(0, size) - .map(([serviceName, { agentName }]) => ({ - serviceName, - agentName, - })); - return { services }; -}; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/services/index.ts b/x-pack/plugins/observability_solution/infra/server/routes/services/index.ts index 86af345d5175e8..9673b317884872 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/services/index.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/services/index.ts @@ -6,15 +6,14 @@ */ import { - GetServicesRequestQueryRT, GetServicesRequestQuery, + GetServicesRequestQueryRT, ServicesAPIResponseRT, } from '../../../common/http_api/host_details'; import { InfraBackendLibs } from '../../lib/infra_types'; -import { getServices } from '../../lib/host_details/get_services'; import { validateStringAssetFilters } from './lib/utils'; -import { createSearchClient } from '../../lib/create_search_client'; import { buildRouteValidationWithExcess } from '../../utils/route_validation'; +import { getApmDataAccessClient } from '../../lib/helpers/get_apm_data_access_client'; export const initServicesRoute = (libs: InfraBackendLibs) => { const { framework } = libs; @@ -33,18 +32,34 @@ export const initServicesRoute = (libs: InfraBackendLibs) => { }, }, }, - async (requestContext, request, response) => { - const [{ savedObjects }] = await libs.getStartServices(); + async (context, request, response) => { const { from, to, size = 10, validatedFilters } = request.query; - const client = createSearchClient(requestContext, framework, request); - const soClient = savedObjects.getScopedClient(request); - const apmIndices = await libs.plugins.apmDataAccess.setup.getApmIndices(soClient); - const services = await getServices(client, apmIndices, { - from, - to, - size, + const apmDataAccessClient = getApmDataAccessClient({ request, libs, context }); + const hasApmPrivileges = await apmDataAccessClient.hasPrivileges(); + + if (!hasApmPrivileges) { + return response.customError({ + statusCode: 403, + body: { + message: 'APM data access service is not available', + }, + }); + } + + const apmDataAccessServices = await apmDataAccessClient.getServices(); + + const apmDocumentSources = await apmDataAccessServices.getDocumentSources({ + start: from, + end: to, + }); + + const services = await apmDataAccessServices?.getHostServices({ + documentSources: apmDocumentSources, + start: from, + end: to, filters: validatedFilters!, + size, }); return response.ok({ body: ServicesAPIResponseRT.encode(services),