mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* [APM] Include services with only metric documents (#92378) * [APM] Include services with only metric documents Closes #92075. * Explain as_mutable_array * Use kuery instead of uiFilters for API tests # Conflicts: # x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts * Use uiFilters in API tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
df5e547393
commit
ad86060730
6 changed files with 263 additions and 15 deletions
41
x-pack/plugins/apm/common/utils/as_mutable_array.ts
Normal file
41
x-pack/plugins/apm/common/utils/as_mutable_array.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Sometimes we use `as const` to have a more specific type,
|
||||
// because TypeScript by default will widen the value type of an
|
||||
// array literal. Consider the following example:
|
||||
//
|
||||
// const filter = [
|
||||
// { term: { 'agent.name': 'nodejs' } },
|
||||
// { range: { '@timestamp': { gte: 'now-15m ' }}
|
||||
// ];
|
||||
|
||||
// The result value type will be:
|
||||
|
||||
// const filter: ({
|
||||
// term: {
|
||||
// 'agent.name'?: string
|
||||
// };
|
||||
// range?: undefined
|
||||
// } | {
|
||||
// term?: undefined;
|
||||
// range: {
|
||||
// '@timestamp': {
|
||||
// gte: string
|
||||
// }
|
||||
// }
|
||||
// })[];
|
||||
|
||||
// This can sometimes leads to issues. In those cases, we can
|
||||
// use `as const`. However, the Readonly<any> type is not compatible
|
||||
// with Array<any>. This function returns a mutable version of a type.
|
||||
|
||||
export function asMutableArray<T extends Readonly<any>>(
|
||||
arr: T
|
||||
): T extends Readonly<[...infer U]> ? U : unknown[] {
|
||||
return arr as any;
|
||||
}
|
|
@ -109,7 +109,6 @@ Array [
|
|||
"environments": Object {
|
||||
"terms": Object {
|
||||
"field": "service.environment",
|
||||
"missing": "",
|
||||
},
|
||||
},
|
||||
"outcomes": Object {
|
||||
|
@ -193,6 +192,61 @@ Array [
|
|||
"size": 0,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"metric",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"services": Object {
|
||||
"aggs": Object {
|
||||
"environments": Object {
|
||||
"terms": Object {
|
||||
"field": "service.environment",
|
||||
},
|
||||
},
|
||||
"latest": Object {
|
||||
"top_metrics": Object {
|
||||
"metrics": Object {
|
||||
"field": "agent.name",
|
||||
},
|
||||
"sort": Object {
|
||||
"@timestamp": "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"terms": Object {
|
||||
"field": "service.name",
|
||||
"size": 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 1528113600000,
|
||||
"lte": 1528977600000,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"service.environment": "test",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
|
|
|
@ -35,14 +35,14 @@ interface AggregationParams {
|
|||
environment?: string;
|
||||
setup: ServicesItemsSetup;
|
||||
searchAggregatedTransactions: boolean;
|
||||
maxNumServices: number;
|
||||
}
|
||||
|
||||
const MAX_NUMBER_OF_SERVICES = 500;
|
||||
|
||||
export async function getServiceTransactionStats({
|
||||
environment,
|
||||
setup,
|
||||
searchAggregatedTransactions,
|
||||
maxNumServices,
|
||||
}: AggregationParams) {
|
||||
return withApmSpan('get_service_transaction_stats', async () => {
|
||||
const { apmEventClient, start, end, esFilter } = setup;
|
||||
|
@ -86,7 +86,7 @@ export async function getServiceTransactionStats({
|
|||
services: {
|
||||
terms: {
|
||||
field: SERVICE_NAME,
|
||||
size: MAX_NUMBER_OF_SERVICES,
|
||||
size: maxNumServices,
|
||||
},
|
||||
aggs: {
|
||||
transactionType: {
|
||||
|
@ -98,7 +98,6 @@ export async function getServiceTransactionStats({
|
|||
environments: {
|
||||
terms: {
|
||||
field: SERVICE_ENVIRONMENT,
|
||||
missing: '',
|
||||
},
|
||||
},
|
||||
sample: {
|
||||
|
@ -141,9 +140,9 @@ export async function getServiceTransactionStats({
|
|||
return {
|
||||
serviceName: bucket.key as string,
|
||||
transactionType: topTransactionTypeBucket.key as string,
|
||||
environments: topTransactionTypeBucket.environments.buckets
|
||||
.map((environmentBucket) => environmentBucket.key as string)
|
||||
.filter(Boolean),
|
||||
environments: topTransactionTypeBucket.environments.buckets.map(
|
||||
(environmentBucket) => environmentBucket.key as string
|
||||
),
|
||||
agentName: topTransactionTypeBucket.sample.top[0].metrics[
|
||||
AGENT_NAME
|
||||
] as AgentName,
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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 { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
|
||||
import {
|
||||
AGENT_NAME,
|
||||
SERVICE_ENVIRONMENT,
|
||||
SERVICE_NAME,
|
||||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { environmentQuery, rangeQuery } from '../../../../common/utils/queries';
|
||||
import { ProcessorEvent } from '../../../../common/processor_event';
|
||||
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
|
||||
import { withApmSpan } from '../../../utils/with_apm_span';
|
||||
|
||||
export function getServicesFromMetricDocuments({
|
||||
environment,
|
||||
setup,
|
||||
maxNumServices,
|
||||
kuery,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange;
|
||||
environment?: string;
|
||||
maxNumServices: number;
|
||||
kuery?: string;
|
||||
}) {
|
||||
return withApmSpan('get_services_from_metric_documents', async () => {
|
||||
const { apmEventClient, start, end, esFilter } = setup;
|
||||
|
||||
const response = await apmEventClient.search({
|
||||
apm: {
|
||||
events: [ProcessorEvent.metric],
|
||||
},
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...rangeQuery(start, end),
|
||||
...environmentQuery(environment),
|
||||
...esFilter,
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
services: {
|
||||
terms: {
|
||||
field: SERVICE_NAME,
|
||||
size: maxNumServices,
|
||||
},
|
||||
aggs: {
|
||||
environments: {
|
||||
terms: {
|
||||
field: SERVICE_ENVIRONMENT,
|
||||
},
|
||||
},
|
||||
latest: {
|
||||
top_metrics: {
|
||||
metrics: { field: AGENT_NAME } as const,
|
||||
sort: { '@timestamp': 'desc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
response.aggregations?.services.buckets.map((bucket) => {
|
||||
return {
|
||||
serviceName: bucket.key as string,
|
||||
environments: bucket.environments.buckets.map(
|
||||
(envBucket) => envBucket.key as string
|
||||
),
|
||||
agentName: bucket.latest.top[0].metrics[AGENT_NAME] as AgentName,
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
}
|
|
@ -6,15 +6,19 @@
|
|||
*/
|
||||
|
||||
import { Logger } from '@kbn/logging';
|
||||
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
|
||||
import { joinByKey } from '../../../../common/utils/join_by_key';
|
||||
import { getServicesProjection } from '../../../projections/services';
|
||||
import { withApmSpan } from '../../../utils/with_apm_span';
|
||||
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
|
||||
import { getHealthStatuses } from './get_health_statuses';
|
||||
import { getServicesFromMetricDocuments } from './get_services_from_metric_documents';
|
||||
import { getServiceTransactionStats } from './get_service_transaction_stats';
|
||||
import { getServicesProjection } from '../../../projections/services';
|
||||
|
||||
export type ServicesItemsSetup = Setup & SetupTimeRange;
|
||||
|
||||
const MAX_NUMBER_OF_SERVICES = 500;
|
||||
|
||||
export async function getServicesItems({
|
||||
environment,
|
||||
setup,
|
||||
|
@ -35,26 +39,47 @@ export async function getServicesItems({
|
|||
}),
|
||||
setup,
|
||||
searchAggregatedTransactions,
|
||||
maxNumServices: MAX_NUMBER_OF_SERVICES,
|
||||
};
|
||||
|
||||
const [transactionStats, healthStatuses] = await Promise.all([
|
||||
const [
|
||||
transactionStats,
|
||||
servicesFromMetricDocuments,
|
||||
healthStatuses,
|
||||
] = await Promise.all([
|
||||
getServiceTransactionStats(params),
|
||||
getServicesFromMetricDocuments(params),
|
||||
getHealthStatuses(params).catch((err) => {
|
||||
logger.error(err);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
const apmServices = transactionStats.map(({ serviceName }) => serviceName);
|
||||
const foundServiceNames = transactionStats.map(
|
||||
({ serviceName }) => serviceName
|
||||
);
|
||||
|
||||
const servicesWithOnlyMetricDocuments = servicesFromMetricDocuments.filter(
|
||||
({ serviceName }) => !foundServiceNames.includes(serviceName)
|
||||
);
|
||||
|
||||
const allServiceNames = foundServiceNames.concat(
|
||||
servicesWithOnlyMetricDocuments.map(({ serviceName }) => serviceName)
|
||||
);
|
||||
|
||||
// make sure to exclude health statuses from services
|
||||
// that are not found in APM data
|
||||
const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) =>
|
||||
apmServices.includes(serviceName)
|
||||
allServiceNames.includes(serviceName)
|
||||
);
|
||||
|
||||
const allMetrics = [...transactionStats, ...matchedHealthStatuses];
|
||||
|
||||
return joinByKey(allMetrics, 'serviceName');
|
||||
return joinByKey(
|
||||
asMutableArray([
|
||||
...transactionStats,
|
||||
...servicesWithOnlyMetricDocuments,
|
||||
...matchedHealthStatuses,
|
||||
] as const),
|
||||
'serviceName'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -261,6 +261,51 @@ export default function ApiTest({ getService }: FtrProviderContext) {
|
|||
}
|
||||
);
|
||||
|
||||
registry.when(
|
||||
'APM Services Overview with a basic license when data is loaded excluding transaction events',
|
||||
{ config: 'basic', archives: [archiveName] },
|
||||
() => {
|
||||
it('includes services that only report metric data', async () => {
|
||||
interface Response {
|
||||
status: number;
|
||||
body: APIReturnType<'GET /api/apm/services'>;
|
||||
}
|
||||
|
||||
const [unfilteredResponse, filteredResponse] = await Promise.all([
|
||||
supertest.get(
|
||||
`/api/apm/services?start=${start}&end=${end}&uiFilters={}`
|
||||
) as Promise<Response>,
|
||||
supertest.get(
|
||||
`/api/apm/services?start=${start}&end=${end}&uiFilters=${encodeURIComponent(
|
||||
'{ "kuery": "not (processor.event:transaction)" }'
|
||||
)}`
|
||||
) as Promise<Response>,
|
||||
]);
|
||||
|
||||
expect(unfilteredResponse.body.items.length).to.be.greaterThan(0);
|
||||
|
||||
const unfilteredServiceNames = unfilteredResponse.body.items
|
||||
.map((item) => item.serviceName)
|
||||
.sort();
|
||||
|
||||
const filteredServiceNames = filteredResponse.body.items
|
||||
.map((item) => item.serviceName)
|
||||
.sort();
|
||||
|
||||
expect(unfilteredServiceNames).to.eql(filteredServiceNames);
|
||||
|
||||
expect(
|
||||
filteredResponse.body.items.every((item) => {
|
||||
// make sure it did not query transaction data
|
||||
return isEmpty(item.avgResponseTime);
|
||||
})
|
||||
).to.be(true);
|
||||
|
||||
expect(filteredResponse.body.items.every((item) => !!item.agentName)).to.be(true);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
registry.when(
|
||||
'APM Services overview with a trial license when data is loaded',
|
||||
{ config: 'trial', archives: [archiveName] },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue