[ProfilingxAPM] Link APM from Profiling UI (#180677)

closes https://github.com/elastic/kibana/issues/178719

A new ES API has been created to support linking APM from the Profiling
UI. It's called `topN/functions`. The new API allows grouping fields. So
we first fetch functions grouping by `service.name` and when the user
opens the APM Transactions we make another request grouping by
`transaction.name`.

A new Advanced setting was created to toggle the old API on (fetch
functions from Stacktraces API): It's turned off by default.
<img width="1235" alt="Screenshot 2024-04-12 at 10 39 36"
src="ee6e7731-2f44-43ca-9793-23ba87e22e6e">

When there are services on the selected function:
*If we cannot find the transaction, we show `N/A`.
<img width="933" alt="Screenshot 2024-04-12 at 10 16 34"
src="2c5dbf60-3a47-4f4c-a46d-8a0984e0e482">

When there are **no** services on the selected function:
*hide the APM transactions section
<img width="921" alt="Screenshot 2024-04-12 at 10 59 14"
src="3fc4c5b1-da62-47c8-97a8-8bcbd1ae1b75">

--
Performance boost:
The new API is faster than the Stacktraces API, especially because
there's no logic on the Kibana side.
Stacktraces API:
<img width="1210" alt="Screenshot 2024-04-12 at 10 50 26"
src="158d73d1-ed91-4652-97c1-c7c3328d5e3d">

TopN/Functions API:
<img width="1195" alt="Screenshot 2024-04-12 at 10 51 20"
src="2de4ef46-eb8a-4557-b7b8-a1c2fed6fd8a">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2024-04-16 21:28:14 +01:00 committed by GitHub
parent d769242c37
commit b900b86c1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1267 additions and 83 deletions

View file

@ -486,6 +486,9 @@ If you're enrolled in the AWS Enterprise Discount Program (EDP), enter your disc
[[observability-profiling-azure-cost-discount-rate]]`observability:profilingAzureCostDiscountRate`::
If you have an Azure Enterprise Agreement with Microsoft, enter your discount rate to update the profiling cost calculation.
[[observability-profiling-use-topNFunctions-from-stacktraces]]`observability:profilingFetchTopNFunctionsFromStacktraces`::
Switch to fetch the TopN Functions from the Stacktraces API.
[[observability-profiling-cost-per-vcpu-per-hour]]`observability:profilingCostPervCPUPerHour`::
Default Hourly Cost per CPU Core for machines not on AWS or Azure.

View file

@ -0,0 +1,40 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface Frame {
frame_type: number;
inline: boolean;
address_or_line: number;
function_name: string;
file_name: string;
line_number: number;
executable_file_name: string;
}
interface TopNFunction {
id: string;
rank: number;
frame: Frame;
sub_groups: Record<string, number>;
self_count: number;
total_count: number;
self_annual_co2_tons: number;
total_annual_co2_tons: number;
self_annual_costs_usd: number;
total_annual_costs_usd: number;
}
export interface ESTopNFunctions {
self_count: number;
total_count: number;
self_annual_co2_tons: number;
self_annual_cost_usd: number;
topn: TopNFunction[];
}
export type AggregationField = 'service.name' | 'transaction.name';

View file

@ -49,6 +49,7 @@ type TopNFunction = Pick<
> & {
Id: string;
Rank: number;
subGroups: Record<string, number>;
};
export interface TopNFunctions {
@ -207,6 +208,7 @@ export function createTopNFunctions({
selfAnnualCostUSD: frameAndCount.selfAnnualCostUSD,
totalAnnualCO2kgs: frameAndCount.totalAnnualCO2kgs,
totalAnnualCostUSD: frameAndCount.totalAnnualCostUSD,
subGroups: {},
};
});

View file

@ -53,3 +53,4 @@ export type {
} from './common/profiling';
export type { ProfilingStatus } from './common/profiling_status';
export type { TopNFunctions } from './common/functions';
export type { AggregationField, ESTopNFunctions } from './common/es_functions';

View file

@ -671,4 +671,8 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'keyword',
_meta: { description: 'Non-default value of setting.' },
},
'observability:profilingFetchTopNFunctionsFromStacktraces': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
};

View file

@ -176,4 +176,5 @@ export interface UsageStats {
'observability:apmEnableTransactionProfiling': boolean;
'devTools:enablePersistentConsole': boolean;
'aiAssistant:preferredAIAssistantType': string;
'observability:profilingFetchTopNFunctionsFromStacktraces': boolean;
}

View file

@ -10451,6 +10451,12 @@
"_meta": {
"description": "Non-default value of setting."
}
},
"observability:profilingFetchTopNFunctionsFromStacktraces": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
}
}
},
@ -11887,4 +11893,4 @@
}
}
}
}
}

View file

@ -0,0 +1,101 @@
/*
* 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 { EuiEmptyPrompt } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { Redirect } from 'react-router-dom';
import { css } from '@emotion/react';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { getRedirectToTransactionDetailPageUrl } from '../trace_link/get_redirect_to_transaction_detail_page_url';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
export function TransactionDetailsByNameLink() {
const {
query: {
rangeFrom = 'now-15m',
rangeTo = 'now',
transactionName,
serviceName,
},
} = useApmParams('/link-to/transaction');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = { transaction: null }, status } = useFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/transactions', {
params: {
query: {
start,
end,
transactionName,
serviceName,
},
},
});
},
[start, end, transactionName, serviceName]
);
if (status === FETCH_STATUS.SUCCESS) {
if (data.transaction) {
return (
<Redirect
to={getRedirectToTransactionDetailPageUrl({
transaction: data.transaction,
rangeFrom,
rangeTo,
})}
/>
);
}
return (
<div
css={css`
height: 100%;
display: flex;
`}
>
<EuiEmptyPrompt
iconType="apmTrace"
title={
<h2>
{i18n.translate(
'xpack.apm.transactionDetailsLink.h2.transactionNotFound',
{ defaultMessage: 'No transaction found' }
)}
</h2>
}
/>
</div>
);
}
return (
<div
css={css`
height: 100%;
display: flex;
`}
>
<EuiEmptyPrompt
iconType="apmTrace"
title={
<h2>
{i18n.translate(
'xpack.apm.transactionDetailsLink.h2.fetchingTransactionLabel',
{ defaultMessage: 'Fetching transaction...' }
)}
</h2>
}
/>
</div>
);
}

View file

@ -23,6 +23,7 @@ import { ApmMainTemplate } from './templates/apm_main_template';
import { ServiceGroupsList } from '../app/service_groups';
import { offsetRt } from '../../../common/comparison_rt';
import { diagnosticsRoute } from '../app/diagnostics';
import { TransactionDetailsByNameLink } from '../app/transaction_details_link';
const ServiceGroupsTitle = i18n.translate(
'xpack.apm.views.serviceGroups.title',
@ -34,6 +35,18 @@ const ServiceGroupsTitle = i18n.translate(
* creates the routes.
*/
const apmRoutes = {
'/link-to/transaction': {
element: <TransactionDetailsByNameLink />,
params: t.type({
query: t.intersection([
t.type({ transactionName: t.string, serviceName: t.string }),
t.partial({
rangeFrom: t.string,
rangeTo: t.string,
}),
]),
}),
},
'/link-to/transaction/{transactionId}': {
element: <TransactionLink />,
params: t.intersection([
@ -69,7 +82,12 @@ const apmRoutes = {
},
'/': {
element: (
<Breadcrumb title="APM" href="/">
<Breadcrumb
title={i18n.translate('xpack.apm..breadcrumb.apmLabel', {
defaultMessage: 'APM',
})}
href="/"
>
<Outlet />
</Breadcrumb>
),

View file

@ -36,6 +36,7 @@ import {
import { getSpan } from '../transactions/get_span';
import { Transaction } from '../../../typings/es_schemas/ui/transaction';
import { Span } from '../../../typings/es_schemas/ui/span';
import { getTransactionByName } from '../transactions/get_transaction_by_name';
const tracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces',
@ -186,6 +187,42 @@ const transactionByIdRoute = createApmServerRoute({
},
});
const transactionByNameRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/transactions',
params: t.type({
query: t.intersection([
rangeRt,
t.type({
transactionName: t.string,
serviceName: t.string,
}),
]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
transaction: Transaction;
}> => {
const {
params: {
query: { start, end, transactionName, serviceName },
},
} = resources;
const apmEventClient = await getApmEventClient(resources);
return {
transaction: await getTransactionByName({
transactionName,
apmEventClient,
start,
end,
serviceName,
}),
};
},
});
const findTracesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/find',
params: t.type({
@ -338,4 +375,5 @@ export const traceRouteRepository = {
...aggregatedCriticalPathRoute,
...transactionFromTraceByIdRoute,
...spanFromTraceByIdRoute,
...transactionByNameRoute,
};

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { rangeQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import {
SERVICE_NAME,
TRANSACTION_NAME,
} from '../../../../common/es_fields/apm';
import { RollupInterval } from '../../../../common/rollup';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
export async function getTransactionByName({
transactionName,
serviceName,
apmEventClient,
start,
end,
}: {
transactionName: string;
serviceName: string;
apmEventClient: APMEventClient;
start: number;
end: number;
}) {
const resp = await apmEventClient.search('get_transaction', {
apm: {
sources: [
{
documentType: ApmDocumentType.TransactionEvent,
rollupInterval: RollupInterval.None,
},
],
},
body: {
track_total_hits: false,
size: 1,
terminate_after: 1,
query: {
bool: {
filter: asMutableArray([
{ term: { [TRANSACTION_NAME]: transactionName } },
{ term: { [SERVICE_NAME]: serviceName } },
...rangeQuery(start, end),
]),
},
},
},
});
return resp.hits.hits[0]?._source;
}

View file

@ -54,6 +54,7 @@ export {
profilingAzureCostDiscountRate,
apmEnableTransactionProfiling,
apmEnableServiceInventoryTableSearchBar,
profilingFetchTopNFunctionsFromStacktraces,
} from './ui_settings_keys';
export {

View file

@ -43,3 +43,5 @@ export const profilingAWSCostDiscountRate = 'observability:profilingAWSCostDisco
export const profilingCostPervCPUPerHour = 'observability:profilingCostPervCPUPerHour';
export const profilingAzureCostDiscountRate = 'observability:profilingAzureCostDiscountRate';
export const apmEnableTransactionProfiling = 'observability:apmEnableTransactionProfiling';
export const profilingFetchTopNFunctionsFromStacktraces =
'observability:profilingFetchTopNFunctionsFromStacktraces';

View file

@ -43,6 +43,7 @@ import {
apmEnableTransactionProfiling,
enableInfrastructureAssetCustomDashboards,
apmEnableServiceInventoryTableSearchBar,
profilingFetchTopNFunctionsFromStacktraces,
} from '../common/ui_settings_keys';
const betaLabel = i18n.translate('xpack.observability.uiSettings.betaLabel', {
@ -600,6 +601,21 @@ export const uiSettings: Record<string, UiSettings> = {
schema: schema.boolean(),
requiresPageReload: true,
},
[profilingFetchTopNFunctionsFromStacktraces]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.profilingFetchTopNFunctionsFromStacktraces', {
defaultMessage: 'Switch to fetch the TopN Functions from the Stacktraces API.',
}),
description: i18n.translate(
'xpack.observability.profilingFetchTopNFunctionsFromStacktracesDescription',
{
defaultMessage: `The topN functions pages use the topN/functions API, turn it on to switch to the stacktraces api`,
}
),
value: false,
schema: schema.boolean(),
requiresPageReload: false,
},
};
function throttlingDocsLink({ href }: { href: string }) {

View file

@ -0,0 +1,34 @@
/*
* 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 qs from 'query-string';
import type { SerializableRecord } from '@kbn/utility-types';
import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
export interface ServiceOverviewParams extends SerializableRecord {
serviceName: string;
rangeFrom?: string;
rangeTo?: string;
}
export type ServiceOverviewLocator = LocatorPublic<ServiceOverviewParams>;
export class ServiceOverviewLocatorDefinition implements LocatorDefinition<ServiceOverviewParams> {
public readonly id = 'serviceOverviewLocator';
public readonly getLocation = async ({
rangeFrom,
rangeTo,
serviceName,
}: ServiceOverviewParams) => {
const params = { rangeFrom, rangeTo };
return {
app: 'apm',
path: `/services/${serviceName}/overview?${qs.stringify(params)}`,
state: {},
};
};
}

View file

@ -0,0 +1,38 @@
/*
* 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 qs from 'query-string';
import type { SerializableRecord } from '@kbn/utility-types';
import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
export interface TransactionDetailsByNameParams extends SerializableRecord {
serviceName: string;
transactionName: string;
rangeFrom?: string;
rangeTo?: string;
}
export type TransactionDetailsByNameLocator = LocatorPublic<TransactionDetailsByNameParams>;
export class TransactionDetailsByNameLocatorDefinition
implements LocatorDefinition<TransactionDetailsByNameParams>
{
public readonly id = 'TransactionDetailsByNameLocator';
public readonly getLocation = async ({
rangeFrom,
rangeTo,
serviceName,
transactionName,
}: TransactionDetailsByNameParams) => {
const params = { rangeFrom, rangeTo, serviceName, transactionName };
return {
app: 'apm',
path: `/link-to/transaction?${qs.stringify(params)}`,
state: {},
};
};
}

View file

@ -39,7 +39,15 @@ import {
type TopNFunctionsLocator,
TopNFunctionsLocatorDefinition,
} from './locators/profiling/topn_functions_locator';
import {
type ServiceOverviewLocator,
ServiceOverviewLocatorDefinition,
} from './locators/apm/service_overview_locator';
import { updateGlobalNavigation } from './services/update_global_navigation';
import {
type TransactionDetailsByNameLocator,
TransactionDetailsByNameLocatorDefinition,
} from './locators/apm/transaction_details_by_name_locator';
export interface ObservabilitySharedSetup {
share: SharePluginSetup;
}
@ -67,6 +75,10 @@ interface ObservabilitySharedLocators {
topNFunctionsLocator: TopNFunctionsLocator;
stacktracesLocator: StacktracesLocator;
};
apm: {
serviceOverview: ServiceOverviewLocator;
transactionDetailsByName: TransactionDetailsByNameLocator;
};
}
export class ObservabilitySharedPlugin implements Plugin {
@ -131,6 +143,12 @@ export class ObservabilitySharedPlugin implements Plugin {
topNFunctionsLocator: urlService.locators.create(new TopNFunctionsLocatorDefinition()),
stacktracesLocator: urlService.locators.create(new StacktracesLocatorDefinition()),
},
apm: {
serviceOverview: urlService.locators.create(new ServiceOverviewLocatorDefinition()),
transactionDetailsByName: urlService.locators.create(
new TransactionDetailsByNameLocatorDefinition()
),
},
};
}
}

View file

@ -26,6 +26,7 @@ export function getRoutePaths() {
TopNHosts: `${BASE_ROUTE_PATH}/topn/hosts`,
TopNThreads: `${BASE_ROUTE_PATH}/topn/threads`,
TopNTraces: `${BASE_ROUTE_PATH}/topn/traces`,
APMTransactions: `${BASE_ROUTE_PATH}/topn/functions/apm/transactions`,
Flamechart: `${BASE_ROUTE_PATH}/flamechart`,
HasSetupESResources: `${BASE_ROUTE_PATH}/setup/es_resources`,
SetupDataCollectionInstructions: `${BASE_ROUTE_PATH}/setup/instructions`,

View file

@ -31,9 +31,9 @@ describe('Differential Functions page', () => {
cy.wait('@getTopNFunctions');
[
{ id: 'overallPerformance', value: '0%' },
{ id: 'annualizedCo2', value: '74.49 lbs / 33.79 kg' },
{ id: 'annualizedCost', value: '$318.32' },
{ id: 'totalNumberOfSamples', value: '513' },
{ id: 'annualizedCo2', value: '79.81 lbs / 36.2 kg' },
{ id: 'annualizedCost', value: '$341.05' },
{ id: 'totalNumberOfSamples', value: '17,186' },
].forEach((item) => {
cy.get(`[data-test-subj="${item.id}_value"]`).contains(item.value);
cy.get(`[data-test-subj="${item.id}_comparison_value"]`).should('not.exist');
@ -50,9 +50,9 @@ describe('Differential Functions page', () => {
cy.wait('@getTopNFunctions');
[
{ id: 'overallPerformance', value: '0%' },
{ id: 'annualizedCo2', value: '0 lbs / 0 kg', comparisonValue: '74.49 lbs / 33.79 kg' },
{ id: 'annualizedCost', value: '$0', comparisonValue: '$318.32' },
{ id: 'totalNumberOfSamples', value: '0', comparisonValue: '15,390' },
{ id: 'annualizedCo2', value: '0 lbs / 0 kg', comparisonValue: '79.81 lbs / 36.2 kg' },
{ id: 'annualizedCost', value: '$0', comparisonValue: '$341.05' },
{ id: 'totalNumberOfSamples', value: '0', comparisonValue: '515,580' },
].forEach((item) => {
cy.get(`[data-test-subj="${item.id}_value"]`).contains(item.value);
if (item.comparisonValue) {
@ -73,23 +73,23 @@ describe('Differential Functions page', () => {
cy.wait('@getTopNFunctions');
cy.wait('@getTopNFunctions');
[
{ id: 'overallPerformance', value: '65.89%', icon: 'sortUp_success' },
{ id: 'overallPerformance', value: '78.01%', icon: 'sortUp_success' },
{
id: 'annualizedCo2',
value: '74.49 lbs / 33.79 kg',
comparisonValue: '25.41 lbs / 11.53 kg (65.89%)',
value: '9.81 lbs / 36.2 kg',
comparisonValue: '28.23 lbs / 12.81 kg (64.62%)',
icon: 'comparison_sortUp_success',
},
{
id: 'annualizedCost',
value: '$318.32',
comparisonValue: '$108.59 (65.89%)',
value: '$341.05',
comparisonValue: '$120.65 (64.62%)',
icon: 'comparison_sortUp_success',
},
{
id: 'totalNumberOfSamples',
value: '513',
comparisonValue: '175 (65.89%)',
value: '17,186',
comparisonValue: '3,780 (78.01%)',
icon: 'comparison_sortUp_success',
},
].forEach((item) => {
@ -113,23 +113,23 @@ describe('Differential Functions page', () => {
cy.wait('@getTopNFunctions');
cy.wait('@getTopNFunctions');
[
{ id: 'overallPerformance', value: '193.14%', icon: 'sortDown_danger' },
{ id: 'overallPerformance', value: '354.66%', icon: 'sortDown_danger' },
{
id: 'annualizedCo2',
value: '25.41 lbs / 11.53 kg',
comparisonValue: '74.49 lbs / 33.79 kg (193.14%)',
value: '28.23 lbs / 12.81 kg',
comparisonValue: '79.81 lbs / 36.2 kg (182.67%)',
icon: 'comparison_sortDown_danger',
},
{
id: 'annualizedCost',
value: '$108.59',
comparisonValue: '$318.32 (193.14%)',
value: '$120.65',
comparisonValue: '$341.05 (182.67%)',
icon: 'comparison_sortDown_danger',
},
{
id: 'totalNumberOfSamples',
value: '175',
comparisonValue: '513 (193.14%)',
value: '3,780',
comparisonValue: '17,186 (354.66%)',
icon: 'comparison_sortDown_danger',
},
].forEach((item) => {

View file

@ -36,10 +36,10 @@ describe('Functions page', () => {
const firstRowSelector = '[data-grid-row-index="0"] [data-test-subj="dataGridRowCell"]';
cy.get(firstRowSelector).eq(1).contains('1');
cy.get(firstRowSelector).eq(2).contains('vmlinux');
cy.get(firstRowSelector).eq(3).contains('5.46%');
cy.get(firstRowSelector).eq(4).contains('5.46%');
cy.get(firstRowSelector).eq(5).contains('4.07 lbs / 1.84 kg');
cy.get(firstRowSelector).eq(6).contains('$17.37');
cy.get(firstRowSelector).eq(3).contains('0.16%');
cy.get(firstRowSelector).eq(4).contains('0.16%');
cy.get(firstRowSelector).eq(5).contains('4.41 lbs / 2 kg');
cy.get(firstRowSelector).eq(6).contains('$18.61');
cy.get(firstRowSelector).eq(7).contains('28');
});
@ -56,8 +56,8 @@ describe('Functions page', () => {
{ parentKey: 'informationRows', key: 'executable', value: 'vmlinux' },
{ parentKey: 'informationRows', key: 'function', value: 'N/A' },
{ parentKey: 'informationRows', key: 'sourceFile', value: 'N/A' },
{ parentKey: 'impactEstimates', key: 'totalCPU', value: '5.46%' },
{ parentKey: 'impactEstimates', key: 'selfCPU', value: '5.46%' },
{ parentKey: 'impactEstimates', key: 'totalCPU', value: '0.16%' },
{ parentKey: 'impactEstimates', key: 'selfCPU', value: '0.16%' },
{ parentKey: 'impactEstimates', key: 'samples', value: '28' },
{ parentKey: 'impactEstimates', key: 'selfSamples', value: '28' },
{ parentKey: 'impactEstimates', key: 'coreSeconds', value: '1.4 seconds' },
@ -66,16 +66,16 @@ describe('Functions page', () => {
{ parentKey: 'impactEstimates', key: 'annualizedSelfCoreSeconds', value: '17.03 days' },
{ parentKey: 'impactEstimates', key: 'co2Emission', value: '~0.00 lbs / ~0.00 kg' },
{ parentKey: 'impactEstimates', key: 'selfCo2Emission', value: '~0.00 lbs / ~0.00 kg' },
{ parentKey: 'impactEstimates', key: 'annualizedCo2Emission', value: '4.07 lbs / 1.84 kg' },
{ parentKey: 'impactEstimates', key: 'annualizedCo2Emission', value: '4.41 lbs / 2 kg' },
{
parentKey: 'impactEstimates',
key: 'annualizedSelfCo2Emission',
value: '4.07 lbs / 1.84 kg',
value: '4.41 lbs / 2 kg',
},
{ parentKey: 'impactEstimates', key: 'dollarCost', value: '$~0.00' },
{ parentKey: 'impactEstimates', key: 'selfDollarCost', value: '$~0.00' },
{ parentKey: 'impactEstimates', key: 'annualizedDollarCost', value: '$17.37' },
{ parentKey: 'impactEstimates', key: 'annualizedSelfDollarCost', value: '$17.37' },
{ parentKey: 'impactEstimates', key: 'annualizedDollarCost', value: '$18.61' },
{ parentKey: 'impactEstimates', key: 'annualizedSelfDollarCost', value: '$18.61' },
].forEach(({ parentKey, key, value }) => {
cy.get(`[data-test-subj="${parentKey}_${key}"]`).contains(value);
});
@ -118,32 +118,32 @@ describe('Functions page', () => {
columnIndex: 3,
highRank: 1,
lowRank: 389,
highValue: '5.46%',
highValue: '0.16%',
lowValue: '0.00%',
},
{
columnKey: 'totalCPU',
columnIndex: 4,
highRank: 3623,
highRank: 693,
lowRank: 44,
highValue: '60.43%',
lowValue: '0.19%',
highValue: '1.80%',
lowValue: '0.01%',
},
{
columnKey: 'annualizedCo2',
columnIndex: 5,
highRank: 1,
highRank: 693,
lowRank: 44,
highValue: '45.01 lbs / 20.42 kg',
lowValue: '0.15 lbs / 0.07 kg',
highValue: '48.28 lbs / 21.9 kg',
lowValue: '0 lbs / 0 kg',
},
{
columnKey: 'annualizedDollarCost',
columnIndex: 6,
highRank: 1,
highRank: 693,
lowRank: 44,
highValue: '$192.36',
lowValue: '$0.62',
highValue: '$206.09',
lowValue: '$0.66',
},
].forEach(({ columnKey, columnIndex, highRank, highValue, lowRank, lowValue }) => {
cy.get(`[data-test-subj="dataGridHeaderCell-${columnKey}"]`).click();
@ -170,7 +170,7 @@ describe('Functions page', () => {
cy.get('[data-test-subj="dataGridHeaderCell-frame"]').click();
cy.contains('Sort A-Z').click();
cy.get(firstRowSelector).eq(1).contains('371');
cy.get(firstRowSelector).eq(1).contains('88');
cy.get(firstRowSelector).eq(2).contains('/');
});
@ -189,7 +189,7 @@ describe('Functions page', () => {
const firstRowSelector = '[data-grid-row-index="0"] [data-test-subj="dataGridRowCell"]';
cy.get(firstRowSelector).eq(1).contains('1');
cy.get(firstRowSelector).eq(2).contains('vmlinux');
cy.get(firstRowSelector).eq(5).contains('4.07 lbs / 1.84 kg');
cy.get(firstRowSelector).eq(5).contains('4.41 lbs / 2 kg');
cy.contains('Settings').click();
cy.contains('Advanced Settings');
cy.get(`[data-test-subj="management-settings-editField-${profilingCo2PerKWH}"]`)
@ -208,7 +208,7 @@ describe('Functions page', () => {
});
cy.go('back');
cy.wait('@getTopNFunctions');
cy.get(firstRowSelector).eq(5).contains('1.87k lbs / 847.83 kg');
cy.get(firstRowSelector).eq(5).contains('2k lbs / 908.4 kg');
const firstRowSelectorActionButton =
'[data-grid-row-index="0"] [data-test-subj="dataGridRowCell"] .euiButtonIcon';
cy.get(firstRowSelectorActionButton).click();
@ -218,12 +218,12 @@ describe('Functions page', () => {
{
parentKey: 'impactEstimates',
key: 'annualizedCo2Emission',
value: '1.87k lbs / 847.83 kg',
value: '2k lbs / 908.4 kg',
},
{
parentKey: 'impactEstimates',
key: 'annualizedSelfCo2Emission',
value: '1.87k lbs / 847.83 kg',
value: '2k lbs / 908.4 kg',
},
].forEach(({ parentKey, key, value }) => {
cy.get(`[data-test-subj="${parentKey}_${key}"]`).contains(value);

View file

@ -89,8 +89,8 @@ describe('Storage explorer page', () => {
cy.wait('@indicesDetails');
cy.get('table > tbody tr.euiTableRow').should('have.length', 10);
});
it('displays a chart with percentage of each index', () => {
// Skipping it we should not rely on dom elements from third-level libraries to write our tests
it.skip('displays a chart with percentage of each index', () => {
cy.intercept('GET', '/internal/profiling/storage_explorer/indices_storage_details?*').as(
'indicesDetails'
);
@ -108,6 +108,7 @@ describe('Storage explorer page', () => {
];
cy.get('.echChartPointerContainer table tbody tr').each(($row, idx) => {
// These are no longer valid elements on charts
cy.wrap($row).find('th').contains(indices[idx].index);
cy.wrap($row).find('td').contains(indices[idx].perc);
});

View file

@ -13,7 +13,8 @@
"security",
"cloud",
"fleet",
"observabilityAIAssistant"
"observabilityAIAssistant",
"apmDataAccess",
],
"requiredPlugins": [
"charts",

View file

@ -0,0 +1,306 @@
/*
* 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 {
Comparators,
Criteria,
EuiBasicTable,
EuiBasicTableColumn,
EuiFieldSearch,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import React, { useMemo, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { NOT_AVAILABLE_LABEL } from '../../../common';
import { AsyncStatus } from '../../hooks/use_async';
import { useAnyOfProfilingParams } from '../../hooks/use_profiling_params';
import { useTimeRange } from '../../hooks/use_time_range';
import { useTimeRangeAsync } from '../../hooks/use_time_range_async';
import { asNumber } from '../../utils/formatters/as_number';
import { useProfilingDependencies } from '../contexts/profiling_dependencies/use_profiling_dependencies';
interface Props {
serviceNames: Record<string, number>;
functionName: string;
}
interface ServicesAndTransactions {
serviceName: string;
serviceSamples: number;
transactionName: string | null;
transactionSamples: number | null;
}
const findServicesAndTransactions = (
servicesAndTransactions: ServicesAndTransactions[],
pageIndex: number,
pageSize: number,
sortField: keyof ServicesAndTransactions,
sortDirection: 'asc' | 'desc',
filter: string
) => {
let filteredItems: ServicesAndTransactions[] = servicesAndTransactions;
if (!isEmpty(filter)) {
filteredItems = servicesAndTransactions.filter((item) => item.serviceName.includes(filter));
}
let sortedItems: ServicesAndTransactions[];
if (sortField) {
sortedItems = filteredItems
.slice(0)
.sort(Comparators.property(sortField, Comparators.default(sortDirection)));
} else {
sortedItems = filteredItems;
}
let pageOfItems;
if (!pageIndex && !pageSize) {
pageOfItems = sortedItems;
} else {
const startIndex = pageIndex * pageSize;
pageOfItems = sortedItems.slice(
startIndex,
Math.min(startIndex + pageSize, filteredItems.length)
);
}
return {
pageOfItems,
totalItemCount: filteredItems.length,
};
};
export function APMTransactions({ functionName, serviceNames }: Props) {
const {
query: { rangeFrom, rangeTo },
} = useAnyOfProfilingParams('/functions/*', '/flamegraphs/*');
const timeRange = useTimeRange({ rangeFrom, rangeTo });
const {
services: { fetchTopNFunctionAPMTransactions },
setup: { observabilityShared },
} = useProfilingDependencies();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState<keyof ServicesAndTransactions>('serviceSamples');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [filter, setFilter] = useState('');
const [filterDebounced, setFilterDebounced] = useState('');
useDebounce(
() => {
setFilterDebounced(filter);
},
500,
[filter]
);
const onTableChange = ({ page, sort }: Criteria<ServicesAndTransactions>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
if (sort) {
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
}
};
const initialServices: ServicesAndTransactions[] = useMemo(() => {
return Object.keys(serviceNames).map((key) => {
const samples = serviceNames[key];
return {
serviceName: key,
serviceSamples: samples,
transactionName: null,
transactionSamples: null,
};
});
}, [serviceNames]);
const { pageOfItems, totalItemCount } = useMemo(
() =>
findServicesAndTransactions(
initialServices,
pageIndex,
pageSize,
sortField,
sortDirection,
filterDebounced
),
[initialServices, pageIndex, pageSize, sortField, sortDirection, filterDebounced]
);
const { status, data: transactionsPerServiceMap = pageOfItems } = useTimeRangeAsync(
({ http }) => {
const serviceNamesToSearch = pageOfItems.map((item) => item.serviceName).sort();
if (serviceNamesToSearch.length) {
return fetchTopNFunctionAPMTransactions({
http,
timeFrom: new Date(timeRange.start).getTime(),
timeTo: new Date(timeRange.end).getTime(),
functionName,
serviceNames: serviceNamesToSearch,
}).then((resp) => {
return pageOfItems.flatMap((item) => {
const transactionDetails = resp[item.serviceName];
if (transactionDetails?.transactions?.length) {
return transactionDetails.transactions.map((transaction) => ({
...item,
transactionName: transaction.name,
transactionSamples: transaction.samples,
}));
}
return [item];
});
});
}
return Promise.resolve(pageOfItems);
},
[fetchTopNFunctionAPMTransactions, functionName, pageOfItems, timeRange.end, timeRange.start]
);
const isLoadingTransactions = status !== AsyncStatus.Settled;
const columns: Array<EuiBasicTableColumn<ServicesAndTransactions>> = useMemo(
() => [
{
field: 'serviceName',
name: i18n.translate('xpack.profiling.apmTransactions.columns.serviceName', {
defaultMessage: 'Service Name',
}),
truncateText: true,
sortable: true,
render: (_, { serviceName }) => {
return (
<EuiLink
data-test-subj="profilingColumnsLink"
href={observabilityShared.locators.apm.serviceOverview.getRedirectUrl({
serviceName,
rangeFrom,
rangeTo,
})}
>
{serviceName}
</EuiLink>
);
},
},
{
field: 'serviceSamples',
name: i18n.translate('xpack.profiling.apmTransactions.columns.serviceSamplesName', {
defaultMessage: 'Service Samples',
}),
width: '150px',
sortable: true,
render(_, { serviceSamples }) {
return asNumber(serviceSamples);
},
},
{
field: 'transactionName',
name: i18n.translate('xpack.profiling.apmTransactions.columns.transactionName', {
defaultMessage: 'Transaction Name',
}),
truncateText: true,
render(_, { serviceName, transactionName }) {
if (isLoadingTransactions) {
return '--';
}
if (transactionName) {
return (
<EuiLink
data-test-subj="profilingColumnsLink"
href={observabilityShared.locators.apm.transactionDetailsByName.getRedirectUrl({
serviceName,
transactionName,
rangeFrom,
rangeTo,
})}
>
{transactionName}
</EuiLink>
);
}
return NOT_AVAILABLE_LABEL;
},
},
{
field: 'transactionSamples',
name: i18n.translate('xpack.profiling.apmTransactions.columns.transactionSamples', {
defaultMessage: 'Transaction Samples',
}),
width: '150px',
render(_, { transactionSamples }) {
if (isLoadingTransactions) {
return '--';
}
if (transactionSamples === null) {
return NOT_AVAILABLE_LABEL;
}
return asNumber(transactionSamples);
},
},
],
[
isLoadingTransactions,
observabilityShared.locators.apm.serviceOverview,
observabilityShared.locators.apm.transactionDetailsByName,
rangeFrom,
rangeTo,
]
);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFieldSearch
data-test-subj="profilingAPMTransactionsFieldText"
value={filter}
onChange={(e) => setFilter(e.target.value)}
isClearable
fullWidth
placeholder={i18n.translate('xpack.profiling.apmTransactions.searchPlaceholder', {
defaultMessage: 'Search services by name',
})}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiBasicTable
loading={isLoadingTransactions}
tableCaption={i18n.translate('xpack.profiling.apmTransactions.tableCaption', {
defaultMessage: 'APM Services and Transactions links',
})}
items={transactionsPerServiceMap}
columns={columns}
pagination={{
pageIndex,
pageSize,
totalItemCount,
showPerPageOptions: false,
}}
sorting={{
sort: {
field: sortField,
direction: sortDirection,
},
}}
onChange={onTableChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -8,6 +8,7 @@
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import type { Message } from '@kbn/observability-ai-assistant-plugin/public';
import { EuiFlexItem } from '@elastic/eui';
import { Frame } from '.';
import { useProfilingDependencies } from '../contexts/profiling_dependencies/use_profiling_dependencies';
@ -78,12 +79,14 @@ export function FrameInformationAIAssistant({ frame }: Props) {
return (
<>
{observabilityAIAssistant?.ObservabilityAIAssistantContextualInsight && promptMessages ? (
<observabilityAIAssistant.ObservabilityAIAssistantContextualInsight
messages={promptMessages}
title={i18n.translate('xpack.profiling.frameInformationWindow.optimizeFunction', {
defaultMessage: 'Optimize function',
})}
/>
<EuiFlexItem>
<observabilityAIAssistant.ObservabilityAIAssistantContextualInsight
messages={promptMessages}
title={i18n.translate('xpack.profiling.frameInformationWindow.optimizeFunction', {
defaultMessage: 'Optimize function',
})}
/>
</EuiFlexItem>
) : null}
</>
);

View file

@ -4,12 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiTitle } from '@elastic/eui';
import {
EuiAccordion,
EuiAccordionProps,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiStat,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FrameSymbolStatus, getFrameSymbolStatus } from '@kbn/profiling-utils';
import React from 'react';
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import { useCalculateImpactEstimate } from '../../hooks/use_calculate_impact_estimates';
import { FramesSummary } from '../frames_summary';
import { APMTransactions } from './apm_transactions';
import { EmptyFrame } from './empty_frame';
import { FrameInformationAIAssistant } from './frame_information_ai_assistant';
import { FrameInformationPanel } from './frame_information_panel';
@ -32,6 +44,7 @@ export interface Frame {
totalAnnualCO2Kgs: number;
selfAnnualCostUSD: number;
totalAnnualCostUSD: number;
subGroups?: Record<string, number>;
}
export interface Props {
@ -59,6 +72,7 @@ export function FrameInformationWindow({
rank,
compressed = false,
}: Props) {
const [accordionState, setAccordionState] = useState<EuiAccordionProps['forceState']>('closed');
const calculateImpactEstimates = useCalculateImpactEstimate();
if (!frame) {
@ -79,6 +93,7 @@ export function FrameInformationWindow({
functionName,
sourceFileName,
sourceLine,
subGroups = {},
} = frame;
const informationRows = getInformationRows({
@ -143,14 +158,59 @@ export function FrameInformationWindow({
))}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<FrameInformationAIAssistant frame={frame} />
</EuiFlexItem>
<FrameInformationAIAssistant frame={frame} />
{showSymbolsStatus && symbolStatus !== FrameSymbolStatus.SYMBOLIZED ? (
<EuiFlexItem>
<MissingSymbolsCallout frameType={frame.frameType} />
</EuiFlexItem>
) : null}
{isEmpty(subGroups) ? null : (
<EuiFlexItem>
<EuiAccordion
id="apmTransactions"
borders="horizontal"
buttonProps={{ paddingSize: 'm' }}
buttonContent={
<div>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type="apmApp" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.profiling.frameInformationWindow.apmTransactions',
{ defaultMessage: 'Distributed Tracing Correlation' }
)}
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiText size="s">
<p>
<EuiTextColor color="subdued">
{i18n.translate(
'xpack.profiling.frameInformationWindow.apmTransactions.description',
{
defaultMessage:
'A curated view of APM services and transactions that call this function.',
}
)}
</EuiTextColor>
</p>
</EuiText>
</div>
}
forceState={accordionState}
onToggle={(isOpen) => setAccordionState(isOpen ? 'open' : 'closed')}
>
{accordionState === 'open' ? (
<APMTransactions functionName={functionName} serviceNames={subGroups} />
) : null}
</EuiAccordion>
</EuiFlexItem>
)}
<EuiFlexItem>
<FramesSummary
compressed={compressed}

View file

@ -48,6 +48,7 @@ export interface IFunctionRow {
selfAnnualCostUSD: number;
totalAnnualCO2kgs: number;
totalAnnualCostUSD: number;
subGroups?: Record<string, number>;
diff?: {
rank: number;
samples: number;
@ -149,6 +150,7 @@ export function getFunctionsRows({
selfAnnualCostUSD: topN.selfAnnualCostUSD,
totalAnnualCO2kgs: topN.totalAnnualCO2kgs,
totalAnnualCostUSD: topN.totalAnnualCostUSD,
subGroups: topN.subGroups,
diff: calculateDiff(),
};
});
@ -214,5 +216,6 @@ export function convertRowToFrame(row: IFunctionRow) {
totalAnnualCO2Kgs: row.totalAnnualCO2kgs,
selfAnnualCostUSD: row.selfAnnualCostUSD,
totalAnnualCostUSD: row.totalAnnualCostUSD,
subGroups: row.subGroups,
};
}

View file

@ -23,6 +23,13 @@ import { TopNResponse } from '../common/topn';
import type { SetupDataCollectionInstructions } from '../server/routes/setup/get_cloud_setup_instructions';
import { AutoAbortedHttpService } from './hooks/use_auto_aborted_http_client';
export interface APMTransactionsPerService {
[serviceName: string]: {
serviceName: string;
transactions: Array<{ name: string | null; samples: number | null }>;
};
}
export interface ProfilingSetupStatus {
has_setup: boolean;
has_data: boolean;
@ -77,6 +84,13 @@ export interface Services {
http: AutoAbortedHttpService;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
}) => Promise<IndicesStorageDetailsAPIResponse>;
fetchTopNFunctionAPMTransactions: (params: {
http: AutoAbortedHttpService;
timeFrom: number;
timeTo: number;
functionName: string;
serviceNames: string[];
}) => Promise<APMTransactionsPerService>;
}
export function getServices(): Services {
@ -167,5 +181,16 @@ export function getServices(): Services {
)) as IndicesStorageDetailsAPIResponse;
return eventsMetricsSizeTimeseries;
},
fetchTopNFunctionAPMTransactions: ({ functionName, http, serviceNames, timeFrom, timeTo }) => {
const query: HttpFetchQuery = {
timeFrom,
timeTo,
functionName,
serviceNames: JSON.stringify(serviceNames),
};
return http.get(paths.APMTransactions, {
query,
}) as Promise<APMTransactionsPerService>;
},
};
}

View file

@ -27,6 +27,7 @@ import {
profilingAzureCostDiscountRate,
profilingCostPervCPUPerHour,
profilingShowErrorFrames,
profilingFetchTopNFunctionsFromStacktraces,
} from '@kbn/observability-plugin/common';
import { useEditableSettings, useUiTracker } from '@kbn/observability-shared-plugin/public';
import { isEmpty } from 'lodash';
@ -53,7 +54,7 @@ const costSettings = [
profilingAzureCostDiscountRate,
profilingCostPervCPUPerHour,
];
const miscSettings = [profilingShowErrorFrames];
const miscSettings = [profilingShowErrorFrames, profilingFetchTopNFunctionsFromStacktraces];
export function Settings() {
const trackProfilingEvent = useUiTracker({ app: 'profiling' });

View file

@ -0,0 +1,119 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { termQuery } from '@kbn/observability-plugin/server';
import { keyBy } from 'lodash';
import { IDLE_SOCKET_TIMEOUT, RouteRegisterParameters } from '.';
import { getRoutePaths } from '../../common';
import { handleRouteHandlerError } from '../utils/handle_route_error_handler';
import { getClient } from './compat';
const querySchema = schema.object({
timeFrom: schema.number(),
timeTo: schema.number(),
functionName: schema.string(),
serviceNames: schema.arrayOf(schema.string()),
});
type QuerySchemaType = TypeOf<typeof querySchema>;
export function registerTopNFunctionsAPMTransactionsRoute({
router,
logger,
dependencies: {
start: { profilingDataAccess },
setup: { apmDataAccess },
},
}: RouteRegisterParameters) {
const paths = getRoutePaths();
router.get(
{
path: paths.APMTransactions,
options: {
tags: ['access:profiling', 'access:apm'],
timeout: { idleSocket: IDLE_SOCKET_TIMEOUT },
},
validate: { query: querySchema },
},
async (context, request, response) => {
try {
if (!apmDataAccess) {
return response.ok({
body: [],
});
}
const core = await context.core;
const { transaction: transactionIndices } = await apmDataAccess.getApmIndices(
core.savedObjects.client
);
const esClient = await getClient(context);
const { timeFrom, timeTo, functionName, serviceNames }: QuerySchemaType = request.query;
const startSecs = timeFrom / 1000;
const endSecs = timeTo / 1000;
const transactionsPerService = await Promise.all(
serviceNames.slice(0, 5).map(async (serviceName) => {
const apmFunctions = await profilingDataAccess.services.fetchESFunctions({
core,
esClient,
query: {
bool: {
filter: [
...termQuery('service.name', serviceName),
{
range: {
['@timestamp']: {
gte: String(startSecs),
lt: String(endSecs),
format: 'epoch_second',
},
},
},
],
},
},
aggregationField: 'transaction.name',
indices: transactionIndices.split(','),
stacktraceIdsField: 'transaction.profiler_stack_trace_ids',
limit: 1000,
});
const apmFunction = apmFunctions.TopN.find(
(topNFunction) => topNFunction.Frame.FunctionName === functionName
);
if (apmFunction?.subGroups) {
const subGroups = apmFunction.subGroups;
return {
serviceName,
transactions: Object.keys(subGroups).map((key) => ({
name: key,
samples: subGroups[key],
})),
};
}
})
);
const transactionsGroupedByService = keyBy(transactionsPerService, 'serviceName');
return response.ok({
body: transactionsGroupedByService,
});
} catch (error) {
return handleRouteHandlerError({
error,
logger,
response,
message: 'Error while fetching TopN functions',
});
}
}
);
}

View file

@ -7,6 +7,7 @@
import { schema, TypeOf } from '@kbn/config-schema';
import { kqlQuery } from '@kbn/observability-plugin/server';
import { profilingFetchTopNFunctionsFromStacktraces } from '@kbn/observability-plugin/common';
import { IDLE_SOCKET_TIMEOUT, RouteRegisterParameters } from '.';
import { getRoutePaths } from '../../common';
import { handleRouteHandlerError } from '../utils/handle_route_error_handler';
@ -45,29 +46,43 @@ export function registerTopNFunctionsSearchRoute({
const endSecs = timeTo / 1000;
const esClient = await getClient(context);
const topNFunctions = await profilingDataAccess.services.fetchFunctions({
core,
esClient,
startIndex,
endIndex,
totalSeconds: endSecs - startSecs,
query: {
bool: {
filter: [
...kqlQuery(kuery),
{
range: {
['@timestamp']: {
gte: String(startSecs),
lt: String(endSecs),
format: 'epoch_second',
},
const query = {
bool: {
filter: [
...kqlQuery(kuery),
{
range: {
['@timestamp']: {
gte: String(startSecs),
lt: String(endSecs),
format: 'epoch_second',
},
},
],
},
},
],
},
});
};
const useStacktracesAPI = await core.uiSettings.client.get<boolean>(
profilingFetchTopNFunctionsFromStacktraces
);
const topNFunctions = useStacktracesAPI
? await profilingDataAccess.services.fetchFunctions({
core,
esClient,
startIndex,
endIndex,
totalSeconds: endSecs - startSecs,
query,
})
: await profilingDataAccess.services.fetchESFunctions({
core,
esClient,
query,
aggregationField: 'service.name',
});
return response.ok({
body: topNFunctions,

View file

@ -16,6 +16,7 @@ import {
TelemetryUsageCounter,
} from '../types';
import { ProfilingESClient } from '../utils/create_profiling_es_client';
import { registerTopNFunctionsAPMTransactionsRoute } from './apm';
import { registerFlameChartSearchRoute } from './flamechart';
import { registerTopNFunctionsSearchRoute } from './functions';
import { registerSetupRoute } from './setup/route';
@ -61,4 +62,5 @@ export function registerRoutes(params: RouteRegisterParameters) {
// and will show instructions on how to add data
registerSetupRoute(params);
registerStorageExplorerRoute(params);
registerTopNFunctionsAPMTransactionsRoute(params);
}

View file

@ -69,6 +69,7 @@ describe('TopN data from Elasticsearch', () => {
},
}) as Promise<any>
),
topNFunctions: jest.fn(),
};
const logger = loggerMock.create();

View file

@ -17,6 +17,10 @@ import {
ProfilingDataAccessPluginStart,
} from '@kbn/profiling-data-access-plugin/server';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import {
ApmDataAccessPluginSetup,
ApmDataAccessPluginStart,
} from '@kbn/apm-data-access-plugin/server';
export interface ProfilingPluginSetupDeps {
observability: ObservabilityPluginSetup;
@ -27,6 +31,7 @@ export interface ProfilingPluginSetupDeps {
usageCollection?: UsageCollectionSetup;
profilingDataAccess: ProfilingDataAccessPluginSetup;
security?: SecurityPluginSetup;
apmDataAccess?: ApmDataAccessPluginSetup;
}
export interface ProfilingPluginStartDeps {
@ -37,6 +42,7 @@ export interface ProfilingPluginStartDeps {
spaces?: SpacesPluginStart;
profilingDataAccess: ProfilingDataAccessPluginStart;
security?: SecurityPluginStart;
apmDataAccess?: ApmDataAccessPluginStart;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface

View file

@ -11,7 +11,9 @@ import type { KibanaRequest } from '@kbn/core/server';
import { unwrapEsResponse } from '@kbn/observability-plugin/server';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type {
AggregationField,
BaseFlameGraph,
ESTopNFunctions,
ProfilingStatusResponse,
StackTraceResponse,
} from '@kbn/profiling-utils';
@ -45,6 +47,21 @@ export interface ProfilingESClient {
query: QueryDslQueryContainer;
sampleSize: number;
}): Promise<BaseFlameGraph>;
topNFunctions(params: {
query: QueryDslQueryContainer;
limit?: number;
sampleSize?: number;
indices?: string[];
stacktraceIdsField?: string;
aggregationField?: AggregationField;
co2PerKWH?: number;
datacenterPUE?: number;
pervCPUWattX86?: number;
pervCPUWattArm64?: number;
awsCostDiscountRate?: number;
azureCostDiscountRate?: number;
costPervCPUPerHour?: number;
}): Promise<ESTopNFunctions>;
}
export function createProfilingEsClient({
@ -151,5 +168,51 @@ export function createProfilingEsClient({
});
return unwrapEsResponse(promise) as Promise<BaseFlameGraph>;
},
topNFunctions({
query,
aggregationField,
indices,
stacktraceIdsField,
co2PerKWH,
datacenterPUE,
awsCostDiscountRate,
costPervCPUPerHour,
pervCPUWattArm64,
pervCPUWattX86,
azureCostDiscountRate,
sampleSize,
limit,
}) {
const controller = new AbortController();
const promise = withProfilingSpan('_profiling/topn/functions', () => {
return esClient.transport.request(
{
method: 'POST',
path: encodeURI('/_profiling/topn/functions'),
body: {
query,
sample_size: sampleSize,
limit,
indices,
stacktrace_ids_field: stacktraceIdsField,
aggregation_field: aggregationField,
co2_per_kwh: co2PerKWH,
per_core_watt_x86: pervCPUWattX86,
per_core_watt_arm64: pervCPUWattArm64,
datacenter_pue: datacenterPUE,
aws_cost_factor: awsCostDiscountRate,
cost_per_core_hour: costPervCPUPerHour,
azure_cost_factor: azureCostDiscountRate,
},
},
{
signal: controller.signal,
meta: true,
}
);
});
return unwrapEsResponse(promise) as Promise<ESTopNFunctions>;
},
};
}

View file

@ -53,7 +53,8 @@
"@kbn/security-plugin",
"@kbn/shared-ux-utility",
"@kbn/management-settings-components-field-row",
"@kbn/deeplinks-observability"
"@kbn/deeplinks-observability",
"@kbn/apm-data-access-plugin"
// add references to other TypeScript projects the plugin depends on
// requiredPlugins from ./kibana.json

View file

@ -9,7 +9,9 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWith
import { ElasticsearchClient } from '@kbn/core/server';
import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import type {
AggregationField,
BaseFlameGraph,
ESTopNFunctions,
ProfilingStatusResponse,
StackTraceResponse,
} from '@kbn/profiling-utils';
@ -49,4 +51,19 @@ export interface ProfilingESClient {
indices?: string[];
stacktraceIdsField?: string;
}): Promise<BaseFlameGraph>;
topNFunctions(params: {
query: QueryDslQueryContainer;
limit?: number;
sampleSize?: number;
indices?: string[];
stacktraceIdsField?: string;
aggregationField?: AggregationField;
co2PerKWH?: number;
datacenterPUE?: number;
pervCPUWattX86?: number;
pervCPUWattArm64?: number;
awsCostDiscountRate?: number;
azureCostDiscountRate?: number;
costPervCPUPerHour?: number;
}): Promise<ESTopNFunctions>;
}

View file

@ -0,0 +1,129 @@
/*
* 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 {
profilingAWSCostDiscountRate,
profilingCo2PerKWH,
profilingCostPervCPUPerHour,
profilingDatacenterPUE,
profilingPervCPUWattArm64,
profilingPervCPUWattX86,
profilingAzureCostDiscountRate,
profilingShowErrorFrames,
} from '@kbn/observability-plugin/common';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { CoreRequestHandlerContext, ElasticsearchClient } from '@kbn/core/server';
import {
AggregationField,
convertTonsToKgs,
ESTopNFunctions,
TopNFunctions,
} from '@kbn/profiling-utils';
import { RegisterServicesParams } from '../register_services';
import { percentToFactor } from '../../utils/percent_to_factor';
export interface FetchFunctionsParams {
core: CoreRequestHandlerContext;
esClient: ElasticsearchClient;
indices?: string[];
stacktraceIdsField?: string;
query: QueryDslQueryContainer;
aggregationField?: AggregationField;
limit?: number;
}
const targetSampleSize = 20000; // minimum number of samples to get statistically sound results
export function createFetchESFunctions({ createProfilingEsClient }: RegisterServicesParams) {
return async ({
core,
esClient,
indices,
stacktraceIdsField,
query,
aggregationField,
limit,
}: FetchFunctionsParams) => {
const [
co2PerKWH,
datacenterPUE,
pervCPUWattX86,
pervCPUWattArm64,
awsCostDiscountRate,
costPervCPUPerHour,
azureCostDiscountRate,
] = await Promise.all([
core.uiSettings.client.get<number>(profilingCo2PerKWH),
core.uiSettings.client.get<number>(profilingDatacenterPUE),
core.uiSettings.client.get<number>(profilingPervCPUWattX86),
core.uiSettings.client.get<number>(profilingPervCPUWattArm64),
core.uiSettings.client.get<number>(profilingAWSCostDiscountRate),
core.uiSettings.client.get<number>(profilingCostPervCPUPerHour),
core.uiSettings.client.get<number>(profilingAzureCostDiscountRate),
core.uiSettings.client.get<boolean>(profilingShowErrorFrames),
]);
const profilingEsClient = createProfilingEsClient({ esClient });
const esTopNFunctions = await profilingEsClient.topNFunctions({
sampleSize: targetSampleSize,
limit,
query,
indices,
stacktraceIdsField,
aggregationField,
co2PerKWH,
datacenterPUE,
pervCPUWattX86,
pervCPUWattArm64,
awsCostDiscountRate: percentToFactor(awsCostDiscountRate),
costPervCPUPerHour,
azureCostDiscountRate: percentToFactor(azureCostDiscountRate),
});
return transformToKibanaTopNFunction(esTopNFunctions);
};
}
/**
* Transforms object returned by ES because we share a lot of components in the UI with the current data model
* We must first align the ES api response type then remove this
*/
function transformToKibanaTopNFunction(esTopNFunctions: ESTopNFunctions): TopNFunctions {
return {
TotalCount: esTopNFunctions.total_count,
totalCPU: esTopNFunctions.total_count,
selfCPU: esTopNFunctions.self_count,
totalAnnualCO2Kgs: convertTonsToKgs(esTopNFunctions.self_annual_co2_tons),
totalAnnualCostUSD: esTopNFunctions.self_annual_cost_usd,
SamplingRate: 1,
TopN: esTopNFunctions.topn.map((item) => {
return {
Id: item.id,
Rank: item.rank,
CountExclusive: item.self_count,
CountInclusive: item.total_count,
selfAnnualCO2kgs: convertTonsToKgs(item.self_annual_co2_tons),
selfAnnualCostUSD: item.self_annual_costs_usd,
totalAnnualCO2kgs: convertTonsToKgs(item.total_annual_co2_tons),
totalAnnualCostUSD: item.total_annual_costs_usd,
subGroups: item.sub_groups,
Frame: {
AddressOrLine: item.frame.address_or_line,
ExeFileName: item.frame.executable_file_name,
FrameType: item.frame.frame_type,
FunctionName: item.frame.function_name,
Inline: item.frame.inline,
SourceFilename: item.frame.file_name,
SourceLine: item.frame.line_number,
FileID: '',
FrameID: '',
FunctionOffset: 0,
},
};
}),
};
}

View file

@ -13,6 +13,7 @@ import { createGetStatusService } from './status';
import { ProfilingESClient } from '../../common/profiling_es_client';
import { createFetchFunctions } from './functions';
import { createSetupState } from './setup_state';
import { createFetchESFunctions } from './functions/es_functions';
export interface RegisterServicesParams {
createProfilingEsClient: (params: {
@ -31,6 +32,8 @@ export function registerServices(params: RegisterServicesParams) {
fetchFlamechartData: createFetchFlamechart(params),
getStatus: createGetStatusService(params),
getSetupState: createSetupState(params),
// Legacy fetch functions api based on stacktraces
fetchFunctions: createFetchFunctions(params),
fetchESFunctions: createFetchESFunctions(params),
};
}

View file

@ -9,6 +9,7 @@ import { ElasticsearchClient } from '@kbn/core/server';
import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import type {
BaseFlameGraph,
ESTopNFunctions,
ProfilingStatusResponse,
StackTraceResponse,
} from '@kbn/profiling-utils';
@ -150,5 +151,51 @@ export function createProfilingEsClient({
});
return unwrapEsResponse(promise) as Promise<BaseFlameGraph>;
},
topNFunctions({
query,
aggregationField,
indices,
stacktraceIdsField,
co2PerKWH,
datacenterPUE,
awsCostDiscountRate,
costPervCPUPerHour,
pervCPUWattArm64,
pervCPUWattX86,
azureCostDiscountRate,
sampleSize,
limit,
}) {
const controller = new AbortController();
const promise = withProfilingSpan('_profiling/topn/functions', () => {
return esClient.transport.request(
{
method: 'POST',
path: encodeURI('/_profiling/topn/functions'),
body: {
query,
sample_size: sampleSize,
limit,
indices,
stacktrace_ids_field: stacktraceIdsField,
aggregation_field: aggregationField,
co2_per_kwh: co2PerKWH,
per_core_watt_x86: pervCPUWattX86,
per_core_watt_arm64: pervCPUWattArm64,
datacenter_pue: datacenterPUE,
aws_cost_factor: awsCostDiscountRate,
cost_per_core_hour: costPervCPUPerHour,
azure_cost_factor: azureCostDiscountRate,
},
},
{
signal: controller.signal,
meta: true,
}
);
});
return unwrapEsResponse(promise) as Promise<ESTopNFunctions>;
},
};
}