APM UI changes for serverless services / AWS lambda (#122775)

* 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 <alexander.wert@elastic.co>
Co-authored-by: Casper Hübertz <casper@formgeist.com>
This commit is contained in:
Giorgos Bamparopoulos 2022-01-31 11:54:34 +00:00 committed by GitHub
parent ecba4787f5
commit 78643f495c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2240 additions and 247 deletions

View file

@ -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;

View file

@ -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) => {

View file

@ -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`;

View file

@ -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);
});
});
});
});

View file

@ -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');
}

View file

@ -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';

View file

@ -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');
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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;
}

View file

@ -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}
>
<EuiFlexItem grow={3}>
<TransactionBreakdownChart
showAnnotations={false}
environment={environment}
kuery={kuery}
/>
</EuiFlexItem>
{isServerless ? (
<EuiFlexItem grow={3}>
<TransactionColdstartRateChart
showAnnotations={false}
environment={environment}
kuery={kuery}
/>
</EuiFlexItem>
) : (
<EuiFlexItem grow={3}>
<TransactionBreakdownChart
showAnnotations={false}
environment={environment}
kuery={kuery}
/>
</EuiFlexItem>
)}
{!isRumAgent && (
<EuiFlexItem grow={7}>
<EuiPanel hasBorder={true}>
@ -180,7 +200,7 @@ export function ServiceOverview() {
)}
</EuiFlexGroup>
</EuiFlexItem>
{!isRumAgent && !isIosAgent && (
{!isRumAgent && !isIosAgent && !isServerless && (
<EuiFlexItem>
<EuiFlexGroup
direction="column"

View file

@ -18,6 +18,7 @@ import { AggregatedTransactionsBadge } from '../../shared/aggregated_transaction
import { TransactionCharts } from '../../shared/charts/transaction_charts';
import { replace } from '../../shared/links/url_helpers';
import { TransactionDetailsTabs } from './transaction_details_tabs';
import { isServerlessAgent } from '../../../../common/agent_name';
export function TransactionDetails() {
const { path, query } = useApmParams(
@ -28,10 +29,13 @@ export function TransactionDetails() {
rangeFrom,
rangeTo,
transactionType: transactionTypeFromUrl,
comparisonEnabled,
comparisonType,
} = query;
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const apmRouter = useApmRouter();
const { transactionType, fallbackToTransactions } = useApmServiceContext();
const { transactionType, fallbackToTransactions, runtimeName } =
useApmServiceContext();
const history = useHistory();
@ -48,6 +52,8 @@ export function TransactionDetails() {
}),
});
const isServerless = isServerlessAgent(runtimeName);
return (
<>
{fallbackToTransactions && <AggregatedTransactionsBadge />}
@ -66,6 +72,9 @@ export function TransactionDetails() {
start={start}
end={end}
transactionName={transactionName}
isServerlessContext={isServerless}
comparisonEnabled={comparisonEnabled}
comparisonType={comparisonType}
/>
</ChartPointerEventContextProvider>

View file

@ -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 <ColdStartBadge />;
}

View file

@ -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 (
<EuiBadge color="warning">
{i18n.translate('xpack.apm.transactionDetails.coldstartBadge', {
defaultMessage: 'cold start',
})}
</EuiBadge>
);
}

View file

@ -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: {

View file

@ -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 {
/**

View file

@ -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';

View file

@ -87,6 +87,7 @@ export function TransactionFlyout({
transaction={transactionDoc}
totalDuration={rootTransactionDuration}
errorCount={errorCount}
coldStartBadge={transactionDoc.faas?.coldstart}
/>
<EuiHorizontalRule margin="m" />
<DroppedSpansWarning transactionDoc={transactionDoc} />

View file

@ -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 (
<Container
@ -234,6 +237,7 @@ export function WaterfallItem({
agentName={item.doc.agent.name}
/>
)}
{isServerlessColdstart && <ColdStartBadge />}
</ItemText>
</Container>
);

View file

@ -226,6 +226,9 @@ export const simpleTrace = {
timestamp: {
us: 1584975868787052,
},
faas: {
coldstart: true,
},
},
{
parent: {

View file

@ -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<typeof WaterfallContainer>;
const apmPluginContextMock = {
core: {
http: {
basePath: { prepend: () => {} },
},
},
} as unknown as ApmPluginContextValue;
const stories: Meta<Args> = {
title: 'app/TransactionDetails/waterfall',
component: WaterfallContainer,
@ -32,7 +41,7 @@ const stories: Meta<Args> = {
'/services/{serviceName}/transactions/view?rangeFrom=now-15m&rangeTo=now&transactionName=testTransactionName',
]}
>
<MockApmPluginContextWrapper>
<MockApmPluginContextWrapper value={apmPluginContextMock}>
<StoryComponent />
</MockApmPluginContextWrapper>
</MemoryRouter>

View file

@ -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}
/>
<EuiSpacer size="s" />
<EuiPanel hasBorder={true}>

View file

@ -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();

View file

@ -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'] }) {

View file

@ -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({
<EuiFlexItem>
<FailedTransactionRateChart kuery={kuery} />
</EuiFlexItem>
<EuiFlexItem>
<TransactionBreakdownChart
kuery={kuery}
environment={environment}
/>
</EuiFlexItem>
{isServerlessContext ? (
<EuiFlexItem>
<TransactionColdstartRateChart
kuery={kuery}
transactionName={transactionName}
environment={environment}
comparisonEnabled={comparisonEnabled}
comparisonType={comparisonType}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<TransactionBreakdownChart
kuery={kuery}
environment={environment}
/>
</EuiFlexItem>
)}
</EuiFlexGrid>
</ChartPointerEventContextProvider>
</AnnotationsContextProvider>

View file

@ -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 (
<EuiPanel hasBorder={true}>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.coldstartRate', {
defaultMessage: 'Cold start rate',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
content={i18n.translate('xpack.apm.serviceOverview.coldstartHelp', {
defaultMessage:
'The cold start rate indicates the percentage of requests that trigger a cold start of a serverless function.',
})}
position="right"
/>
</EuiFlexItem>
</EuiFlexGroup>
<TimeseriesChart
id="coldstartRate"
height={height}
showAnnotations={showAnnotations}
fetchStatus={status}
timeseries={timeseries}
yLabelFormat={yLabelFormat}
yDomain={{ min: 0, max: 1 }}
customTheme={comparisonChartThem}
/>
</EuiPanel>
);
}

View file

@ -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: (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>{cloud.serviceName}</EuiFlexItem>
{isServerless && (
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.betaLabel',
{
defaultMessage: 'Beta',
}
)}
tooltipContent={i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.betaTooltip',
{
defaultMessage:
'AWS Lambda support is not GA. Please help us by reporting bugs.',
}
)}
size="s"
iconType="beaker"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
});
}
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: (
<ul>
{cloud.regions.map((region, index) => (
<li key={index}>
<EuiBadge color="hollow">{region}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (!!cloud.machineTypes?.length) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel',

View file

@ -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(
<Wrapper>
<EuiThemeProvider>
<ServiceIcons
serviceName="foo"
start="2021-08-20T10:00:00.000Z"
end="2021-08-20T10:15:00.000Z"
/>
</EuiThemeProvider>
</Wrapper>
);
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(
<Wrapper>
<EuiThemeProvider>
<ServiceIcons
serviceName="foo"
start="2021-08-20T10:00:00.000Z"
end="2021-08-20T10:15:00.000Z"
/>
</EuiThemeProvider>
</Wrapper>
);
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();
});
});
});

View file

@ -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: <ContainerDetails container={details?.container} />,
},
{
key: 'serverless',
icon: {
type: getAgentIcon(icons?.serverlessType, theme.darkMode) || 'node',
},
isVisible: !!icons?.serverlessType,
title: i18n.translate('xpack.apm.serviceIcons.serverless', {
defaultMessage: 'Serverless',
}),
component: <ServerlessDetails serverless={details?.serverless} />,
},
{
key: 'cloud',
icon: {
@ -139,7 +151,12 @@ export function ServiceIcons({ start, end, serviceName }: Props) {
title: i18n.translate('xpack.apm.serviceIcons.cloud', {
defaultMessage: 'Cloud',
}),
component: <CloudDetails cloud={details?.cloud} />,
component: (
<CloudDetails
cloud={details?.cloud}
isServerless={!!details?.serverless}
/>
),
},
];

View file

@ -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: (
<ul>
{serverless.functionNames.map((type, index) => (
<li key={index}>
<EuiBadge color="hollow">{type}</EuiBadge>
</li>
))}
</ul>
),
});
}
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: (
<ul>
{serverless.faasTriggerTypes.map((type, index) => (
<li key={index}>
<EuiBadge color="hollow">{type}</EuiBadge>
</li>
))}
</ul>
),
});
}
return <EuiDescriptionList textStyle="reverse" listItems={listItems} />;
}

View file

@ -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 = [
<TimestampTooltip time={transaction.timestamp.us / 1000} />,
<DurationSummaryItem
@ -52,6 +59,7 @@ function TransactionSummary({ transaction, totalDuration, errorCount }: Props) {
transaction.user_agent ? (
<UserAgentSummaryItem {...transaction.user_agent} />
) : null,
coldStartBadge ? <ColdStartBadge /> : null,
];
return <Summary items={items} />;

View file

@ -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<typeof getColdstartAggregation>;
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<ColdstartAggregation, {}>
) {
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),
};
});
}

View file

@ -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,
}),
},
};
}

View file

@ -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] || '';
}

View file

@ -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,
};
}

View file

@ -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<number>;
}>;
average: number | null;
}
| {
transactionColdstartRate: Array<{
x: number;
y: import('../../../typings/common').Maybe<number>;
}>;
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<number>;
}>;
average: number | null;
}
| {
transactionColdstartRate: Array<{
x: number;
y: import('../../../typings/common').Maybe<number>;
}>;
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,
};

View file

@ -27,4 +27,7 @@ export interface Cloud {
image?: {
id: string;
};
service?: {
name: string;
};
}

View file

@ -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;
};
}

View file

@ -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;
}

View file

@ -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);
});
});
}
);
}

View file

@ -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);
});
});
}
);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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",
],
},
}
`);
});
}
);
}

View file

@ -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);
}

View file

@ -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);
});
}
);
}

View file

@ -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",
}
`);
});
}
);
}

View file

@ -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);
}

View file

@ -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);
});
}
);
}