From 78643f495c925154bb20839b3d12f241cb4ffe66 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 31 Jan 2022 11:54:34 +0000 Subject: [PATCH] APM UI changes for serverless services / AWS lambda (#122775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adapted service UI for AWS lambda / serverless services * Add unit tests for isServerlessAgent function * Add story for cold start badge * Add unit tests for service icons and icon details * Add aws_lambda checks to isMetricsTabHidden and isJVMsTabHidden unit tests * Add API test for coldstart_rate chart * Change service icon API tests to use synthrace and test for serverless property * Change service details API tests to use synthrace and add test for serverless * Add e2e tests for cold start rate chart * Add cold start badge to transaction flyout * Add beta badge to cloud details in a lambda context * Add support for multiple lambda functions in a single service Co-authored-by: Alexander Wert Co-authored-by: Casper Hübertz --- .../src/lib/apm/apm_fields.ts | 16 ++ .../lib/apm/utils/get_transaction_metrics.ts | 14 ++ .../elasticsearch_fieldnames.test.ts.snap | 24 ++ x-pack/plugins/apm/common/agent_name.test.ts | 33 ++- x-pack/plugins/apm/common/agent_name.ts | 4 + .../apm/common/elasticsearch_fieldnames.ts | 5 + .../aws_lambda/aws_lamba.spec.ts | 53 +++++ .../aws_lambda/generate_data.ts | 41 ++++ .../components/app/service_overview/index.tsx | 44 +++- .../app/transaction_details/index.tsx | 11 +- .../badge/cold_start_badge.stories.tsx | 18 ++ .../waterfall/badge/cold_start_badge.tsx | 20 ++ .../{ => badge}/sync_badge.stories.tsx | 2 +- .../waterfall/{ => badge}/sync_badge.test.tsx | 0 .../waterfall/{ => badge}/sync_badge.tsx | 2 +- .../waterfall/span_flyout/index.tsx | 2 +- .../waterfall/transaction_flyout/index.tsx | 1 + .../waterfall/waterfall_item.tsx | 6 +- .../waterfall_container.stories.data.ts | 3 + .../waterfall_container.stories.tsx | 11 +- .../app/transaction_overview/index.tsx | 10 +- .../apm_service_template/index.test.tsx | 2 + .../templates/apm_service_template/index.tsx | 9 +- .../charts/transaction_charts/index.tsx | 32 ++- .../index.tsx | 184 ++++++++++++++++ .../shared/service_icons/cloud_details.tsx | 72 +++++- .../shared/service_icons/index.test.tsx | 116 ++++++++++ .../components/shared/service_icons/index.tsx | 21 +- .../service_icons/serverless_details.tsx | 73 ++++++ .../shared/summary/transaction_summary.tsx | 10 +- .../lib/helpers/transaction_coldstart_rate.ts | 67 ++++++ .../transaction_groups/get_coldstart_rate.ts | 181 +++++++++++++++ .../services/get_service_metadata_details.ts | 58 +++++ .../services/get_service_metadata_icons.ts | 17 +- .../apm/server/routes/transactions/route.ts | 155 +++++++++++++ .../typings/es_schemas/raw/fields/cloud.ts | 3 + .../apm/typings/es_schemas/raw/fields/faas.ts | 16 ++ .../typings/es_schemas/raw/transaction_raw.ts | 2 + .../tests/cold_start/cold_start.spec.ts | 205 +++++++++++++++++ .../cold_start_by_transaction_name.spec.ts | 207 ++++++++++++++++++ .../generate_data.ts | 64 ++++++ .../tests/cold_start/generate_data.ts | 70 ++++++ .../tests/services/service_details.spec.ts | 135 ------------ .../services/service_details/generate_data.ts | 130 +++++++++++ .../service_details/service_details.spec.ts | 128 +++++++++++ .../tests/services/service_icons.spec.ts | 77 ------- .../services/service_icons/generate_data.ts | 56 +++++ .../service_icons/service_icons.spec.ts | 77 +++++++ 48 files changed, 2240 insertions(+), 247 deletions(-) create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.stories.tsx (94%) rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/{ => badge}/sync_badge.tsx (94%) create mode 100644 x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx create mode 100644 x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts create mode 100644 x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts delete mode 100644 x-pack/test/apm_api_integration/tests/services/service_details.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts delete mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts index e0a48fdcf2b895..4afebf0352a6ad 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -63,8 +63,12 @@ export type ApmFields = Fields & }; 'transaction.sampled': true; 'service.name': string; + 'service.version': string; 'service.environment': string; 'service.node.name': string; + 'service.runtime.name': string; + 'service.runtime.version': string; + 'service.framework.name': string; 'span.id': string; 'span.name': string; 'span.type': string; @@ -77,5 +81,17 @@ export type ApmFields = Fields & 'span.destination.service.response_time.count': number; 'span.self_time.count': number; 'span.self_time.sum.us': number; + 'cloud.provider': string; + 'cloud.project.name': string; + 'cloud.service.name': string; + 'cloud.availability_zone': string; + 'cloud.machine.type': string; + 'cloud.region': string; + 'host.os.platform': string; + 'faas.id': string; + 'faas.coldstart': boolean; + 'faas.execution': string; + 'faas.trigger.type': string; + 'faas.trigger.request_id': string; }> & ApmApplicationMetricFields; diff --git a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts index 8545ae65d8aa0f..baa9f57a19a465 100644 --- a/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts +++ b/packages/elastic-apm-synthtrace/src/lib/apm/utils/get_transaction_metrics.ts @@ -51,6 +51,20 @@ export function getTransactionMetrics(events: ApmFields[]) { 'host.name', 'container.id', 'kubernetes.pod.name', + 'cloud.account.id', + 'cloud.account.name', + 'cloud.machine.type', + 'cloud.project.id', + 'cloud.project.name', + 'cloud.service.name', + 'service.language.name', + 'service.language.version', + 'service.runtime.name', + 'service.runtime.version', + 'host.os.platform', + 'faas.id', + 'faas.coldstart', + 'faas.trigger.type', ]); return metricsets.map((metricset) => { diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 5dd35886741798..9b255a87df39ed 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -37,6 +37,8 @@ exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Error CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Error CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Error CLS_FIELD 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -63,6 +65,12 @@ exports[`Error ERROR_PAGE_URL 1`] = `undefined`; exports[`Error EVENT_OUTCOME 1`] = `undefined`; +exports[`Error FAAS_COLDSTART 1`] = `undefined`; + +exports[`Error FAAS_ID 1`] = `undefined`; + +exports[`Error FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Error FCP_FIELD 1`] = `undefined`; exports[`Error FID_FIELD 1`] = `undefined`; @@ -282,6 +290,8 @@ exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Span CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Span CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Span CLS_FIELD 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -308,6 +318,12 @@ exports[`Span ERROR_PAGE_URL 1`] = `undefined`; exports[`Span EVENT_OUTCOME 1`] = `"unknown"`; +exports[`Span FAAS_COLDSTART 1`] = `undefined`; + +exports[`Span FAAS_ID 1`] = `undefined`; + +exports[`Span FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Span FCP_FIELD 1`] = `undefined`; exports[`Span FID_FIELD 1`] = `undefined`; @@ -519,6 +535,8 @@ exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`; +exports[`Transaction CLOUD_SERVICE_NAME 1`] = `undefined`; + exports[`Transaction CLS_FIELD 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -545,6 +563,12 @@ exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`; exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`; +exports[`Transaction FAAS_COLDSTART 1`] = `undefined`; + +exports[`Transaction FAAS_ID 1`] = `undefined`; + +exports[`Transaction FAAS_TRIGGER_TYPE 1`] = `undefined`; + exports[`Transaction FCP_FIELD 1`] = `undefined`; exports[`Transaction FID_FIELD 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/agent_name.test.ts b/x-pack/plugins/apm/common/agent_name.test.ts index 162a5716d6c7b1..e48fa502d33d19 100644 --- a/x-pack/plugins/apm/common/agent_name.test.ts +++ b/x-pack/plugins/apm/common/agent_name.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { isJavaAgentName, isRumAgentName, isIosAgentName } from './agent_name'; +import { + isJavaAgentName, + isRumAgentName, + isIosAgentName, + isServerlessAgent, +} from './agent_name'; describe('agent name helpers', () => { describe('isJavaAgentName', () => { @@ -79,4 +84,30 @@ describe('agent name helpers', () => { }); }); }); + + describe('isServerlessAgent', () => { + describe('when the runtime name is AWS_LAMBDA', () => { + it('returns true', () => { + expect(isServerlessAgent('AWS_LAMBDA')).toEqual(true); + }); + }); + + describe('when the runtime name is aws_lambda', () => { + it('returns true', () => { + expect(isServerlessAgent('aws_lambda')).toEqual(true); + }); + }); + + describe('when the runtime name is aws_lambda_test', () => { + it('returns true', () => { + expect(isServerlessAgent('aws_lambda_test')).toEqual(true); + }); + }); + + describe('when the runtime name is something else', () => { + it('returns false', () => { + expect(isServerlessAgent('not_aws_lambda')).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index b41ae949d5867e..e8947d550a8fc2 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -90,3 +90,7 @@ export function isIosAgentName(agentName?: string) { export function isJRubyAgent(agentName?: string, runtimeName?: string) { return agentName === 'ruby' && runtimeName?.toLowerCase() === 'jruby'; } + +export function isServerlessAgent(runtimeName?: string) { + return runtimeName?.toLowerCase().startsWith('aws_lambda'); +} diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 5c7c953d8d900a..f0ea5b6cb116e9 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -13,6 +13,7 @@ export const CLOUD_MACHINE_TYPE = 'cloud.machine.type'; export const CLOUD_ACCOUNT_ID = 'cloud.account.id'; export const CLOUD_INSTANCE_ID = 'cloud.instance.id'; export const CLOUD_INSTANCE_NAME = 'cloud.instance.name'; +export const CLOUD_SERVICE_NAME = 'cloud.service.name'; export const SERVICE = 'service'; export const SERVICE_NAME = 'service.name'; @@ -152,3 +153,7 @@ export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count'; export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes'; export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count'; export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes'; + +export const FAAS_ID = 'faas.id'; +export const FAAS_COLDSTART = 'faas.coldstart'; +export const FAAS_TRIGGER_TYPE = 'faas.trigger.type'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts new file mode 100644 index 00000000000000..518cfefde2fb1e --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/aws_lamba.spec.ts @@ -0,0 +1,53 @@ +/* + * 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 url from 'url'; +import { synthtrace } from '../../../../../synthtrace'; +import { generateData } from './generate_data'; + +const start = '2021-10-10T00:00:00.000Z'; +const end = '2021-10-10T00:15:00.000Z'; + +const serviceOverviewHref = url.format({ + pathname: '/app/apm/services/synth-python/overview', + query: { rangeFrom: start, rangeTo: end }, +}); + +const apiToIntercept = { + endpoint: + '/internal/apm/services/synth-python/transactions/charts/coldstart_rate?*', + name: 'coldStartRequest', +}; + +describe('Service overview - aws lambda', () => { + before(async () => { + await synthtrace.index( + generateData({ + start: new Date(start).getTime(), + end: new Date(end).getTime(), + }) + ); + }); + + after(async () => { + await synthtrace.clean(); + }); + + beforeEach(() => { + cy.loginAsReadOnlyUser(); + }); + + it('displays a cold start rate chart and not a transaction breakdown chart', () => { + const { endpoint, name } = apiToIntercept; + + cy.intercept('GET', endpoint).as(name); + cy.visit(serviceOverviewHref); + cy.wait(`@${name}`); + + cy.contains('Cold start rate'); + cy.contains('Time spent by span type').should('not.exist'); + }); +}); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts new file mode 100644 index 00000000000000..2dba10e8e517e7 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/aws_lambda/generate_data.ts @@ -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. + */ +import { apm, timerange } from '@elastic/apm-synthtrace'; + +const dataConfig = { + serviceName: 'synth-python', + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, +}; + +export function generateData({ start, end }: { start: number; end: number }) { + const { rate, transaction, serviceName } = dataConfig; + const instance = apm + .service(serviceName, 'production', 'python') + .instance('instance-a'); + + const traceEvents = timerange(start, end) + .interval('1m') + .rate(rate) + .flatMap((timestamp) => [ + ...instance + .transaction(transaction.name) + .defaults({ + 'service.runtime.name': 'AWS_Lambda_python3.8', + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(transaction.duration) + .success() + .serialize(), + ]); + + return traceEvents; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 2c30027770f435..3c2db540887702 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -9,13 +9,18 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { isRumAgentName, isIosAgentName } from '../../../../common/agent_name'; +import { + isRumAgentName, + isIosAgentName, + isServerlessAgent, +} from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; +import { TransactionColdstartRateChart } from '../../shared/charts/transaction_coldstart_rate_chart'; import { FailedTransactionRateChart } from '../../shared/charts/failed_transaction_rate_chart'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; @@ -35,8 +40,13 @@ import { replace } from '../../shared/links/url_helpers'; export const chartHeight = 288; export function ServiceOverview() { - const { agentName, serviceName, transactionType, fallbackToTransactions } = - useApmServiceContext(); + const { + agentName, + serviceName, + transactionType, + fallbackToTransactions, + runtimeName, + } = useApmServiceContext(); const { query, query: { @@ -69,7 +79,7 @@ export function ServiceOverview() { const rowDirection = isSingleColumn ? 'column' : 'row'; const isRumAgent = isRumAgentName(agentName); const isIosAgent = isIosAgentName(agentName); - + const isServerless = isServerlessAgent(runtimeName); const router = useApmRouter(); const dependenciesLink = router.link('/services/{serviceName}/dependencies', { path: { @@ -152,13 +162,23 @@ export function ServiceOverview() { gutterSize="s" responsive={false} > - - - + {isServerless ? ( + + + + ) : ( + + + + )} {!isRumAgent && ( @@ -180,7 +200,7 @@ export function ServiceOverview() { )} - {!isRumAgent && !isIosAgent && ( + {!isRumAgent && !isIosAgent && !isServerless && ( {fallbackToTransactions && } @@ -66,6 +72,9 @@ export function TransactionDetails() { start={start} end={end} transactionName={transactionName} + isServerlessContext={isServerless} + comparisonEnabled={comparisonEnabled} + comparisonType={comparisonType} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx new file mode 100644 index 00000000000000..50af7cc42435b1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.stories.tsx @@ -0,0 +1,18 @@ +/* + * 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 React from 'react'; +import { ColdStartBadge } from './cold_start_badge'; + +export default { + title: 'app/TransactionDetails/Waterfall/Badge/ColdStartBadge', + component: ColdStartBadge, +}; + +export function Example() { + return ; +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx new file mode 100644 index 00000000000000..dfe38aab5021d1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge.tsx @@ -0,0 +1,20 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function ColdStartBadge() { + return ( + + {i18n.translate('xpack.apm.transactionDetails.coldstartBadge', { + defaultMessage: 'cold start', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx index dea05961c4cef5..7209203b54cc01 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { SyncBadge, SyncBadgeProps } from './sync_badge'; export default { - title: 'app/TransactionDetails/SyncBadge', + title: 'app/TransactionDetails/Waterfall/Badge/SyncBadge', component: SyncBadge, argTypes: { sync: { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.test.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx rename to x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx index a51d710bf39616..85571b9065c4d7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/sync_badge.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/sync_badge.tsx @@ -8,7 +8,7 @@ import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent'; +import { AgentName } from '../../../../../../../../typings/es_schemas/ui/fields/agent'; export interface SyncBadgeProps { /** diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx index 0087b0f9d1facb..0f7a6a295601bb 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/index.tsx @@ -34,7 +34,7 @@ import { DurationSummaryItem } from '../../../../../../shared/summary/duration_s import { HttpInfoSummaryItem } from '../../../../../../shared/summary/http_info_summary_item'; import { TimestampTooltip } from '../../../../../../shared/timestamp_tooltip'; import { ResponsiveFlyout } from '../responsive_flyout'; -import { SyncBadge } from '../sync_badge'; +import { SyncBadge } from '../badge/sync_badge'; import { SpanDatabase } from './span_db'; import { StickySpanProperties } from './sticky_span_properties'; import { FailureBadge } from '../failure_badge'; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx index 5f1e0cacd8483c..43a7ebfa2f97ff 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/transaction_flyout/index.tsx @@ -87,6 +87,7 @@ export function TransactionFlyout({ transaction={transactionDoc} totalDuration={rootTransactionDuration} errorCount={errorCount} + coldStartBadge={transactionDoc.faas?.coldstart} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx index d8942cab36f77c..51e9cbeaba6f7a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx @@ -18,7 +18,8 @@ import { import { asDuration } from '../../../../../../../common/utils/formatters'; import { Margins } from '../../../../../shared/charts/timeline'; import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip'; -import { SyncBadge } from './sync_badge'; +import { SyncBadge } from './badge/sync_badge'; +import { ColdStartBadge } from './badge/cold_start_badge'; import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers'; import { FailureBadge } from './failure_badge'; import { useApmRouter } from '../../../../../../hooks/use_apm_router'; @@ -200,6 +201,8 @@ export function WaterfallItem({ const isCompositeSpan = item.docType === 'span' && item.doc.span.composite; const itemBarStyle = getItemBarStyle(item, color, width, left); + const isServerlessColdstart = + item.docType === 'transaction' && item.doc.faas?.coldstart; return ( )} + {isServerlessColdstart && } ); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts index f3331fba0ca23a..d4af0e92c90548 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.data.ts @@ -226,6 +226,9 @@ export const simpleTrace = { timestamp: { us: 1584975868787052, }, + faas: { + coldstart: true, + }, }, { parent: { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx index 312412a8cf8273..db82e9e360207e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall_container.stories.tsx @@ -19,9 +19,18 @@ import { traceWithErrors, urlParams as testUrlParams, } from './waterfall_container.stories.data'; +import type { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; type Args = ComponentProps; +const apmPluginContextMock = { + core: { + http: { + basePath: { prepend: () => {} }, + }, + }, +} as unknown as ApmPluginContextValue; + const stories: Meta = { title: 'app/TransactionDetails/waterfall', component: WaterfallContainer, @@ -32,7 +41,7 @@ const stories: Meta = { '/services/{serviceName}/transactions/view?rangeFrom=now-15m&rangeTo=now&transactionName=testTransactionName', ]} > - + diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 39d522ca088fcf..68315fc3b2b021 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -15,6 +15,7 @@ import { AggregatedTransactionsBadge } from '../../shared/aggregated_transaction import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { replace } from '../../shared/links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; +import { isServerlessAgent } from '../../../../common/agent_name'; export function TransactionOverview() { const { @@ -24,12 +25,14 @@ export function TransactionOverview() { rangeFrom, rangeTo, transactionType: transactionTypeFromUrl, + comparisonEnabled, + comparisonType, }, } = useApmParams('/services/{serviceName}/transactions'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { transactionType, serviceName, fallbackToTransactions } = + const { transactionType, serviceName, fallbackToTransactions, runtimeName } = useApmServiceContext(); const history = useHistory(); @@ -45,6 +48,8 @@ export function TransactionOverview() { return null; } + const isServerless = isServerlessAgent(runtimeName); + return ( <> {fallbackToTransactions && ( @@ -62,6 +67,9 @@ export function TransactionOverview() { environment={environment} start={start} end={end} + isServerlessContext={isServerless} + comparisonEnabled={comparisonEnabled} + comparisonType={comparisonType} /> diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx index 5a481b2d6f10c1..03a50d6082583c 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.test.tsx @@ -19,6 +19,7 @@ describe('APM service template', () => { { agentName: 'ios/swift' }, { agentName: 'opentelemetry/swift' }, { agentName: 'ruby', runtimeName: 'jruby' }, + { runtimeName: 'aws_lambda' }, ].map((input) => { it(`when input ${JSON.stringify(input)}`, () => { expect(isMetricsTabHidden(input)).toBeTruthy(); @@ -52,6 +53,7 @@ describe('APM service template', () => { { agentName: 'nodejs' }, { agentName: 'php' }, { agentName: 'python' }, + { runtimeName: 'aws_lambda' }, ].map((input) => { it(`when input ${JSON.stringify(input)}`, () => { expect(isJVMsTabHidden(input)).toBeTruthy(); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index 93c222164f0260..20f907e03fc370 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -21,6 +21,7 @@ import { isJavaAgentName, isJRubyAgent, isRumAgentName, + isServerlessAgent, } from '../../../../../common/agent_name'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; @@ -144,7 +145,8 @@ export function isMetricsTabHidden({ isRumAgentName(agentName) || isJavaAgentName(agentName) || isIosAgentName(agentName) || - isJRubyAgent(agentName, runtimeName) + isJRubyAgent(agentName, runtimeName) || + isServerlessAgent(runtimeName) ); } @@ -155,7 +157,10 @@ export function isJVMsTabHidden({ agentName?: string; runtimeName?: string; }) { - return !(isJavaAgentName(agentName) || isJRubyAgent(agentName, runtimeName)); + return ( + !(isJavaAgentName(agentName) || isJRubyAgent(agentName, runtimeName)) || + isServerlessAgent(runtimeName) + ); } function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index ff1eec200a0c22..a2bfad0175a5f9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -12,7 +12,9 @@ import { ChartPointerEventContextProvider } from '../../../../context/chart_poin import { ServiceOverviewThroughputChart } from '../../../app/service_overview/service_overview_throughput_chart'; import { LatencyChart } from '../latency_chart'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; +import { TransactionColdstartRateChart } from '../transaction_coldstart_rate_chart'; import { FailedTransactionRateChart } from '../failed_transaction_rate_chart'; +import { TimeRangeComparisonType } from '../../../../../common/runtime_types/comparison_type_rt'; export function TransactionCharts({ kuery, @@ -20,12 +22,18 @@ export function TransactionCharts({ start, end, transactionName, + isServerlessContext, + comparisonEnabled, + comparisonType, }: { kuery: string; environment: string; start: string; end: string; transactionName?: string; + isServerlessContext?: boolean; + comparisonEnabled?: boolean; + comparisonType?: TimeRangeComparisonType; }) { return ( <> @@ -56,12 +64,24 @@ export function TransactionCharts({ - - - + {isServerlessContext ? ( + + + + ) : ( + + + + )} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx new file mode 100644 index 00000000000000..2b99562b67172f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx @@ -0,0 +1,184 @@ +/* + * 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 { + EuiPanel, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { asPercent } from '../../../../../common/utils/formatters'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useTheme } from '../../../../hooks/use_theme'; +import { TimeseriesChart } from '../timeseries_chart'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../time_comparison/get_time_range_comparison'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { TimeRangeComparisonType } from '../../../../../common/runtime_types/comparison_type_rt'; + +function yLabelFormat(y?: number | null) { + return asPercent(y || 0, 1); +} + +interface Props { + height?: number; + showAnnotations?: boolean; + kuery: string; + environment: string; + transactionName?: string; + comparisonEnabled?: boolean; + comparisonType?: TimeRangeComparisonType; +} + +type ColdstartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>; + +const INITIAL_STATE: ColdstartRate = { + currentPeriod: { + transactionColdstartRate: [], + average: null, + }, + previousPeriod: { + transactionColdstartRate: [], + average: null, + }, +}; + +export function TransactionColdstartRateChart({ + height, + showAnnotations = true, + environment, + kuery, + transactionName, + comparisonEnabled, + comparisonType, +}: Props) { + const theme = useTheme(); + + const { + query: { rangeFrom, rangeTo }, + } = useApmParams('/services/{serviceName}'); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { serviceName, transactionType } = useApmServiceContext(); + const comparisonChartThem = getComparisonChartTheme(); + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); + + const endpoint = transactionName + ? ('GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name' as const) + : ('GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate' as const); + + const { data = INITIAL_STATE, status } = useFetcher( + (callApmApi) => { + if (transactionType && serviceName && start && end) { + return callApmApi(endpoint, { + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + start, + end, + transactionType, + comparisonStart, + comparisonEnd, + ...(transactionName ? { transactionName } : {}), + }, + }, + }); + } + }, + [ + environment, + kuery, + serviceName, + start, + end, + transactionType, + transactionName, + comparisonStart, + comparisonEnd, + endpoint, + ] + ); + + const timeseries = [ + { + data: data.currentPeriod.transactionColdstartRate, + type: 'linemark', + color: theme.eui.euiColorVis5, + title: i18n.translate('xpack.apm.coldstartRate.chart.coldstartRate', { + defaultMessage: 'Cold start rate (avg.)', + }), + }, + ...(comparisonEnabled + ? [ + { + data: data.previousPeriod.transactionColdstartRate, + type: 'area', + color: theme.eui.euiColorMediumShade, + title: i18n.translate( + 'xpack.apm.coldstartRate.chart.coldstartRate.previousPeriodLabel', + { defaultMessage: 'Previous period' } + ), + }, + ] + : []), + ]; + + return ( + + + + +

+ {i18n.translate('xpack.apm.coldstartRate', { + defaultMessage: 'Cold start rate', + })} +

+
+
+ + + + +
+ +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx index b5938167378d7a..91780fec158450 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiBadge, EuiDescriptionList } from '@elastic/eui'; +import { + EuiBadge, + EuiDescriptionList, + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -16,9 +22,10 @@ type ServiceDetailsReturnType = interface Props { cloud: ServiceDetailsReturnType['cloud']; + isServerless: boolean; } -export function CloudDetails({ cloud }: Props) { +export function CloudDetails({ cloud, isServerless }: Props) { if (!cloud) { return null; } @@ -36,6 +43,43 @@ export function CloudDetails({ cloud }: Props) { }); } + if (cloud.serviceName) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel', + { + defaultMessage: 'Cloud service', + } + ), + description: ( + + {cloud.serviceName} + {isServerless && ( + + + + )} + + ), + }); + } + if (!!cloud.availabilityZones?.length) { listItems.push({ title: i18n.translate( @@ -58,7 +102,29 @@ export function CloudDetails({ cloud }: Props) { }); } - if (cloud.machineTypes) { + if (!!cloud.regions?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.regionLabel', + { + defaultMessage: + '{regions, plural, =0 {Region} one {Region} other {Regions}} ', + values: { regions: cloud.regions.length }, + } + ), + description: ( +
    + {cloud.regions.map((region, index) => ( +
  • + {region} +
  • + ))} +
+ ), + }); + } + + if (!!cloud.machineTypes?.length) { listItems.push({ title: i18n.translate( 'xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel', diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index 778cdeb9be3f75..2e15ea9f0db5f5 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -189,6 +189,7 @@ describe('ServiceIcons', () => { data: { agentName: 'java', containerType: 'Kubernetes', + serverlessType: 'lambda', cloudProvider: 'gcp', }, status: fetcherHook.FETCH_STATUS.SUCCESS, @@ -220,6 +221,7 @@ describe('ServiceIcons', () => { expect(queryAllByTestId('loading')).toHaveLength(0); expect(getByTestId('service')).toBeInTheDocument(); expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); expect(getByTestId('cloud')).toBeInTheDocument(); fireEvent.click(getByTestId('popover_Service')); expect(getByTestId('loading-content')).toBeInTheDocument(); @@ -231,6 +233,7 @@ describe('ServiceIcons', () => { data: { agentName: 'java', containerType: 'Kubernetes', + serverlessType: '', cloudProvider: 'gcp', }, status: fetcherHook.FETCH_STATUS.SUCCESS, @@ -269,5 +272,118 @@ describe('ServiceIcons', () => { expect(getByText('Service')).toBeInTheDocument(); expect(getByText('v1.0.0')).toBeInTheDocument(); }); + + it('shows serverless content', () => { + const apisMockData = { + 'GET /internal/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + serverlessType: 'lambda', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /internal/apm/services/{serviceName}/metadata/details': { + data: { + serverless: { + type: '', + functionNames: ['lambda-java-dev'], + faasTriggerTypes: ['datasource', 'http'], + }, + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId, getByText } = render( + + + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + + fireEvent.click(getByTestId('popover_Serverless')); + expect(queryAllByTestId('loading-content')).toHaveLength(0); + expect(getByText('Serverless')).toBeInTheDocument(); + expect(getByText('lambda-java-dev')).toBeInTheDocument(); + expect(getByText('datasource')).toBeInTheDocument(); + expect(getByText('http')).toBeInTheDocument(); + }); + + it('shows cloud content', () => { + const apisMockData = { + 'GET /internal/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + serverlessType: 'lambda', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /internal/apm/services/{serviceName}/metadata/details': { + data: { + cloud: { + provider: 'aws', + projectName: '', + serviceName: 'lambda', + availabilityZones: [], + regions: ['us-east-1'], + machineTypes: [], + }, + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId, getByText } = render( + + + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('serverless')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + + fireEvent.click(getByTestId('popover_Cloud')); + expect(queryAllByTestId('loading-content')).toHaveLength(0); + expect(getByText('Cloud')).toBeInTheDocument(); + expect(getByText('aws')).toBeInTheDocument(); + expect(getByText('lambda')).toBeInTheDocument(); + expect(getByText('us-east-1')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index 52c5ca37d818e0..2fd578f10b1101 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -13,6 +13,7 @@ import { ContainerType } from '../../../../common/service_metadata'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { getAgentIcon } from '../agent_icon/get_agent_icon'; import { CloudDetails } from './cloud_details'; +import { ServerlessDetails } from './serverless_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; import { ServiceDetails } from './service_details'; @@ -47,7 +48,7 @@ export function getContainerIcon(container?: ContainerType) { } } -type Icons = 'service' | 'container' | 'cloud' | 'alerts'; +type Icons = 'service' | 'container' | 'serverless' | 'cloud' | 'alerts'; export interface PopoverItem { key: Icons; @@ -130,6 +131,17 @@ export function ServiceIcons({ start, end, serviceName }: Props) { }), component: , }, + { + key: 'serverless', + icon: { + type: getAgentIcon(icons?.serverlessType, theme.darkMode) || 'node', + }, + isVisible: !!icons?.serverlessType, + title: i18n.translate('xpack.apm.serviceIcons.serverless', { + defaultMessage: 'Serverless', + }), + component: , + }, { key: 'cloud', icon: { @@ -139,7 +151,12 @@ export function ServiceIcons({ start, end, serviceName }: Props) { title: i18n.translate('xpack.apm.serviceIcons.cloud', { defaultMessage: 'Cloud', }), - component: , + component: ( + + ), }, ]; diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx new file mode 100644 index 00000000000000..d05abab6d674ab --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/service_icons/serverless_details.tsx @@ -0,0 +1,73 @@ +/* + * 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 { EuiBadge, EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../services/rest/create_call_apm_api'; + +type ServiceDetailsReturnType = + APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>; + +interface Props { + serverless: ServiceDetailsReturnType['serverless']; +} + +export function ServerlessDetails({ serverless }: Props) { + if (!serverless) { + return null; + } + + const listItems: EuiDescriptionListProps['listItems'] = []; + + if (!!serverless.functionNames?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel', + { + defaultMessage: + '{functionNames, plural, =0 {Function name} one {Function name} other {Function names}} ', + values: { functionNames: serverless.functionNames.length }, + } + ), + description: ( +
    + {serverless.functionNames.map((type, index) => ( +
  • + {type} +
  • + ))} +
+ ), + }); + } + + if (!!serverless.faasTriggerTypes?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel', + { + defaultMessage: + '{triggerTypes, plural, =0 {Trigger type} one {Trigger type} other {Trigger types}} ', + values: { triggerTypes: serverless.faasTriggerTypes.length }, + } + ), + description: ( +
    + {serverless.faasTriggerTypes.map((type, index) => ( +
  • + {type} +
  • + ))} +
+ ), + }); + } + + return ; +} diff --git a/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx b/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx index 399121b710ce91..7ceec331ae8fef 100644 --- a/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx +++ b/x-pack/plugins/apm/public/components/shared/summary/transaction_summary.tsx @@ -14,11 +14,13 @@ import { ErrorCountSummaryItemBadge } from './error_count_summary_item_badge'; import { HttpInfoSummaryItem } from './http_info_summary_item'; import { TransactionResultSummaryItem } from './transaction_result_summary_item'; import { UserAgentSummaryItem } from './user_agent_summary_item'; +import { ColdStartBadge } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge'; interface Props { transaction: Transaction; totalDuration: number | undefined; errorCount: number; + coldStartBadge?: boolean; } function getTransactionResultSummaryItem(transaction: Transaction) { @@ -39,7 +41,12 @@ function getTransactionResultSummaryItem(transaction: Transaction) { return null; } -function TransactionSummary({ transaction, totalDuration, errorCount }: Props) { +function TransactionSummary({ + transaction, + totalDuration, + errorCount, + coldStartBadge, +}: Props) { const items = [ , ) : null, + coldStartBadge ? : null, ]; return ; diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts new file mode 100644 index 00000000000000..094d5ed6350df6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_coldstart_rate.ts @@ -0,0 +1,67 @@ +/* + * 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 { FAAS_COLDSTART } from '../../../common/elasticsearch_fieldnames'; +import { + AggregationOptionsByType, + AggregationResultOf, +} from '../../../../../../src/core/types/elasticsearch'; + +export const getColdstartAggregation = () => ({ + terms: { + field: FAAS_COLDSTART, + }, +}); + +type ColdstartAggregation = ReturnType; + +export const getTimeseriesAggregation = ( + start: number, + end: number, + intervalString: string +) => ({ + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { coldstartStates: getColdstartAggregation() }, +}); + +export function calculateTransactionColdstartRate( + coldstartStatesResponse: AggregationResultOf +) { + const coldstartStates = Object.fromEntries( + coldstartStatesResponse.buckets.map(({ key, doc_count: count }) => [ + key === 1 ? 'true' : 'false', + count, + ]) + ); + + const coldstarts = coldstartStates.true ?? 0; + const warmstarts = coldstartStates.false ?? 0; + + return coldstarts / (coldstarts + warmstarts); +} + +export function getTransactionColdstartRateTimeSeries( + buckets: AggregationResultOf< + { + date_histogram: AggregationOptionsByType['date_histogram']; + aggs: { coldstartStates: ColdstartAggregation }; + }, + {} + >['buckets'] +) { + return buckets.map((dateBucket) => { + return { + x: dateBucket.key, + y: calculateTransactionColdstartRate(dateBucket.coldstartStates), + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts new file mode 100644 index 00000000000000..e7c9e111be7a51 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_coldstart_rate.ts @@ -0,0 +1,181 @@ +/* + * 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 { + FAAS_COLDSTART, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; +import { kqlQuery, rangeQuery } from '../../../../observability/server'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { Coordinate } from '../../../typings/timeseries'; +import { + getDocumentTypeFilterForTransactions, + getProcessorEventForTransactions, +} from '../helpers/transactions'; +import { getBucketSizeForAggregatedTransactions } from '../helpers/get_bucket_size_for_aggregated_transactions'; +import { Setup } from '../helpers/setup_request'; +import { + calculateTransactionColdstartRate, + getColdstartAggregation, + getTransactionColdstartRateTimeSeries, +} from '../helpers/transaction_coldstart_rate'; +import { termQuery } from '../../../../observability/server'; + +export async function getColdstartRate({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + start, + end, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionType?: string; + transactionName: string; + setup: Setup; + searchAggregatedTransactions: boolean; + start: number; + end: number; +}): Promise<{ + transactionColdstartRate: Coordinate[]; + average: number | null; +}> { + const { apmEventClient } = setup; + + const filter = [ + ...termQuery(SERVICE_NAME, serviceName), + { exists: { field: FAAS_COLDSTART } }, + ...(transactionName ? termQuery(TRANSACTION_NAME, transactionName) : []), + ...termQuery(TRANSACTION_TYPE, transactionType), + ...getDocumentTypeFilterForTransactions(searchAggregatedTransactions), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ]; + + const coldstartStates = getColdstartAggregation(); + + const params = { + apm: { + events: [getProcessorEventForTransactions(searchAggregatedTransactions)], + }, + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + coldstartStates, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSizeForAggregatedTransactions({ + start, + end, + searchAggregatedTransactions, + }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + coldstartStates, + }, + }, + }, + }, + }; + + const resp = await apmEventClient.search( + 'get_transaction_group_coldstart_rate', + params + ); + + if (!resp.aggregations) { + return { transactionColdstartRate: [], average: null }; + } + + const transactionColdstartRate = getTransactionColdstartRateTimeSeries( + resp.aggregations.timeseries.buckets + ); + + const average = calculateTransactionColdstartRate( + resp.aggregations.coldstartStates + ); + + return { transactionColdstartRate, average }; +} + +export async function getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + transactionName = '', + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, +}: { + environment: string; + kuery: string; + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup; + searchAggregatedTransactions: boolean; + comparisonStart?: number; + comparisonEnd?: number; + start: number; + end: number; +}) { + const commonProps = { + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + }; + + const currentPeriodPromise = getColdstartRate({ ...commonProps, start, end }); + + const previousPeriodPromise = + comparisonStart && comparisonEnd + ? getColdstartRate({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }) + : { transactionColdstartRate: [], average: null }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firstCurrentPeriod = currentPeriod.transactionColdstartRate; + + return { + currentPeriod, + previousPeriod: { + ...previousPeriod, + transactionColdstartRate: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod, + previousPeriodTimeseries: previousPeriod.transactionColdstartRate, + }), + }, + }; +} diff --git a/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts index b7ce68a0de5785..6b7f16084e5f31 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_metadata_details.ts @@ -10,7 +10,9 @@ import { AGENT, CLOUD, CLOUD_AVAILABILITY_ZONE, + CLOUD_REGION, CLOUD_MACHINE_TYPE, + CLOUD_SERVICE_NAME, CONTAINER_ID, HOST, KUBERNETES, @@ -18,6 +20,8 @@ import { SERVICE_NAME, SERVICE_NODE_NAME, SERVICE_VERSION, + FAAS_ID, + FAAS_TRIGGER_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { ContainerType } from '../../../common/service_metadata'; import { rangeQuery } from '../../../../observability/server'; @@ -50,11 +54,18 @@ export interface ServiceMetadataDetails { totalNumberInstances?: number; type?: ContainerType; }; + serverless?: { + type?: string; + functionNames?: string[]; + faasTriggerTypes?: string[]; + }; cloud?: { provider?: string; availabilityZones?: string[]; + regions?: string[]; machineTypes?: string[]; projectName?: string; + serviceName?: string; }; } @@ -104,12 +115,36 @@ export async function getServiceMetadataDetails({ size: 10, }, }, + regions: { + terms: { + field: CLOUD_REGION, + size: 10, + }, + }, + cloudServices: { + terms: { + field: CLOUD_SERVICE_NAME, + size: 1, + }, + }, machineTypes: { terms: { field: CLOUD_MACHINE_TYPE, size: 10, }, }, + faasTriggerTypes: { + terms: { + field: FAAS_TRIGGER_TYPE, + size: 10, + }, + }, + faasFunctionNames: { + terms: { + field: FAAS_ID, + size: 10, + }, + }, totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, }, @@ -153,13 +188,30 @@ export async function getServiceMetadataDetails({ } : undefined; + const serverlessDetails = + !!response.aggregations?.faasTriggerTypes?.buckets.length && cloud + ? { + type: cloud.service?.name, + functionNames: response.aggregations?.faasFunctionNames.buckets + .map((bucket) => getLambdaFunctionNameFromARN(bucket.key as string)) + .filter((name) => name), + faasTriggerTypes: response.aggregations?.faasTriggerTypes.buckets.map( + (bucket) => bucket.key as string + ), + } + : undefined; + const cloudDetails = cloud ? { provider: cloud.provider, projectName: cloud.project?.name, + serviceName: cloud.service?.name, availabilityZones: response.aggregations?.availabilityZones.buckets.map( (bucket) => bucket.key as string ), + regions: response.aggregations?.regions.buckets.map( + (bucket) => bucket.key as string + ), machineTypes: response.aggregations?.machineTypes.buckets.map( (bucket) => bucket.key as string ), @@ -169,6 +221,12 @@ export async function getServiceMetadataDetails({ return { service: serviceMetadataDetails, container: containerDetails, + serverless: serverlessDetails, cloud: cloudDetails, }; } + +function getLambdaFunctionNameFromARN(arn: string) { + // Lambda function ARN example: arn:aws:lambda:us-west-2:123456789012:function:my-function + return arn.split(':')[6] || ''; +} diff --git a/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts index ca97e9d58f0609..6082ae6c5b1124 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_metadata_icons.ts @@ -9,6 +9,7 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { AGENT_NAME, CLOUD_PROVIDER, + CLOUD_SERVICE_NAME, CONTAINER_ID, KUBERNETES, SERVICE_NAME, @@ -29,6 +30,7 @@ type ServiceMetadataIconsRaw = Pick< export interface ServiceMetadataIcons { agentName?: string; containerType?: ContainerType; + serverlessType?: string; cloudProvider?: string; } @@ -70,7 +72,13 @@ export async function getServiceMetadataIcons({ }, body: { size: 1, - _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], + _source: [ + KUBERNETES, + CLOUD_PROVIDER, + CONTAINER_ID, + AGENT_NAME, + CLOUD_SERVICE_NAME, + ], query: { bool: { filter, should } }, }, }; @@ -85,6 +93,7 @@ export async function getServiceMetadataIcons({ agentName: undefined, containerType: undefined, cloudProvider: undefined, + serverlessType: undefined, }; } @@ -98,9 +107,15 @@ export async function getServiceMetadataIcons({ containerType = 'Docker'; } + let serverlessType: string | undefined; + if (cloud?.provider === 'aws' && cloud?.service?.name === 'lambda') { + serverlessType = 'lambda'; + } + return { agentName: agent?.name, containerType, + serverlessType, cloudProvider: cloud?.provider, }; } diff --git a/x-pack/plugins/apm/server/routes/transactions/route.ts b/x-pack/plugins/apm/server/routes/transactions/route.ts index e22e521c699ecd..657387037855f0 100644 --- a/x-pack/plugins/apm/server/routes/transactions/route.ts +++ b/x-pack/plugins/apm/server/routes/transactions/route.ts @@ -19,6 +19,7 @@ import { getTransactionBreakdown } from './breakdown'; import { getTransactionTraceSamples } from './trace_samples'; import { getLatencyPeriods } from './get_latency_charts'; import { getFailedTransactionRatePeriods } from './get_failed_transaction_rate_periods'; +import { getColdstartRatePeriods } from '../../lib/transaction_groups/get_coldstart_rate'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { comparisonRangeRt, @@ -461,6 +462,158 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ }, }); +const transactionChartsColdstartRateRoute = createApmServerRoute({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string }), + t.intersection([environmentRt, kueryRt, rangeRt, comparisonRangeRt]), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + currentPeriod: { + transactionColdstartRate: Array< + import('../../../typings/timeseries').Coordinate + >; + average: number | null; + }; + previousPeriod: + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: number | null; + } + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: null; + }; + }> => { + const setup = await setupRequest(resources); + + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + comparisonStart, + comparisonEnd, + start, + end, + } = params.query; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + start, + end, + }); + + return getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, + }); + }, +}); + +const transactionChartsColdstartRateByTransactionNameRoute = + createApmServerRoute({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string, transactionName: t.string }), + t.intersection([environmentRt, kueryRt, rangeRt, comparisonRangeRt]), + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ( + resources + ): Promise<{ + currentPeriod: { + transactionColdstartRate: Array< + import('../../../typings/timeseries').Coordinate + >; + average: number | null; + }; + previousPeriod: + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: number | null; + } + | { + transactionColdstartRate: Array<{ + x: number; + y: import('../../../typings/common').Maybe; + }>; + average: null; + }; + }> => { + const setup = await setupRequest(resources); + + const { params } = resources; + const { serviceName } = params.path; + const { + environment, + kuery, + transactionType, + transactionName, + comparisonStart, + comparisonEnd, + start, + end, + } = params.query; + + const searchAggregatedTransactions = + await getSearchAggregatedTransactions({ + ...setup, + kuery, + start, + end, + }); + + return getColdstartRatePeriods({ + environment, + kuery, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + comparisonStart, + comparisonEnd, + start, + end, + }); + }, + }); + export const transactionRouteRepository = { ...transactionGroupsMainStatisticsRoute, ...transactionGroupsDetailedStatisticsRoute, @@ -468,4 +621,6 @@ export const transactionRouteRepository = { ...transactionTraceSamplesRoute, ...transactionChartsBreakdownRoute, ...transactionChartsErrorRateRoute, + ...transactionChartsColdstartRateRoute, + ...transactionChartsColdstartRateByTransactionNameRoute, }; diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts index 8891318554e92c..bc0c3ea8002ad0 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts @@ -27,4 +27,7 @@ export interface Cloud { image?: { id: string; }; + service?: { + name: string; + }; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts new file mode 100644 index 00000000000000..1229b8134ac13a --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/faas.ts @@ -0,0 +1,16 @@ +/* + * 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 interface Faas { + id: string; + coldstart?: boolean; + execution?: string; + trigger?: { + type?: string; + request_id?: string; + }; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 34c391134b6041..0811bfb8c1a796 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -19,6 +19,7 @@ import { TimestampUs } from './fields/timestamp_us'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Faas } from './fields/faas'; interface Processor { name: 'transaction'; @@ -69,4 +70,5 @@ export interface TransactionRaw extends APMBaseDoc { user?: User; user_agent?: UserAgent; cloud?: Cloud; + faas?: Faas; } diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts new file mode 100644 index 00000000000000..fedf44d477d2da --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start.spec.ts @@ -0,0 +1,205 @@ +/* + * 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 { first, last } from 'lodash'; +import moment from 'moment'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { + APIReturnType, + APIClientRequestParamsOf, +} from '../../../../plugins/apm/public/services/rest/create_call_apm_api'; +import { RecursivePartial } from '../../../../plugins/apm/typings/common'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; + +type ColdStartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>['params'] + > + ) { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate', + params: { + path: { serviceName }, + query: { + transactionType: 'request', + environment: 'ENVIRONMENT_ALL', + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery: '', + ...overrides?.query, + }, + }, + }); + } + + registry.when( + 'Cold start rate when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body.currentPeriod.transactionColdstartRate).to.empty(); + expect(body.currentPeriod.average).to.be(null); + + expect(body.previousPeriod.transactionColdstartRate).to.empty(); + expect(body.previousPeriod.average).to.be(null); + }); + } + ); + + registry.when( + 'Cold start rate when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('without comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + await generateData({ + synthtraceEsClient, + start, + end, + coldStartRate: 10, + warmStartRate: 30, + }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body).to.have.property('currentPeriod'); + expect(body.currentPeriod.transactionColdstartRate).to.have.length(15); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + }); + + it('returns correct average rate', () => { + expect(body.currentPeriod.average).to.be(0.25); + }); + + it("doesn't have data for the previous period", () => { + expect(body).to.have.property('previousPeriod'); + expect(body.previousPeriod.transactionColdstartRate).to.have.length(0); + expect(body.previousPeriod.average).to.be(null); + }); + }); + + describe('with comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + const startDate = moment(start).add(6, 'minutes'); + const endDate = moment(start).add(9, 'minutes'); + const comparisonStartDate = new Date(start); + const comparisonEndDate = moment(start).add(3, 'minutes'); + + await generateData({ + synthtraceEsClient, + start: startDate.valueOf(), + end: endDate.valueOf(), + coldStartRate: 10, + warmStartRate: 30, + }); + await generateData({ + synthtraceEsClient, + start: comparisonStartDate.getTime(), + end: comparisonEndDate.valueOf(), + coldStartRate: 20, + warmStartRate: 20, + }); + + const response = await callApi({ + query: { + start: startDate.toISOString(), + end: endDate.subtract(1, 'seconds').toISOString(), + comparisonStart: comparisonStartDate.toISOString(), + comparisonEnd: comparisonEndDate.subtract(1, 'seconds').toISOString(), + }, + }); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns some data', () => { + expect(body.currentPeriod.average).not.to.be(null); + expect(body.currentPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasCurrentPeriodData = body.currentPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasCurrentPeriodData).to.equal(true); + + expect(body.previousPeriod.average).not.to.be(null); + expect(body.previousPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasPreviousPeriodData = body.previousPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasPreviousPeriodData).to.equal(true); + }); + + it('has same start time for both periods', () => { + expect(first(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + first(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('has same end time for both periods', () => { + expect(last(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + last(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body.currentPeriod.transactionColdstartRate).to.have.length(3); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + + expect(body.previousPeriod.transactionColdstartRate).to.have.length(3); + expect(body.previousPeriod.transactionColdstartRate.every(({ y }) => y === 0.5)).to.be( + true + ); + }); + + it('has same average value for both periods', () => { + expect(body.currentPeriod.average).to.be(0.25); + expect(body.previousPeriod.average).to.be(0.5); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts new file mode 100644 index 00000000000000..d577077490b886 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/cold_start_by_transaction_name.spec.ts @@ -0,0 +1,207 @@ +/* + * 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 { first, last } from 'lodash'; +import moment from 'moment'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { + APIReturnType, + APIClientRequestParamsOf, +} from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; +import { RecursivePartial } from '../../../../../plugins/apm/typings/common'; +import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number'; + +type ColdStartRate = + APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName, transactionName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate'>['params'] + > + ) { + return await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/transactions/charts/coldstart_rate_by_transaction_name', + params: { + path: { serviceName }, + query: { + transactionType: 'request', + transactionName, + environment: 'ENVIRONMENT_ALL', + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery: '', + ...overrides?.query, + }, + }, + }); + } + + registry.when( + 'Cold start rate by transaction name when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body.currentPeriod.transactionColdstartRate).to.empty(); + expect(body.currentPeriod.average).to.be(null); + + expect(body.previousPeriod.transactionColdstartRate).to.empty(); + expect(body.previousPeriod.average).to.be(null); + }); + } + ); + + registry.when( + 'Cold start rate by transaction name when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('without comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + await generateData({ + synthtraceEsClient, + start, + end, + coldStartRate: 10, + warmStartRate: 30, + }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body).to.have.property('currentPeriod'); + expect(body.currentPeriod.transactionColdstartRate).to.have.length(15); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + }); + + it('returns correct average rate', () => { + expect(body.currentPeriod.average).to.be(0.25); + }); + + it("doesn't have data for the previous period", () => { + expect(body).to.have.property('previousPeriod'); + expect(body.previousPeriod.transactionColdstartRate).to.have.length(0); + expect(body.previousPeriod.average).to.be(null); + }); + }); + + describe('with comparison', () => { + let body: ColdStartRate; + let status: number; + + before(async () => { + const startDate = moment(start).add(6, 'minutes'); + const endDate = moment(start).add(9, 'minutes'); + const comparisonStartDate = new Date(start); + const comparisonEndDate = moment(start).add(3, 'minutes'); + + await generateData({ + synthtraceEsClient, + start: startDate.valueOf(), + end: endDate.valueOf(), + coldStartRate: 10, + warmStartRate: 30, + }); + await generateData({ + synthtraceEsClient, + start: comparisonStartDate.getTime(), + end: comparisonEndDate.valueOf(), + coldStartRate: 20, + warmStartRate: 20, + }); + + const response = await callApi({ + query: { + start: startDate.toISOString(), + end: endDate.subtract(1, 'seconds').toISOString(), + comparisonStart: comparisonStartDate.toISOString(), + comparisonEnd: comparisonEndDate.subtract(1, 'seconds').toISOString(), + }, + }); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns some data', () => { + expect(body.currentPeriod.average).not.to.be(null); + expect(body.currentPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasCurrentPeriodData = body.currentPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasCurrentPeriodData).to.equal(true); + + expect(body.previousPeriod.average).not.to.be(null); + expect(body.previousPeriod.transactionColdstartRate.length).to.be.greaterThan(0); + const hasPreviousPeriodData = body.previousPeriod.transactionColdstartRate.some(({ y }) => + isFiniteNumber(y) + ); + expect(hasPreviousPeriodData).to.equal(true); + }); + + it('has same start time for both periods', () => { + expect(first(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + first(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('has same end time for both periods', () => { + expect(last(body.currentPeriod.transactionColdstartRate)?.x).to.equal( + last(body.previousPeriod.transactionColdstartRate)?.x + ); + }); + + it('returns an array of transaction cold start rates', () => { + expect(body.currentPeriod.transactionColdstartRate).to.have.length(3); + expect(body.currentPeriod.transactionColdstartRate.every(({ y }) => y === 0.25)).to.be( + true + ); + + expect(body.previousPeriod.transactionColdstartRate).to.have.length(3); + expect(body.previousPeriod.transactionColdstartRate.every(({ y }) => y === 0.5)).to.be( + true + ); + }); + + it('has same average value for both periods', () => { + expect(body.currentPeriod.average).to.be(0.25); + expect(body.previousPeriod.average).to.be(0.5); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts new file mode 100644 index 00000000000000..e081c5ea2e168c --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/cold_start_by_transaction_name/generate_data.ts @@ -0,0 +1,64 @@ +/* + * 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 '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-go', + transactionName: 'GET /apple 🍎', + duration: 1000, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, + coldStartRate, + warmStartRate, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; + coldStartRate: number; + warmStartRate: number; +}) { + const { transactionName, duration, serviceName } = dataConfig; + const instance = apm.service(serviceName, 'production', 'go').instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('1m') + .rate(coldStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(transactionName) + .defaults({ + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(duration) + .success() + .serialize(), + ]), + ...timerange(start, end) + .interval('1m') + .rate(warmStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(transactionName) + .defaults({ + 'faas.coldstart': false, + }) + .timestamp(timestamp) + .duration(duration) + .success() + .serialize(), + ]), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts b/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts new file mode 100644 index 00000000000000..50861da61a9f71 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/cold_start/generate_data.ts @@ -0,0 +1,70 @@ +/* + * 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 '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-go', + coldStartTransaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + warmStartTransaction: { + name: 'GET /banana 🍌', + duration: 2000, + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, + coldStartRate, + warmStartRate, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; + coldStartRate: number; + warmStartRate: number; +}) { + const { coldStartTransaction, warmStartTransaction, serviceName } = dataConfig; + const instance = apm.service(serviceName, 'production', 'go').instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('1m') + .rate(coldStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(coldStartTransaction.name) + .defaults({ + 'faas.coldstart': true, + }) + .timestamp(timestamp) + .duration(coldStartTransaction.duration) + .success() + .serialize(), + ]), + ...timerange(start, end) + .interval('1m') + .rate(warmStartRate) + .flatMap((timestamp) => [ + ...instance + .transaction(warmStartTransaction.name) + .defaults({ + 'faas.coldstart': false, + }) + .timestamp(timestamp) + .duration(warmStartTransaction.duration) + .success() + .serialize(), + ]), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts deleted file mode 100644 index 00315693082247..00000000000000 --- a/x-pack/test/apm_api_integration/tests/services/service_details.spec.ts +++ /dev/null @@ -1,135 +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. - */ - -import expect from '@kbn/expect'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when( - 'Service details when data is not loaded', - { config: 'basic', archives: [] }, - () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({}); - }); - } - ); - - registry.when( - 'Service details when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns java service details', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "cloud": Object { - "availabilityZones": Array [ - "europe-west1-c", - ], - "machineTypes": Array [ - "n1-standard-4", - ], - "projectName": "elastic-observability", - "provider": "gcp", - }, - "container": Object { - "isContainerized": true, - "os": "Linux", - "totalNumberInstances": 1, - "type": "Kubernetes", - }, - "service": Object { - "agent": Object { - "ephemeral_id": "2745d454-f57f-4473-a09b-fe6bef295860", - "name": "java", - "version": "1.25.1-SNAPSHOT.UNKNOWN", - }, - "runtime": Object { - "name": "Java", - "version": "11.0.11", - }, - "versions": Array [ - "2021-08-03 04:26:27", - ], - }, - } - `); - }); - - it('returns python service details', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-python/metadata/details`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "cloud": Object { - "availabilityZones": Array [ - "europe-west1-c", - ], - "machineTypes": Array [ - "n1-standard-4", - ], - "projectName": "elastic-observability", - "provider": "gcp", - }, - "container": Object { - "isContainerized": true, - "os": "linux", - "totalNumberInstances": 1, - "type": "Kubernetes", - }, - "service": Object { - "agent": Object { - "name": "python", - "version": "6.3.3", - }, - "framework": "django", - "runtime": Object { - "name": "CPython", - "version": "3.9.6", - }, - "versions": Array [ - "2021-08-03 04:26:25", - ], - }, - } - `); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts b/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts new file mode 100644 index 00000000000000..f6035cf784dbdb --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_details/generate_data.ts @@ -0,0 +1,130 @@ +/* + * 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 '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + service: { + name: 'lambda-python-dev-hello', + version: '$LATEST', + runtime: { + name: 'AWS_Lambda_python3.8', + version: '3.8.11', + }, + framework: 'AWS Lambda', + agent: { + name: 'python', + version: '6.6.0', + }, + }, + containerOs: 'linux', + serverless: { + firstFunctionName: 'my-function-1', + secondFunctionName: 'my-function-2', + faasTriggerType: 'other', + }, + cloud: { + provider: 'aws', + availabilityZone: 'us-central1-c', + region: 'us-east-1', + machineType: 'e2-standard-4', + projectName: 'elastic-observability', + serviceName: 'lambda', + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const { rate, service, containerOs, serverless, cloud, transaction } = dataConfig; + const { + provider, + availabilityZone, + region, + machineType, + projectName, + serviceName: cloudServiceName, + } = cloud; + const { faasTriggerType, firstFunctionName, secondFunctionName } = serverless; + const { version, runtime, framework, agent, name: serviceName } = service; + const { name: serviceRunTimeName, version: serviceRunTimeVersion } = runtime; + const { name: agentName, version: agentVersion } = agent; + + const instance = apm.service(serviceName, 'production', agentName).instance('instance-a'); + + const traceEvents = [ + ...timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .timestamp(timestamp) + .defaults({ + 'cloud.provider': provider, + 'cloud.project.name': projectName, + 'cloud.service.name': cloudServiceName, + 'cloud.availability_zone': availabilityZone, + 'cloud.machine.type': machineType, + 'cloud.region': region, + 'faas.id': `arn:aws:lambda:us-west-2:123456789012:function:${firstFunctionName}`, + 'faas.trigger.type': faasTriggerType, + 'host.os.platform': containerOs, + 'kubernetes.pod.uid': '48f4c5a5-0625-4bea-9d94-77ee94a17e70', + 'service.version': version, + 'service.runtime.name': serviceRunTimeName, + 'service.runtime.version': serviceRunTimeVersion, + 'service.framework.name': framework, + 'agent.version': agentVersion, + }) + .duration(transaction.duration) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .timestamp(timestamp) + .defaults({ + 'cloud.provider': provider, + 'cloud.project.name': projectName, + 'cloud.service.name': cloudServiceName, + 'cloud.availability_zone': availabilityZone, + 'cloud.machine.type': machineType, + 'cloud.region': region, + 'faas.id': `arn:aws:lambda:us-west-2:123456789012:function:${secondFunctionName}`, + 'faas.trigger.type': faasTriggerType, + 'host.os.platform': containerOs, + 'kubernetes.pod.uid': '48f4c5a5-0625-4bea-9d94-77ee94a17e70', + 'service.version': version, + 'service.runtime.name': serviceRunTimeName, + 'service.runtime.version': serviceRunTimeVersion, + 'service.framework.name': framework, + 'agent.version': agentVersion, + }) + .duration(transaction.duration) + .success() + .serialize() + ), + ]; + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts new file mode 100644 index 00000000000000..cf59b0d39660da --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_details/service_details.spec.ts @@ -0,0 +1,128 @@ +/* + * 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 { first } from 'lodash'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; + +type ServiceDetails = APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { + service: { name: serviceName }, + } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi() { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/metadata/details', + params: { + path: { serviceName }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); + } + + registry.when( + 'Service details when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body).to.empty(); + }); + } + ); + + registry.when( + 'Service details when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + let body: ServiceDetails; + let status: number; + + before(async () => { + await generateData({ synthtraceEsClient, start, end }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns correct cloud details', () => { + const { cloud } = dataConfig; + const { + provider, + availabilityZone, + region, + machineType, + projectName, + serviceName: cloudServiceName, + } = cloud; + + expect(first(body?.cloud?.availabilityZones)).to.be(availabilityZone); + expect(first(body?.cloud?.machineTypes)).to.be(machineType); + expect(body?.cloud?.provider).to.be(provider); + expect(body?.cloud?.projectName).to.be(projectName); + expect(body?.cloud?.serviceName).to.be(cloudServiceName); + expect(first(body?.cloud?.regions)).to.be(region); + }); + + it('returns correct container details', () => { + const { containerOs } = dataConfig; + + expect(body?.container?.isContainerized).to.be(true); + expect(body?.container?.os).to.be(containerOs); + expect(body?.container?.totalNumberInstances).to.be(1); + expect(body?.container?.type).to.be('Kubernetes'); + }); + + it('returns correct serverless details', () => { + const { cloud, serverless } = dataConfig; + const { serviceName: cloudServiceName } = cloud; + const { faasTriggerType, firstFunctionName, secondFunctionName } = serverless; + + expect(body?.serverless?.type).to.be(cloudServiceName); + expect(body?.serverless?.functionNames).to.have.length(2); + expect(body?.serverless?.functionNames).to.contain(firstFunctionName); + expect(body?.serverless?.functionNames).to.contain(secondFunctionName); + expect(first(body?.serverless?.faasTriggerTypes)).to.be(faasTriggerType); + }); + + it('returns correct service details', () => { + const { service } = dataConfig; + const { version, runtime, framework, agent } = service; + const { name: runTimeName, version: runTimeVersion } = runtime; + const { name: agentName, version: agentVersion } = agent; + + expect(body?.service?.framework).to.be(framework); + expect(body?.service?.agent.name).to.be(agentName); + expect(body?.service?.agent.version).to.be(agentVersion); + expect(body?.service?.runtime?.name).to.be(runTimeName); + expect(body?.service?.runtime?.version).to.be(runTimeVersion); + expect(first(body?.service?.versions)).to.be(version); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts deleted file mode 100644 index f8b66a621e83e4..00000000000000 --- a/x-pack/test/apm_api_integration/tests/services/service_icons.spec.ts +++ /dev/null @@ -1,77 +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. - */ - -import expect from '@kbn/expect'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when('Service icons when data is not loaded', { config: 'basic', archives: [] }, () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({}); - }); - }); - - registry.when( - 'Service icons when data is not loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns java service icons', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "agentName": "java", - "cloudProvider": "gcp", - "containerType": "Kubernetes", - } - `); - }); - - it('returns python service icons', async () => { - const response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-python/metadata/icons`, - query: { start, end }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body).toMatchInline(` - Object { - "agentName": "python", - "cloudProvider": "gcp", - "containerType": "Kubernetes", - } - `); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts b/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.ts new file mode 100644 index 00000000000000..93f68242caa5e8 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_icons/generate_data.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 { apm, timerange } from '@elastic/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@elastic/apm-synthtrace'; + +export const dataConfig = { + serviceName: 'synth-node', + rate: 10, + transaction: { + name: 'GET /apple 🍎', + duration: 1000, + }, + agentName: 'node', + cloud: { + provider: 'aws', + serviceName: 'lambda', + }, +}; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const { serviceName, agentName, rate, cloud, transaction } = dataConfig; + const { provider, serviceName: cloudServiceName } = cloud; + + const instance = apm.service(serviceName, 'production', agentName).instance('instance-a'); + + const traceEvents = timerange(start, end) + .interval('30s') + .rate(rate) + .flatMap((timestamp) => + instance + .transaction(transaction.name) + .defaults({ + 'kubernetes.pod.uid': 'test', + 'cloud.provider': provider, + 'cloud.service.name': cloudServiceName, + }) + .timestamp(timestamp) + .duration(transaction.duration) + .success() + .serialize() + ); + + await synthtraceEsClient.index(traceEvents); +} diff --git a/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts b/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts new file mode 100644 index 00000000000000..389b8c6ac1fc83 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/services/service_icons/service_icons.spec.ts @@ -0,0 +1,77 @@ +/* + * 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { dataConfig, generateData } from './generate_data'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/create_call_apm_api'; + +type ServiceIconMetadata = APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/icons'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const { serviceName } = dataConfig; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function callApi() { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services/{serviceName}/metadata/icons', + params: { + path: { serviceName }, + query: { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }, + }, + }); + } + + registry.when('Service icons when data is not loaded', { config: 'basic', archives: [] }, () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body).to.empty(); + }); + }); + + registry.when( + 'Service icons when data is generated', + { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, + () => { + let body: ServiceIconMetadata; + let status: number; + + before(async () => { + await generateData({ synthtraceEsClient, start, end }); + const response = await callApi(); + body = response.body; + status = response.status; + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct HTTP status', () => { + expect(status).to.be(200); + }); + + it('returns correct metadata', () => { + const { agentName, cloud } = dataConfig; + const { provider, serviceName: cloudServiceName } = cloud; + + expect(body.agentName).to.be(agentName); + expect(body.cloudProvider).to.be(provider); + expect(body.containerType).to.be('Kubernetes'); + expect(body.serverlessType).to.be(cloudServiceName); + }); + } + ); +}