diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts index 12d89484570c6..7d0b94705f9e6 100644 --- a/x-pack/plugins/infra/common/constants.ts +++ b/x-pack/plugins/infra/common/constants.ts @@ -21,6 +21,8 @@ export const TIEBREAKER_FIELD = '_doc'; export const HOST_FIELD = 'host.name'; export const CONTAINER_FIELD = 'container.id'; export const POD_FIELD = 'kubernetes.pod.uid'; +export const CMDLINE_FIELD = 'system.process.cmdline'; +export const HOST_NAME_FIELD = 'host.name'; export const O11Y_AAD_FIELDS = [ 'cloud.*', diff --git a/x-pack/plugins/infra/common/http_api/host_details/get_infra_services.ts b/x-pack/plugins/infra/common/http_api/host_details/get_infra_services.ts new file mode 100644 index 0000000000000..56b5a4757bdb2 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/host_details/get_infra_services.ts @@ -0,0 +1,78 @@ +/* + * 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 { + createLiteralValueFromUndefinedRT, + inRangeFromStringRt, + dateRt, + datemathStringRt, +} from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +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, +}); + +export type ServicesFilter = rt.TypeOf; + +export const GetServicesRequestQueryRT = rt.intersection([ + rt.strict({ from: assetDateRT, to: assetDateRT, filters: rt.string }), + rt.partial({ + size: sizeRT, + validatedFilters: servicesFiltersRT, + }), +]); + +export type GetServicesRequestQuery = rt.TypeOf; + +export interface ServicesAPIRequest { + filters: ServicesFilter; + from: string; + to: string; + size?: number; +} + +const AgentNameRT = rt.union([rt.string, rt.null]); + +export const ServicesAPIQueryAggregationRT = rt.type({ + services: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + latestAgent: rt.type({ + top: rt.array( + rt.type({ + sort: rt.array(rt.string), + metrics: rt.type({ + 'agent.name': AgentNameRT, + }), + }) + ), + }), + }) + ), + }), +}); + +export type ServicesAPIQueryAggregation = rt.TypeOf; + +export const ServiceRT = rt.type({ + 'service.name': rt.string, + 'agent.name': AgentNameRT, +}); + +export type Service = rt.TypeOf; + +export const ServicesAPIResponseRT = rt.type({ + services: rt.array(ServiceRT), +}); diff --git a/x-pack/plugins/infra/common/http_api/host_details/index.ts b/x-pack/plugins/infra/common/http_api/host_details/index.ts index 4defa8052cde7..1ff12a5190b57 100644 --- a/x-pack/plugins/infra/common/http_api/host_details/index.ts +++ b/x-pack/plugins/infra/common/http_api/host_details/index.ts @@ -6,3 +6,4 @@ */ export * from './process_list'; +export * from './get_infra_services'; diff --git a/x-pack/plugins/infra/kibana.jsonc b/x-pack/plugins/infra/kibana.jsonc index 7b2705117675d..8f59853b97101 100644 --- a/x-pack/plugins/infra/kibana.jsonc +++ b/x-pack/plugins/infra/kibana.jsonc @@ -33,7 +33,8 @@ "uiActions", "unifiedSearch", "usageCollection", - "visTypeTimeseries" + "visTypeTimeseries", + "apmDataAccess" ], "optionalPlugins": ["spaces", "ml", "home", "embeddable", "osquery", "cloud", "profilingDataAccess"], "requiredBundles": [ diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index b041f495057af..5bba62e104b5c 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -33,6 +33,7 @@ import { initSnapshotRoute } from './routes/snapshot'; import { initInfraMetricsRoute } from './routes/infra'; import { initMetricsExplorerViewRoutes } from './routes/metrics_explorer_views'; import { initProfilingRoutes } from './routes/profiling'; +import { initServicesRoute } from './routes/services'; export const initInfraServer = (libs: InfraBackendLibs) => { initIpToHostName(libs); @@ -61,4 +62,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initOverviewRoute(libs); initInfraMetricsRoute(libs); initProfilingRoutes(libs); + initServicesRoute(libs); }; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index a6bba3babfa83..56b8fe29c85d6 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -31,6 +31,10 @@ import { ProfilingDataAccessPluginSetup, ProfilingDataAccessPluginStart, } from '@kbn/profiling-data-access-plugin/server'; +import { + ApmDataAccessPluginSetup, + ApmDataAccessPluginStart, +} from '@kbn/apm-data-access-plugin/server'; export interface InfraServerPluginSetupDeps { alerting: AlertingPluginContract; @@ -47,6 +51,7 @@ export interface InfraServerPluginSetupDeps { logsShared: LogsSharedPluginSetup; metricsDataAccess: MetricsDataPluginSetup; profilingDataAccess?: ProfilingDataAccessPluginSetup; + apmDataAccess: ApmDataAccessPluginSetup; } export interface InfraServerPluginStartDeps { @@ -54,6 +59,7 @@ export interface InfraServerPluginStartDeps { dataViews: DataViewsPluginStart; logsShared: LogsSharedPluginStart; profilingDataAccess?: ProfilingDataAccessPluginStart; + apmDataAccess: ApmDataAccessPluginStart; } export interface CallWithRequestParams extends estypes.RequestBase { diff --git a/x-pack/plugins/infra/server/lib/host_details/common.ts b/x-pack/plugins/infra/server/lib/host_details/common.ts deleted file mode 100644 index 3b84934e8181e..0000000000000 --- a/x-pack/plugins/infra/server/lib/host_details/common.ts +++ /dev/null @@ -1,8 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const CMDLINE_FIELD = 'system.process.cmdline'; diff --git a/x-pack/plugins/infra/server/lib/host_details/get_services.ts b/x-pack/plugins/infra/server/lib/host_details/get_services.ts new file mode 100644 index 0000000000000..f2a58cf72d4de --- /dev/null +++ b/x-pack/plugins/infra/server/lib/host_details/get_services.ts @@ -0,0 +1,143 @@ +/* + * 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { APMDataAccessConfig } from '@kbn/apm-data-access-plugin/server'; +import { termQuery } from '@kbn/observability-plugin/server'; +import { ESSearchClient } from '../metrics/types'; +import { + ServicesAPIRequest, + ServicesAPIQueryAggregation, +} from '../../../common/http_api/host_details'; +import { HOST_NAME_FIELD } from '../../../common/constants'; + +export const getServices = async ( + client: ESSearchClient, + apmIndices: APMDataAccessConfig['indices'], + options: ServicesAPIRequest +) => { + 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: { + 'metricset.name': 'transaction', + }, + }, + { + term: { + 'metricset.interval': '1m', // make this dynamic if we start returning time series data + }, + }, + ...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 }]) => ({ + 'service.name': serviceName, + 'agent.name': agentName, + })); + return { services }; +}; diff --git a/x-pack/plugins/infra/server/lib/host_details/process_list.ts b/x-pack/plugins/infra/server/lib/host_details/process_list.ts index 27563e3218aae..55f2cf3f612f6 100644 --- a/x-pack/plugins/infra/server/lib/host_details/process_list.ts +++ b/x-pack/plugins/infra/server/lib/host_details/process_list.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { TIMESTAMP_FIELD } from '../../../common/constants'; +import { TIMESTAMP_FIELD, CMDLINE_FIELD } from '../../../common/constants'; import { ProcessListAPIRequest, ProcessListAPIQueryAggregation } from '../../../common/http_api'; import { ESSearchClient } from '../metrics/types'; -import { CMDLINE_FIELD } from './common'; const TOP_N = 10; diff --git a/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts b/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts index 03b7e8f825686..befe8fc017ad4 100644 --- a/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts +++ b/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts @@ -6,7 +6,7 @@ */ import { first } from 'lodash'; -import { TIMESTAMP_FIELD } from '../../../common/constants'; +import { TIMESTAMP_FIELD, CMDLINE_FIELD } from '../../../common/constants'; import { ProcessListAPIChartRequest, ProcessListAPIChartQueryAggregation, @@ -14,7 +14,6 @@ import { ProcessListAPIChartResponse, } from '../../../common/http_api'; import { ESSearchClient } from '../metrics/types'; -import { CMDLINE_FIELD } from './common'; export const getProcessListChart = async ( search: ESSearchClient, diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index b3c8039423ec5..8cf0fc81a7321 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -13,6 +13,8 @@ import { ObservabilityConfig } from '@kbn/observability-plugin/server'; import type { LocatorPublic } from '@kbn/share-plugin/common'; import type { ILogsSharedLogEntriesDomain } from '@kbn/logs-shared-plugin/server'; import type { MetricsDataClient } from '@kbn/metrics-data-access-plugin/server'; +import { APMDataAccessConfig } from '@kbn/apm-data-access-plugin/server'; +import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { RulesServiceSetup } from '../services/rules'; import { InfraConfig, InfraPluginStartServicesAccessor } from '../types'; import { KibanaFramework } from './adapters/framework/kibana_framework_adapter'; @@ -41,4 +43,5 @@ export interface InfraBackendLibs extends InfraDomainLibs { logger: Logger; alertsLocator?: LocatorPublic; metricsClient: MetricsDataClient; + getApmIndices: (soClient: SavedObjectsClientContract) => Promise; } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 499dae06e36b7..78501be3f0f26 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -175,6 +175,7 @@ export class InfraServerPlugin setup(core: InfraPluginCoreSetup, plugins: InfraServerPluginSetupDeps) { const framework = new KibanaFramework(core, this.config, plugins); const metricsClient = plugins.metricsDataAccess.client; + const getApmIndices = plugins.apmDataAccess.getApmIndices; metricsClient.setDefaultMetricIndicesHandler(async (options: GetMetricIndicesOptions) => { const sourceConfiguration = await sources.getInfraSourceConfiguration( options.savedObjectsClient, @@ -219,6 +220,7 @@ export class InfraServerPlugin sources, sourceStatus, metricsClient, + getApmIndices, ...domainLibs, handleEsError, logsRules: this.logsRules.setup(core, plugins), diff --git a/x-pack/plugins/infra/server/routes/services/index.ts b/x-pack/plugins/infra/server/routes/services/index.ts new file mode 100644 index 0000000000000..4d8d3c1ad9473 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/services/index.ts @@ -0,0 +1,54 @@ +/* + * 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 { + GetServicesRequestQueryRT, + GetServicesRequestQuery, + 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'; + +export const initServicesRoute = (libs: InfraBackendLibs) => { + const { framework } = libs; + framework.registerRoute( + { + method: 'get', + path: '/api/infra/services', + validate: { + query: (q, res) => { + const [invalidResponse, parsedFilters] = validateStringAssetFilters(q, res); + if (invalidResponse) { + return invalidResponse; + } + q.validatedFilters = parsedFilters; + return buildRouteValidationWithExcess(GetServicesRequestQueryRT)(q, res); + }, + }, + }, + async (requestContext, request, response) => { + const [{ savedObjects }] = await libs.getStartServices(); + const { from, to, size = 10, validatedFilters } = request.query; + + const client = createSearchClient(requestContext, framework, request); + const soClient = savedObjects.getScopedClient(request); + const apmIndices = await libs.getApmIndices(soClient); + const services = await getServices(client, apmIndices, { + from, + to, + size, + filters: validatedFilters!, + }); + return response.ok({ + body: ServicesAPIResponseRT.encode(services), + }); + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/services/lib/utils.ts b/x-pack/plugins/infra/server/routes/services/lib/utils.ts new file mode 100644 index 0000000000000..c3ef240a53a7d --- /dev/null +++ b/x-pack/plugins/infra/server/routes/services/lib/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { RouteValidationError, RouteValidationResultFactory } from '@kbn/core/server'; + +type ValidateStringAssetFiltersReturn = [{ error: RouteValidationError }] | [null, any]; + +export function validateStringAssetFilters( + q: any, + res: RouteValidationResultFactory +): ValidateStringAssetFiltersReturn { + try { + if (!q.filters) return [res.badRequest(new Error(`filters is required`))]; + const parsedFilters = JSON.parse(q.filters); + return [null, parsedFilters]; + } catch (err: any) { + return [res.badRequest(err)]; + } +} diff --git a/x-pack/plugins/infra/server/utils/route_validation.test.ts b/x-pack/plugins/infra/server/utils/route_validation.test.ts new file mode 100644 index 0000000000000..8e25a974a8d98 --- /dev/null +++ b/x-pack/plugins/infra/server/utils/route_validation.test.ts @@ -0,0 +1,133 @@ +/* + * 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 * as rt from 'io-ts'; +import type { RouteValidationResultFactory } from '@kbn/core/server'; + +import { buildRouteValidationWithExcess } from './route_validation'; + +describe('buildRouteValidationwithExcess', () => { + const schema = rt.type({ + ids: rt.array(rt.string), + }); + type Schema = rt.TypeOf; + + const deepSchema = rt.type({ + topLevel: rt.type({ + secondLevel: rt.type({ + thirdLevel: rt.string, + }), + }), + }); + type DeepSchema = rt.TypeOf; + + // t.strict({ name: A }) is an alias of t.exact(t.type({ name: A }))) + const strictSchema = rt.strict({ + requiredField: rt.string, + }); + type StrictSchema = rt.TypeOf; + const validationResult: RouteValidationResultFactory = { + ok: jest.fn().mockImplementation((validatedInput) => validatedInput), + badRequest: jest.fn().mockImplementation((e) => e), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('return validation error', () => { + const input: Omit & { id: string } = { id: 'someId' }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + + expect(result).toEqual('Invalid value {"id":"someId"}, excess properties: ["id"]'); + }); + + test('return validation error with intersection', () => { + const schemaI = rt.intersection([ + rt.type({ + ids: rt.array(rt.string), + }), + rt.partial({ + valid: rt.array(rt.string), + }), + ]); + type SchemaI = rt.TypeOf; + const input: Omit & { id: string } = { id: 'someId', valid: ['yes'] }; + const result = buildRouteValidationWithExcess(schemaI)(input, validationResult); + + expect(result).toEqual( + 'Invalid value {"id":"someId","valid":["yes"]}, excess properties: ["id"]' + ); + }); + + test('return NO validation error with a partial intersection', () => { + const schemaI = rt.intersection([ + rt.type({ + id: rt.array(rt.string), + }), + rt.partial({ + valid: rt.array(rt.string), + }), + ]); + const input = { id: ['someId'] }; + const result = buildRouteValidationWithExcess(schemaI)(input, validationResult); + + expect(result).toEqual({ id: ['someId'] }); + }); + + test('return validated input', () => { + const input: Schema = { ids: ['someId'] }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + + expect(result).toEqual(input); + }); + + test('returns validation error if given extra keys on input for an array', () => { + const input: Schema & { somethingExtra: string } = { + ids: ['someId'], + somethingExtra: 'hello', + }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + expect(result).toEqual( + 'Invalid value {"ids":["someId"],"somethingExtra":"hello"}, excess properties: ["somethingExtra"]' + ); + }); + + test('return validation input for a deep 3rd level object', () => { + const input: DeepSchema = { topLevel: { secondLevel: { thirdLevel: 'hello' } } }; + const result = buildRouteValidationWithExcess(deepSchema)(input, validationResult); + expect(result).toEqual(input); + }); + + test('return validation error for a deep 3rd level object that has an extra key value of "somethingElse"', () => { + const input: DeepSchema & { + topLevel: { secondLevel: { thirdLevel: string; somethingElse: string } }; + } = { + topLevel: { secondLevel: { thirdLevel: 'hello', somethingElse: 'extraKey' } }, + }; + const result = buildRouteValidationWithExcess(deepSchema)(input, validationResult); + expect(result).toEqual( + 'Invalid value {"topLevel":{"secondLevel":{"thirdLevel":"hello","somethingElse":"extraKey"}}}, excess properties: ["somethingElse"]' + ); + }); + test('return validation error for excess properties with ExactType', () => { + // Create an input object with a required field and an excess property + const input: StrictSchema & { excessProp: string } = { + requiredField: 'value', + excessProp: 'extra', + }; + const result = buildRouteValidationWithExcess(strictSchema)(input, validationResult); + + // Expect a validation error indicating the presence of an excess property + expect(result).toEqual( + 'Invalid value {"requiredField":"value","excessProp":"extra"}, excess properties: ["excessProp"]' + ); + }); +}); diff --git a/x-pack/plugins/infra/server/utils/route_validation.ts b/x-pack/plugins/infra/server/utils/route_validation.ts new file mode 100644 index 0000000000000..b3e82a949c811 --- /dev/null +++ b/x-pack/plugins/infra/server/utils/route_validation.ts @@ -0,0 +1,156 @@ +/* + * 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 type { + RouteValidationFunction, + RouteValidationResultFactory, + RouteValidationError, +} from '@kbn/core/server'; +import { either, fold } from 'fp-ts/lib/Either'; +import get from 'lodash/get'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { formatErrors } from '@kbn/securitysolution-io-ts-utils'; +import * as rt from 'io-ts'; + +type RequestValidationResult = + | { + value: T; + error?: undefined; + } + | { + value?: undefined; + error: RouteValidationError; + }; + +export const buildRouteValidationWithExcess = + < + T extends + | rt.InterfaceType + | GenericIntersectionC + | rt.PartialType + | rt.ExactC, + A = rt.TypeOf + >( + schema: T + ): RouteValidationFunction => + (inputValue: unknown, validationResult: RouteValidationResultFactory) => + pipe( + excess(schema).decode(inputValue), + fold>( + (errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); + +export type GenericIntersectionC = + | rt.IntersectionC<[any, any]> + | rt.IntersectionC<[any, any, any]> + | rt.IntersectionC<[any, any, any, any]> + | rt.IntersectionC<[any, any, any, any, any]>; + +export const excess = < + C extends + | rt.InterfaceType + | GenericIntersectionC + | rt.PartialType + | rt.ExactC +>( + codec: C +): C => { + const codecProps = getProps(codec); + + const r = new rt.InterfaceType( + codec.name, + codec.is, + (i, c) => + either.chain(rt.UnknownRecord.validate(i, c), (s) => { + if (codecProps == null) { + return rt.failure(i, c, 'unknown codec'); + } + + const ex = getExcessProps(codecProps, s); + + return ex.length > 0 + ? rt.failure( + i, + c, + `Invalid value ${JSON.stringify(i)}, excess properties: ${JSON.stringify(ex)}` + ) + : codec.validate(i, c); + }), + codec.encode, + codecProps + ); + + return r as C; +}; + +const getExcessProps = ( + props: rt.Props | rt.RecordC, + + r: any +): string[] => { + return Object.keys(r).reduce((acc, k) => { + const codecChildren = get(props, [k]); + const childrenProps = getProps(codecChildren); + const childrenObject = r[k] as Record; + if (codecChildren != null && childrenProps != null && codecChildren._tag === 'DictionaryType') { + const keys = Object.keys(childrenObject); + return [ + ...acc, + ...keys.reduce( + (kAcc, i) => [...kAcc, ...getExcessProps(childrenProps, childrenObject[i])], + [] + ), + ]; + } + if (codecChildren != null && childrenProps != null) { + return [...acc, ...getExcessProps(childrenProps, childrenObject)]; + } else if (codecChildren == null) { + return [...acc, k]; + } + return acc; + }, []); +}; + +const getProps = ( + codec: rt.HasProps | rt.RecordC | GenericIntersectionC | rt.ExactType +): rt.Props | null => { + if (codec == null) { + return null; + } + + switch (codec._tag) { + case 'DictionaryType': { + if (codec.codomain.props != null) { + return codec.codomain.props; + } + + const dTypes: rt.HasProps[] = codec.codomain.types; + + return dTypes.reduce((props, type) => Object.assign(props, getProps(type)), {}); + } + + case 'RefinementType': + case 'ReadonlyType': + return getProps(codec.type); + case 'ExactType': + return getProps(codec.type); + case 'InterfaceType': + case 'PartialType': + return codec.props; + case 'IntersectionType': { + const iTypes = codec.types as rt.HasProps[]; + return iTypes.reduce((props, type) => { + const typeProps = getProps(type); + return Object.assign(props, typeProps); + }, {}); + } + + default: + return null; + } +}; diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index ac5a2b3d15c1a..fcda9c0de77a1 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -79,10 +79,13 @@ "@kbn/profiling-data-access-plugin", "@kbn/core-http-request-handler-context-server", "@kbn/observability-get-padded-alert-time-range-util", + "@kbn/apm-data-access-plugin", "@kbn/ebt-tools", "@kbn/shared-ux-utility", "@kbn/management-settings-components-field-row", - "@kbn/core-ui-settings-browser" + "@kbn/core-ui-settings-browser", + "@kbn/core-saved-objects-api-server", + "@kbn/securitysolution-io-ts-utils" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/api_integration/apis/metrics_ui/config.ts b/x-pack/test/api_integration/apis/metrics_ui/config.ts index 5f335f116fefe..ffca87e276ef1 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/config.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/config.ts @@ -5,13 +5,61 @@ * 2.0. */ -import { FtrConfigProviderContext } from '@kbn/test'; +import { + ApmSynthtraceEsClient, + ApmSynthtraceKibanaClient, + createLogger, + LogLevel, +} from '@kbn/apm-synthtrace'; +import url from 'url'; +import { FtrConfigProviderContext, kbnTestConfig } from '@kbn/test'; +import { FtrProviderContext as InheritedFtrProviderContext } from '../../ftr_provider_context'; +import { InheritedServices } from './types'; -export default async function ({ readConfigFile }: FtrConfigProviderContext) { +interface MetricsUIConfig { + services: InheritedServices & { + apmSynthtraceEsClient: (context: InheritedFtrProviderContext) => Promise; + }; +} +export default async function createTestConfig({ + readConfigFile, +}: FtrConfigProviderContext): Promise { const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts')); - + const services = baseIntegrationTestsConfig.get('services'); return { ...baseIntegrationTestsConfig.getAll(), testFiles: [require.resolve('.')], + services: { + ...services, + apmSynthtraceEsClient: async (context: InheritedFtrProviderContext) => { + const servers = baseIntegrationTestsConfig.get('servers'); + + const kibanaServer = servers.kibana as url.UrlObject; + const kibanaServerUrl = url.format(kibanaServer); + const kibanaServerUrlWithAuth = url + .format({ + ...url.parse(kibanaServerUrl), + auth: `elastic:${kbnTestConfig.getUrlParts().password}`, + }) + .slice(0, -1); + + const kibanaClient = new ApmSynthtraceKibanaClient({ + target: kibanaServerUrlWithAuth, + logger: createLogger(LogLevel.debug), + }); + const kibanaVersion = await kibanaClient.fetchLatestApmPackageVersion(); + await kibanaClient.installApmPackage(kibanaVersion); + + return new ApmSynthtraceEsClient({ + client: context.getService('es'), + logger: createLogger(LogLevel.info), + version: kibanaVersion, + refreshAfterIndex: true, + }); + }, + }, }; } +export type CreateTestConfig = Awaited>; + +export type MetricsUIServices = CreateTestConfig['services']; diff --git a/x-pack/test/api_integration/apis/metrics_ui/helpers.ts b/x-pack/test/api_integration/apis/metrics_ui/helpers.ts new file mode 100644 index 0000000000000..b5375b8d70cab --- /dev/null +++ b/x-pack/test/api_integration/apis/metrics_ui/helpers.ts @@ -0,0 +1,90 @@ +/* + * 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 { apm, timerange } from '@kbn/apm-synthtrace-client'; +import { SuperTest, Test } from 'supertest'; + +export type KibanaSupertest = SuperTest; + +// generates traces, metrics for services +export function generateServicesData({ + from, + to, + instanceCount = 1, + servicesPerHost = 1, +}: { + from: string; + to: string; + instanceCount?: number; + servicesPerHost?: number; +}) { + const range = timerange(from, to); + const services = Array(instanceCount) + .fill(null) + .flatMap((_, hostIdx) => + Array(servicesPerHost) + .fill(null) + .map((__, serviceIdx) => + apm + .service({ + name: `service-${hostIdx}-${serviceIdx}`, + environment: 'production', + agentName: 'nodejs', + }) + .instance(`host-${hostIdx}`) + ) + ); + return range + .interval('1m') + .rate(1) + .generator((timestamp, index) => + services.map((service) => + service + .transaction({ transactionName: 'GET /foo' }) + .timestamp(timestamp) + .duration(500) + .success() + ) + ); +} +// generates error logs only for services +export function generateServicesLogsOnlyData({ + from, + to, + instanceCount = 1, + servicesPerHost = 1, +}: { + from: string; + to: string; + instanceCount?: number; + servicesPerHost?: number; +}) { + const range = timerange(from, to); + const services = Array(instanceCount) + .fill(null) + .flatMap((_, hostIdx) => + Array(servicesPerHost) + .fill(null) + .map((__, serviceIdx) => + apm + .service({ + name: `service-${hostIdx}-${serviceIdx}`, + environment: 'production', + agentName: 'go', + }) + .instance(`host-${hostIdx}`) + ) + ); + return range + .interval('1m') + .rate(1) + .generator((timestamp, index) => + services.map((service) => + service.error({ message: 'error', type: 'My Type' }).timestamp(timestamp) + ) + ); +} diff --git a/x-pack/test/api_integration/apis/metrics_ui/index.js b/x-pack/test/api_integration/apis/metrics_ui/index.js index 8a4b7d3398d1c..ddc444c0e2a70 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/index.js +++ b/x-pack/test/api_integration/apis/metrics_ui/index.js @@ -24,5 +24,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./infra_log_analysis_validation_log_entry_datasets')); loadTestFile(require.resolve('./infra')); loadTestFile(require.resolve('./inventory_threshold_alert')); + loadTestFile(require.resolve('./services')); }); } diff --git a/x-pack/test/api_integration/apis/metrics_ui/services.ts b/x-pack/test/api_integration/apis/metrics_ui/services.ts new file mode 100644 index 0000000000000..c738ecfed673c --- /dev/null +++ b/x-pack/test/api_integration/apis/metrics_ui/services.ts @@ -0,0 +1,110 @@ +/* + * 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 expect from '@kbn/expect'; +import { ServicesAPIResponseRT } from '@kbn/infra-plugin/common/http_api/host_details'; +import { decodeOrThrow } from '@kbn/infra-plugin/common/runtime_types'; +import { FtrProviderContext } from './types'; +import { generateServicesData, generateServicesLogsOnlyData } from './helpers'; + +const SERVICES_ENDPOINT = '/api/infra/services'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const synthtrace = getService('apmSynthtraceEsClient'); + describe('GET /infra/services', () => { + const from = new Date(Date.now() - 1000 * 60 * 2).toISOString(); + const to = new Date().toISOString(); + describe('with transactions', () => { + before(async () => { + await synthtrace.index( + generateServicesData({ from, to, instanceCount: 3, servicesPerHost: 3 }) + ); + }); + after(() => synthtrace.clean()); + it('returns no services with no data', async () => { + const filters = JSON.stringify({ + 'host.name': 'some-host', + }); + + const response = await supertest + .get(SERVICES_ENDPOINT) + .set({ + 'kbn-xsrf': 'some-xsrf-token', + }) + .query({ + filters, + from, + to, + }) + .expect(200); + + const { services } = decodeOrThrow(ServicesAPIResponseRT)(response.body); + expect(services.length).to.be(0); + }); + it('should return correct number of services running on specified host', async () => { + const filters = JSON.stringify({ + 'host.name': 'host-0', + }); + const response = await supertest + .get(SERVICES_ENDPOINT) + .set({ + 'kbn-xsrf': 'some-xsrf-token', + }) + .query({ + filters, + from, + to, + }) + .expect(200); + expect(response.body.services.length).to.equal(3); + }); + it('should return bad request if unallowed filter', async () => { + const filters = JSON.stringify({ + 'host.name': 'host-0', + 'agent.name': 'nodejs', + }); + await supertest + .get(SERVICES_ENDPOINT) + .set({ + 'kbn-xsrf': 'some-xsrf-token', + }) + .query({ + filters, + from, + to, + }) + .expect(400); + }); + }); + describe('with logs only', () => { + before(async () => { + await synthtrace.index( + generateServicesLogsOnlyData({ from, to, instanceCount: 1, servicesPerHost: 2 }) + ); + }); + after(() => synthtrace.clean()); + it('should return services with logs only data', async () => { + const filters = JSON.stringify({ + 'host.name': 'host-0', + }); + const response = await supertest + .get(SERVICES_ENDPOINT) + .set({ + 'kbn-xsrf': 'some-xsrf-token', + }) + .query({ + filters, + from, + to, + }) + .expect(200); + expect(response.body.services.length).to.equal(2); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/metrics_ui/types.ts b/x-pack/test/api_integration/apis/metrics_ui/types.ts new file mode 100644 index 0000000000000..c0e4a84741bd2 --- /dev/null +++ b/x-pack/test/api_integration/apis/metrics_ui/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { GenericFtrProviderContext } from '@kbn/test'; +import { FtrProviderContext as InheritedFtrProviderContext } from '../../ftr_provider_context'; +import { MetricsUIServices } from './config'; + +export type InheritedServices = InheritedFtrProviderContext extends GenericFtrProviderContext< + infer TServices, + {} +> + ? TServices + : {}; + +export type FtrProviderContext = GenericFtrProviderContext;