diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 4ab341571668e6..9e400f01c88ddb 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -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. diff --git a/packages/kbn-profiling-utils/common/es_functions.ts b/packages/kbn-profiling-utils/common/es_functions.ts new file mode 100644 index 00000000000000..25b281142f7fd9 --- /dev/null +++ b/packages/kbn-profiling-utils/common/es_functions.ts @@ -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; + 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'; diff --git a/packages/kbn-profiling-utils/common/functions.ts b/packages/kbn-profiling-utils/common/functions.ts index 7fcf8df2025ee8..0aa7cde3ca0231 100644 --- a/packages/kbn-profiling-utils/common/functions.ts +++ b/packages/kbn-profiling-utils/common/functions.ts @@ -49,6 +49,7 @@ type TopNFunction = Pick< > & { Id: string; Rank: number; + subGroups: Record; }; export interface TopNFunctions { @@ -207,6 +208,7 @@ export function createTopNFunctions({ selfAnnualCostUSD: frameAndCount.selfAnnualCostUSD, totalAnnualCO2kgs: frameAndCount.totalAnnualCO2kgs, totalAnnualCostUSD: frameAndCount.totalAnnualCostUSD, + subGroups: {}, }; }); diff --git a/packages/kbn-profiling-utils/index.ts b/packages/kbn-profiling-utils/index.ts index a2c582009c7855..4978c659373881 100644 --- a/packages/kbn-profiling-utils/index.ts +++ b/packages/kbn-profiling-utils/index.ts @@ -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'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 8a40bd598e1c69..3235cab4acd6d6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -667,4 +667,8 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'keyword', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:profilingFetchTopNFunctionsFromStacktraces': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index e25ebd87afa9a7..9507f11307dcaf 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -175,4 +175,5 @@ export interface UsageStats { 'observability:apmEnableTransactionProfiling': boolean; 'devTools:enablePersistentConsole': boolean; 'aiAssistant:preferredAIAssistantType': string; + 'observability:profilingFetchTopNFunctionsFromStacktraces': boolean; } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 8c6c605bd6c720..43b929d00deb57 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -10445,6 +10445,12 @@ "_meta": { "description": "Non-default value of setting." } + }, + "observability:profilingFetchTopNFunctionsFromStacktraces": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } } } }, @@ -11881,4 +11887,4 @@ } } } -} +} \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/transaction_details_link/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/transaction_details_link/index.tsx new file mode 100644 index 00000000000000..5305eeeeccc020 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/transaction_details_link/index.tsx @@ -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 ( + + ); + } + + return ( +
+ + {i18n.translate( + 'xpack.apm.transactionDetailsLink.h2.transactionNotFound', + { defaultMessage: 'No transaction found' } + )} + + } + /> +
+ ); + } + + return ( +
+ + {i18n.translate( + 'xpack.apm.transactionDetailsLink.h2.fetchingTransactionLabel', + { defaultMessage: 'Fetching transaction...' } + )} + + } + /> +
+ ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx index b3d03381039979..a7646f4484babd 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx @@ -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: , + 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: , params: t.intersection([ @@ -69,7 +82,12 @@ const apmRoutes = { }, '/': { element: ( - + ), diff --git a/x-pack/plugins/observability_solution/apm/server/routes/traces/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/traces/route.ts index f55a145cfb2551..24090c4303c40a 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/traces/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/traces/route.ts @@ -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, }; diff --git a/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_transaction_by_name/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_transaction_by_name/index.ts new file mode 100644 index 00000000000000..1c810dfe394ed8 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_transaction_by_name/index.ts @@ -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; +} diff --git a/x-pack/plugins/observability_solution/observability/common/index.ts b/x-pack/plugins/observability_solution/observability/common/index.ts index c8b65fe127d2ac..e71a8d2bc5777a 100644 --- a/x-pack/plugins/observability_solution/observability/common/index.ts +++ b/x-pack/plugins/observability_solution/observability/common/index.ts @@ -54,6 +54,7 @@ export { profilingAzureCostDiscountRate, apmEnableTransactionProfiling, apmEnableServiceInventoryTableSearchBar, + profilingFetchTopNFunctionsFromStacktraces, } from './ui_settings_keys'; export { diff --git a/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts index a95731ef8c67fb..87f60b977691c7 100644 --- a/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability_solution/observability/common/ui_settings_keys.ts @@ -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'; diff --git a/x-pack/plugins/observability_solution/observability/server/ui_settings.ts b/x-pack/plugins/observability_solution/observability/server/ui_settings.ts index 7c9ced9e308ec7..2b5468e0715f84 100644 --- a/x-pack/plugins/observability_solution/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability_solution/observability/server/ui_settings.ts @@ -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 = { 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 }) { diff --git a/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/service_overview_locator.ts b/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/service_overview_locator.ts new file mode 100644 index 00000000000000..a96d67840ce37e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/service_overview_locator.ts @@ -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; + +export class ServiceOverviewLocatorDefinition implements LocatorDefinition { + 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: {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/transaction_details_by_name_locator.ts b/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/transaction_details_by_name_locator.ts new file mode 100644 index 00000000000000..468faf0ba18b79 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/public/locators/apm/transaction_details_by_name_locator.ts @@ -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; + +export class TransactionDetailsByNameLocatorDefinition + implements LocatorDefinition +{ + 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: {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts index 204e9d3fbc99d4..625bdbaaeeb3a7 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts @@ -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() + ), + }, }; } } diff --git a/x-pack/plugins/observability_solution/profiling/common/index.ts b/x-pack/plugins/observability_solution/profiling/common/index.ts index f6266b606ee5a5..c3107f14b19d15 100644 --- a/x-pack/plugins/observability_solution/profiling/common/index.ts +++ b/x-pack/plugins/observability_solution/profiling/common/index.ts @@ -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`, diff --git a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/differential_functions.cy.ts b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/differential_functions.cy.ts index 187a9a09d49c7d..52b539286471bf 100644 --- a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/differential_functions.cy.ts +++ b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/differential_functions.cy.ts @@ -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) => { diff --git a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/functions.cy.ts b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/functions.cy.ts index 6daa414df36e70..bd2822d300240a 100644 --- a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/functions.cy.ts +++ b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/functions.cy.ts @@ -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); diff --git a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/storage_explorer.cy.ts b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/storage_explorer.cy.ts index b7d5680cc34775..5f9f69180e4499 100644 --- a/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/storage_explorer.cy.ts +++ b/x-pack/plugins/observability_solution/profiling/e2e/cypress/e2e/profiling_views/storage_explorer.cy.ts @@ -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); }); diff --git a/x-pack/plugins/observability_solution/profiling/kibana.jsonc b/x-pack/plugins/observability_solution/profiling/kibana.jsonc index c5d60c2fd378c2..e5df7002f4a353 100644 --- a/x-pack/plugins/observability_solution/profiling/kibana.jsonc +++ b/x-pack/plugins/observability_solution/profiling/kibana.jsonc @@ -13,7 +13,8 @@ "security", "cloud", "fleet", - "observabilityAIAssistant" + "observabilityAIAssistant", + "apmDataAccess", ], "requiredPlugins": [ "charts", diff --git a/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/apm_transactions.tsx b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/apm_transactions.tsx new file mode 100644 index 00000000000000..9d5986deca9b21 --- /dev/null +++ b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/apm_transactions.tsx @@ -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; + 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('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) => { + 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> = useMemo( + () => [ + { + field: 'serviceName', + name: i18n.translate('xpack.profiling.apmTransactions.columns.serviceName', { + defaultMessage: 'Service Name', + }), + truncateText: true, + sortable: true, + render: (_, { serviceName }) => { + return ( + + {serviceName} + + ); + }, + }, + { + 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 ( + + {transactionName} + + ); + } + 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 ( + + + setFilter(e.target.value)} + isClearable + fullWidth + placeholder={i18n.translate('xpack.profiling.apmTransactions.searchPlaceholder', { + defaultMessage: 'Search services by name', + })} + /> + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/frame_information_ai_assistant.tsx b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/frame_information_ai_assistant.tsx index 1096951865eb93..fb68611a74b7d8 100644 --- a/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/frame_information_ai_assistant.tsx +++ b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/frame_information_ai_assistant.tsx @@ -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 ? ( - + + + ) : null} ); diff --git a/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/index.tsx b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/index.tsx index a0dbc1c7737b4c..b392be8717ffc7 100644 --- a/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/index.tsx +++ b/x-pack/plugins/observability_solution/profiling/public/components/frame_information_window/index.tsx @@ -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; } export interface Props { @@ -59,6 +72,7 @@ export function FrameInformationWindow({ rank, compressed = false, }: Props) { + const [accordionState, setAccordionState] = useState('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({ ))} - - - + {showSymbolsStatus && symbolStatus !== FrameSymbolStatus.SYMBOLIZED ? ( ) : null} + {isEmpty(subGroups) ? null : ( + + + + + + + + +

+ {i18n.translate( + 'xpack.profiling.frameInformationWindow.apmTransactions', + { defaultMessage: 'Distributed Tracing Correlation' } + )} +

+
+
+
+ +

+ + {i18n.translate( + 'xpack.profiling.frameInformationWindow.apmTransactions.description', + { + defaultMessage: + 'A curated view of APM services and transactions that call this function.', + } + )} + +

+
+ + } + forceState={accordionState} + onToggle={(isOpen) => setAccordionState(isOpen ? 'open' : 'closed')} + > + {accordionState === 'open' ? ( + + ) : null} +
+
+ )} ; 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, }; } diff --git a/x-pack/plugins/observability_solution/profiling/public/services.ts b/x-pack/plugins/observability_solution/profiling/public/services.ts index 3a8aceed514c53..67fde58c615d65 100644 --- a/x-pack/plugins/observability_solution/profiling/public/services.ts +++ b/x-pack/plugins/observability_solution/profiling/public/services.ts @@ -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; + fetchTopNFunctionAPMTransactions: (params: { + http: AutoAbortedHttpService; + timeFrom: number; + timeTo: number; + functionName: string; + serviceNames: string[]; + }) => Promise; } 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; + }, }; } diff --git a/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx b/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx index a5656c4451aed7..2ed5b2dc952058 100644 --- a/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx +++ b/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx @@ -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' }); diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts b/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts new file mode 100644 index 00000000000000..f77d121a699d24 --- /dev/null +++ b/x-pack/plugins/observability_solution/profiling/server/routes/apm.ts @@ -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; + +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', + }); + } + } + ); +} diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts b/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts index e6b71b17db8be3..e7dd4e7f9d9088 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/functions.ts @@ -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( + 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, diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/index.ts b/x-pack/plugins/observability_solution/profiling/server/routes/index.ts index 95c468048609e3..74e6e3b71be904 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/index.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/index.ts @@ -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); } diff --git a/x-pack/plugins/observability_solution/profiling/server/routes/topn.test.ts b/x-pack/plugins/observability_solution/profiling/server/routes/topn.test.ts index 2985d8d9d78f70..b576c3b54d1445 100644 --- a/x-pack/plugins/observability_solution/profiling/server/routes/topn.test.ts +++ b/x-pack/plugins/observability_solution/profiling/server/routes/topn.test.ts @@ -69,6 +69,7 @@ describe('TopN data from Elasticsearch', () => { }, }) as Promise ), + topNFunctions: jest.fn(), }; const logger = loggerMock.create(); diff --git a/x-pack/plugins/observability_solution/profiling/server/types.ts b/x-pack/plugins/observability_solution/profiling/server/types.ts index adc672c932083d..ef5028b8d311bf 100644 --- a/x-pack/plugins/observability_solution/profiling/server/types.ts +++ b/x-pack/plugins/observability_solution/profiling/server/types.ts @@ -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 diff --git a/x-pack/plugins/observability_solution/profiling/server/utils/create_profiling_es_client.ts b/x-pack/plugins/observability_solution/profiling/server/utils/create_profiling_es_client.ts index 1a32b0c4a676e6..402314ba891e40 100644 --- a/x-pack/plugins/observability_solution/profiling/server/utils/create_profiling_es_client.ts +++ b/x-pack/plugins/observability_solution/profiling/server/utils/create_profiling_es_client.ts @@ -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; + 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; } export function createProfilingEsClient({ @@ -151,5 +168,51 @@ export function createProfilingEsClient({ }); return unwrapEsResponse(promise) as Promise; }, + 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; + }, }; } diff --git a/x-pack/plugins/observability_solution/profiling/tsconfig.json b/x-pack/plugins/observability_solution/profiling/tsconfig.json index 381c6ab4c5d1f8..fc9080606e4543 100644 --- a/x-pack/plugins/observability_solution/profiling/tsconfig.json +++ b/x-pack/plugins/observability_solution/profiling/tsconfig.json @@ -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 diff --git a/x-pack/plugins/observability_solution/profiling_data_access/common/profiling_es_client.ts b/x-pack/plugins/observability_solution/profiling_data_access/common/profiling_es_client.ts index e70a5ea4c1737a..96cef8726cf66c 100644 --- a/x-pack/plugins/observability_solution/profiling_data_access/common/profiling_es_client.ts +++ b/x-pack/plugins/observability_solution/profiling_data_access/common/profiling_es_client.ts @@ -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; + 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; } diff --git a/x-pack/plugins/observability_solution/profiling_data_access/server/services/functions/es_functions.ts b/x-pack/plugins/observability_solution/profiling_data_access/server/services/functions/es_functions.ts new file mode 100644 index 00000000000000..3296ab2141d1c1 --- /dev/null +++ b/x-pack/plugins/observability_solution/profiling_data_access/server/services/functions/es_functions.ts @@ -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(profilingCo2PerKWH), + core.uiSettings.client.get(profilingDatacenterPUE), + core.uiSettings.client.get(profilingPervCPUWattX86), + core.uiSettings.client.get(profilingPervCPUWattArm64), + core.uiSettings.client.get(profilingAWSCostDiscountRate), + core.uiSettings.client.get(profilingCostPervCPUPerHour), + core.uiSettings.client.get(profilingAzureCostDiscountRate), + core.uiSettings.client.get(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, + }, + }; + }), + }; +} diff --git a/x-pack/plugins/observability_solution/profiling_data_access/server/services/register_services.ts b/x-pack/plugins/observability_solution/profiling_data_access/server/services/register_services.ts index dfd51e2125c460..57cb21ee57a74c 100644 --- a/x-pack/plugins/observability_solution/profiling_data_access/server/services/register_services.ts +++ b/x-pack/plugins/observability_solution/profiling_data_access/server/services/register_services.ts @@ -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), }; } diff --git a/x-pack/plugins/observability_solution/profiling_data_access/server/utils/create_profiling_es_client.ts b/x-pack/plugins/observability_solution/profiling_data_access/server/utils/create_profiling_es_client.ts index 4c209540f37a18..ab7483a99916f1 100644 --- a/x-pack/plugins/observability_solution/profiling_data_access/server/utils/create_profiling_es_client.ts +++ b/x-pack/plugins/observability_solution/profiling_data_access/server/utils/create_profiling_es_client.ts @@ -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; }, + 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; + }, }; }