diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts index 3ee43dc63f04f0..d55d34cc188418 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts @@ -124,6 +124,7 @@ export type ApmFields = Fields<{ 'error.grouping_name': string; 'error.id': string; 'error.type': string; + 'error.culprit': string; 'event.ingested': number; 'event.name': string; 'event.action': string; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/instance.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/instance.ts index 3dd5ec30933c6d..12e9454a7770a2 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/instance.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/instance.ts @@ -72,11 +72,12 @@ export class Instance extends Entity { 'error.grouping_name': getErrorGroupingKey(message), }); } - error({ message, type }: { message: string; type?: string }) { + error({ message, type, culprit }: { message: string; type?: string; culprit?: string }) { return new ApmError({ ...this.fields, 'error.exception': [{ message, ...(type ? { type } : {}) }], 'error.grouping_name': getErrorGroupingKey(message), + 'error.culprit': culprit, }); } diff --git a/packages/kbn-apm-synthtrace/src/scenarios/helpers/exception_types.ts b/packages/kbn-apm-synthtrace/src/scenarios/helpers/exception_types.ts new file mode 100644 index 00000000000000..a7e7ba34c48464 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/helpers/exception_types.ts @@ -0,0 +1,42 @@ +/* + * 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 const exceptionTypes = [ + 'ProgrammingError', + 'ProtocolError', + 'RangeError', + 'ReadTimeout', + 'ReadTimeoutError', + 'ReferenceError', + 'RemoteDisconnected', + 'RequestAbortedError', + 'ResponseError (action_request_validation_exception)', + 'ResponseError (illegal_argument_exception)', + 'ResponseError (index_not_found_exception)', + 'ResponseError (index_template_missing_exception)', + 'ResponseError (resource_already_exists_exception)', + 'ResponseError (resource_not_found_exception)', + 'ResponseError (search_phase_execution_exception)', + 'ResponseError (security_exception)', + 'ResponseError (transport_serialization_exception)', + 'ResponseError (version_conflict_engine_exception)', + 'ResponseError (x_content_parse_exception)', + 'ResponseError', + 'SIGTRAP', + 'SocketError', + 'SpawnError', + 'SyntaxError', + 'SyscallError', + 'TimeoutError', + 'TimeoutError', + 'TypeError', +]; + +export function getExceptionTypeForIndex(index: number) { + return exceptionTypes[index % exceptionTypes.length]; +} diff --git a/packages/kbn-apm-synthtrace/src/scenarios/logs_and_metrics.ts b/packages/kbn-apm-synthtrace/src/scenarios/logs_and_metrics.ts index 7532b3dc9477cd..4227f003771efe 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/logs_and_metrics.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/logs_and_metrics.ts @@ -120,7 +120,10 @@ const scenario: Scenario = async (runOptions) => { .failure() .errors( instance - .error({ message: '[ResponseError] index_not_found_exception' }) + .error({ + message: '[ResponseError] index_not_found_exception', + type: 'ResponseError', + }) .timestamp(timestamp + 50) ) ); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_dependencies.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_dependencies.ts new file mode 100644 index 00000000000000..61ecb7832f0f31 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_dependencies.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +import { ApmFields, Instance } from '@kbn/apm-synthtrace-client'; +import { service } from '@kbn/apm-synthtrace-client/src/lib/apm/service'; +import { random, times } from 'lodash'; +import { Scenario } from '../cli/scenario'; +import { RunOptions } from '../cli/utils/parse_run_cli_flags'; +import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; +import { withClient } from '../lib/utils/with_client'; + +const ENVIRONMENT = getSynthtraceEnvironment(__filename); +const NUMBER_OF_DEPENDENCIES_PER_SERVICE = 2000; +const NUMBER_OF_SERVICES = 1; + +const scenario: Scenario = async (runOptions: RunOptions) => { + return { + generate: ({ range, clients: { apmEsClient } }) => { + const instances = times(NUMBER_OF_SERVICES).map((index) => + service({ + name: `synthtrace-high-cardinality-${index}`, + environment: ENVIRONMENT, + agentName: 'java', + }).instance(`java-instance-${index}`) + ); + + const instanceDependencies = (instance: Instance, id: string) => { + const throughput = random(1, 60); + const childLatency = random(10, 100_000); + const parentLatency = childLatency + random(10, 10_000); + + const failureRate = random(0, 100); + + return range.ratePerMinute(throughput).generator((timestamp) => { + const child = instance + .span({ + spanName: 'GET apm-*/_search', + spanType: 'db', + spanSubtype: 'elasticsearch', + }) + .destination(`elasticsearch/${id}`) + .timestamp(timestamp) + .duration(childLatency); + + const span = instance + .transaction({ transactionName: 'GET /java' }) + .timestamp(timestamp) + .duration(parentLatency) + .success() + .children(Math.random() * 100 > failureRate ? child.success() : child.failure()); + + return span; + }); + }; + + return withClient( + apmEsClient, + instances.flatMap((instance, i) => + times(NUMBER_OF_DEPENDENCIES_PER_SERVICE) + .map((j) => instanceDependencies(instance, `${i + 1}.${j + 1}`)) + .flat() + ) + ); + }, + }; +}; + +export default scenario; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_errors.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_errors.ts index 7948688610f561..6f9849615b78e6 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/many_errors.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_errors.ts @@ -9,13 +9,13 @@ import { ApmFields, apm } from '@kbn/apm-synthtrace-client'; import { Scenario } from '../cli/scenario'; import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; import { withClient } from '../lib/utils/with_client'; +import { getExceptionTypeForIndex } from './helpers/exception_types'; import { getRandomNameForIndex } from './helpers/random_names'; const ENVIRONMENT = getSynthtraceEnvironment(__filename); const scenario: Scenario = async (runOptions) => { const { logger } = runOptions; - const severities = ['critical', 'error', 'warning', 'info', 'debug', 'trace']; return { @@ -23,7 +23,11 @@ const scenario: Scenario = async (runOptions) => { const transactionName = 'DELETE /api/orders/{id}'; const instance = apm - .service({ name: `synth-node`, environment: ENVIRONMENT, agentName: 'nodejs' }) + .service({ + name: `synthtrace-high-cardinality-0`, + environment: ENVIRONMENT, + agentName: 'java', + }) .instance('instance'); const failedTraceEvents = range @@ -38,7 +42,13 @@ const scenario: Scenario = async (runOptions) => { .duration(1000) .failure() .errors( - instance.error({ message: errorMessage, type: 'My Type' }).timestamp(timestamp + 50) + instance + .error({ + message: errorMessage, + type: getExceptionTypeForIndex(index), + culprit: 'request (node_modules/@elastic/transport/src/Transport.ts)', + }) + .timestamp(timestamp + 50) ); }); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_instances.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_instances.ts index 4774839c717275..8c57a37177f852 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/many_instances.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_instances.ts @@ -18,8 +18,7 @@ const ENVIRONMENT = getSynthtraceEnvironment(__filename); const scenario: Scenario = async ({ logger, scenarioOpts = { instances: 2000 } }) => { const numInstances = scenarioOpts.instances; const agentVersions = ['2.1.0', '2.0.0', '1.15.0', '1.14.0', '1.13.1']; - const language = 'go'; - const serviceName = 'synth-many-instances'; + const language = 'java'; const transactionName = 'GET /order/{id}'; return { @@ -29,7 +28,7 @@ const scenario: Scenario = async ({ logger, scenarioOpts = { instance const randomName = getRandomNameForIndex(index); return apm .service({ - name: serviceName, + name: 'synthtrace-high-cardinality-0', environment: ENVIRONMENT, agentName: language, }) @@ -51,13 +50,15 @@ const scenario: Scenario = async ({ logger, scenarioOpts = { instance return !generateError ? span.success() - : span - .failure() - .errors( - instance - .error({ message: `No handler for ${transactionName}` }) - .timestamp(timestamp + 50) - ); + : span.failure().errors( + instance + .error({ + message: `No handler for ${transactionName}`, + type: 'No handler', + culprit: 'request', + }) + .timestamp(timestamp + 50) + ); }); const cpuPct = random(0, 1); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts index 1af9f2f9e3b5a5..19da565ab88608 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts @@ -62,13 +62,15 @@ const scenario: Scenario = async ({ logger, scenarioOpts = { services return !generateError ? span.success() - : span - .failure() - .errors( - instance - .error({ message: `No handler for ${transactionName}` }) - .timestamp(timestamp + 50) - ); + : span.failure().errors( + instance + .error({ + message: `No handler for ${transactionName}`, + type: 'No handler', + culprit: 'request', + }) + .timestamp(timestamp + 50) + ); }); }; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_transactions.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_transactions.ts index 895d413235512d..ce9af03c8e4925 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/many_transactions.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_transactions.ts @@ -37,7 +37,11 @@ const scenario: Scenario = async (runOptions) => { generate: ({ range, clients: { apmEsClient } }) => { const instances = times(numServices).map((index) => apm - .service({ name: `synth-go-${index}`, environment: ENVIRONMENT, agentName: 'go' }) + .service({ + name: `synthtrace-high-cardinality-${index}`, + environment: ENVIRONMENT, + agentName: 'java', + }) .instance(`instance-${index}`) ); @@ -60,7 +64,11 @@ const scenario: Scenario = async (runOptions) => { .failure() .errors( instance - .error({ message: '[ResponseError] index_not_found_exception' }) + .error({ + message: '[ResponseError] index_not_found_exception', + type: 'ResponseError', + culprit: 'elasticsearch', + }) .timestamp(timestamp + 50) ) ); diff --git a/packages/kbn-apm-synthtrace/src/scenarios/service_many_dependencies.ts b/packages/kbn-apm-synthtrace/src/scenarios/service_many_dependencies.ts deleted file mode 100644 index a548e55f575a4f..00000000000000 --- a/packages/kbn-apm-synthtrace/src/scenarios/service_many_dependencies.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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. - */ - -import { ApmFields, Instance } from '@kbn/apm-synthtrace-client'; -import { service } from '@kbn/apm-synthtrace-client/src/lib/apm/service'; -import { Scenario } from '../cli/scenario'; -import { RunOptions } from '../cli/utils/parse_run_cli_flags'; -import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; -import { withClient } from '../lib/utils/with_client'; - -const ENVIRONMENT = getSynthtraceEnvironment(__filename); -const MAX_DEPENDENCIES = 10000; -const MAX_DEPENDENCIES_PER_SERVICE = 500; -const MAX_SERVICES = 20; - -const scenario: Scenario = async (runOptions: RunOptions) => { - return { - generate: ({ range, clients: { apmEsClient } }) => { - const javaInstances = Array.from({ length: MAX_SERVICES }).map((_, index) => - service(`opbeans-java-${index}`, ENVIRONMENT, 'java').instance(`java-instance-${index}`) - ); - - const instanceDependencies = (instance: Instance, startIndex: number) => { - const rate = range.ratePerMinute(60); - - return rate.generator((timestamp, index) => { - const currentIndex = index % MAX_DEPENDENCIES_PER_SERVICE; - const destination = (startIndex + currentIndex) % MAX_DEPENDENCIES; - - const span = instance - .transaction({ transactionName: 'GET /java' }) - .timestamp(timestamp) - .duration(400) - .success() - .children( - instance - .span({ - spanName: 'GET apm-*/_search', - spanType: 'db', - spanSubtype: 'elasticsearch', - }) - .destination(`elasticsearch/${destination}`) - .timestamp(timestamp) - .duration(200) - .success() - ); - - return span; - }); - }; - - return withClient( - apmEsClient, - javaInstances.map((instance, index) => - instanceDependencies(instance, (index * MAX_DEPENDENCIES_PER_SERVICE) % MAX_DEPENDENCIES) - ) - ); - }, - }; -}; - -export default scenario; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/simple_trace.ts b/packages/kbn-apm-synthtrace/src/scenarios/simple_trace.ts index c93e37b4f99b37..1d5ee6f53d63ca 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/simple_trace.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/simple_trace.ts @@ -62,7 +62,10 @@ const scenario: Scenario = async (runOptions) => { .failure() .errors( instance - .error({ message: '[ResponseError] index_not_found_exception' }) + .error({ + message: '[ResponseError] index_not_found_exception', + type: 'ResponseError', + }) .timestamp(timestamp + 50) ) ); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts index 889724f664f9bb..c0c3c032a0e61f 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts @@ -115,6 +115,31 @@ describe('Service inventory', () => { }); }); + describe('Table search', () => { + beforeEach(() => { + cy.updateAdvancedSettings({ + 'observability:apmEnableTableSearchBar': true, + }); + + cy.loginAsEditorUser(); + }); + + it('filters for java service on the table', () => { + cy.visitKibana(serviceInventoryHref); + cy.contains('opbeans-node'); + cy.contains('opbeans-java'); + cy.contains('opbeans-rum'); + cy.get('[data-test-subj="tableSearchInput"]').type('java'); + cy.contains('opbeans-node').should('not.exist'); + cy.contains('opbeans-java'); + cy.contains('opbeans-rum').should('not.exist'); + cy.get('[data-test-subj="tableSearchInput"]').clear(); + cy.contains('opbeans-node'); + cy.contains('opbeans-java'); + cy.contains('opbeans-rum'); + }); + }); + describe('Check detailed statistics API with multiple services', () => { before(() => { // clean previous data created diff --git a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx index 9aeff02dff4f3d..858d80e645ae48 100644 --- a/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/dependency_operation_detail_view/index.tsx @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { omit, orderBy } from 'lodash'; import React, { useEffect, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; -import type { DependencySpan } from '../../../../server/routes/dependencies/get_top_dependency_spans'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; @@ -17,13 +16,12 @@ import { useDependencyDetailOperationsBreadcrumb } from '../../../hooks/use_depe import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { DependencyMetricCharts } from '../../shared/dependency_metric_charts'; -import { DetailViewHeader } from './detail_view_header'; import { ResettingHeightRetainer } from '../../shared/height_retainer/resetting_height_container'; import { push, replace } from '../../shared/links/url_helpers'; -import { SortFunction } from '../../shared/managed_table'; import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher'; import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary'; import { DependencyOperationDistributionChart } from './dependency_operation_distribution_chart'; +import { DetailViewHeader } from './detail_view_header'; import { maybeRedirectToAvailableSpanSample } from './maybe_redirect_to_available_span_sample'; export function DependencyOperationDetailView() { @@ -86,25 +84,15 @@ export function DependencyOperationDetailView() { ] ); - const getSortedSamples: SortFunction = ( - items, - localSortField, - localSortDirection - ) => { - return orderBy(items, localSortField, localSortDirection); - }; - const samples = useMemo(() => { return ( - getSortedSamples( - spanFetch.data?.spans ?? [], - sortField, - sortDirection - ).map((span) => ({ - spanId: span.spanId, - traceId: span.traceId, - transactionId: span.transactionId, - })) || [] + orderBy(spanFetch.data?.spans ?? [], sortField, sortDirection).map( + (span) => ({ + spanId: span.spanId, + traceId: span.traceId, + transactionId: span.transactionId, + }) + ) || [] ); }, [spanFetch.data?.spans, sortField, sortDirection]); diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx index e0133ba6a6695b..7f0f2bda106ba2 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx @@ -166,7 +166,7 @@ export function ErrorGroupDetails() { [environment, kuery, serviceName, start, end, groupId] ); - const { errorDistributionData, status: errorDistributionStatus } = + const { errorDistributionData, errorDistributionStatus } = useErrorGroupDistributionFetcher({ serviceName, groupId, diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/error_group_list.stories.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/error_group_list.stories.tsx index c61a37fa86e5e8..d1183c91cb3e41 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/error_group_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/error_group_list.stories.tsx @@ -4,14 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { CoreStart } from '@kbn/core/public'; import { Meta, Story } from '@storybook/react'; import React, { ComponentProps } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; - import { ErrorGroupList } from '.'; +import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { MockApmPluginStorybook } from '../../../../context/apm_plugin/mock_apm_plugin_storybook'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; type Args = ComponentProps; @@ -19,19 +18,86 @@ const stories: Meta = { title: 'app/ErrorGroupOverview/ErrorGroupList', component: ErrorGroupList, decorators: [ - (StoryComponent) => { + (StoryComponent, { args }) => { + const coreMock = { + http: { + get: async (endpoint: string) => { + switch (endpoint) { + case `/internal/apm/services/test service/errors/groups/main_statistics`: + return { + errorGroups: [ + { + name: 'net/http: abort Handler', + occurrences: 14, + culprit: 'Main.func2', + groupId: '83a653297ec29afed264d7b60d5cda7b', + lastSeen: 1634833121434, + handled: false, + type: 'errorString', + }, + { + name: 'POST /api/orders (500)', + occurrences: 5, + culprit: 'logrusMiddleware', + groupId: '7a640436a9be648fd708703d1ac84650', + lastSeen: 1634833121434, + handled: false, + type: 'OpError', + }, + { + name: 'write tcp 10.36.2.24:3000->10.36.1.14:34232: write: connection reset by peer', + occurrences: 4, + culprit: 'apiHandlers.getProductCustomers', + groupId: '95ca0e312c109aa11e298bcf07f1445b', + lastSeen: 1634833121434, + handled: false, + type: 'OpError', + }, + { + name: 'write tcp 10.36.0.21:3000->10.36.1.252:57070: write: connection reset by peer', + occurrences: 3, + culprit: 'apiHandlers.getCustomers', + groupId: '4053d7e33d2b716c819bd96d9d6121a2', + lastSeen: 1634833121434, + handled: false, + type: 'OpError', + }, + { + name: 'write tcp 10.36.0.21:3000->10.36.0.88:33926: write: broken pipe', + occurrences: 2, + culprit: 'apiHandlers.getOrders', + groupId: '94f4ca8ec8c02e5318cf03f46ae4c1f3', + lastSeen: 1634833121434, + handled: false, + type: 'OpError', + }, + ], + maxCountExceeded: false, + }; + default: + return { + errorGroups: [], + maxCountExceeded: false, + }; + } + }, + }, + } as unknown as CoreStart; + return ( - - - - - - - + + ); }, ], @@ -42,60 +108,14 @@ export const Example: Story = (args) => { return ; }; Example.args = { - mainStatistics: [ - { - name: 'net/http: abort Handler', - occurrences: 14, - culprit: 'Main.func2', - groupId: '83a653297ec29afed264d7b60d5cda7b', - lastSeen: 1634833121434, - handled: false, - type: 'errorString', - }, - { - name: 'POST /api/orders (500)', - occurrences: 5, - culprit: 'logrusMiddleware', - groupId: '7a640436a9be648fd708703d1ac84650', - lastSeen: 1634833121434, - handled: false, - type: 'OpError', - }, - { - name: 'write tcp 10.36.2.24:3000->10.36.1.14:34232: write: connection reset by peer', - occurrences: 4, - culprit: 'apiHandlers.getProductCustomers', - groupId: '95ca0e312c109aa11e298bcf07f1445b', - lastSeen: 1634833121434, - handled: false, - type: 'OpError', - }, - { - name: 'write tcp 10.36.0.21:3000->10.36.1.252:57070: write: connection reset by peer', - occurrences: 3, - culprit: 'apiHandlers.getCustomers', - groupId: '4053d7e33d2b716c819bd96d9d6121a2', - lastSeen: 1634833121434, - handled: false, - type: 'OpError', - }, - { - name: 'write tcp 10.36.0.21:3000->10.36.0.88:33926: write: broken pipe', - occurrences: 2, - culprit: 'apiHandlers.getOrders', - groupId: '94f4ca8ec8c02e5318cf03f46ae4c1f3', - lastSeen: 1634833121434, - handled: false, - type: 'OpError', - }, - ], serviceName: 'test service', + initialPageSize: 5, }; export const EmptyState: Story = (args) => { return ; }; EmptyState.args = { - mainStatistics: [], - serviceName: 'test service', + serviceName: 'foo', + initialPageSize: 5, }; diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx index 819b75a44c7b17..3fbc22b845d338 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx @@ -13,11 +13,11 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; +import { isPending } from '../../../../hooks/use_fetcher'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { asInteger } from '../../../../../common/utils/formatters'; -import { useApmParams } from '../../../../hooks/use_apm_params'; -import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { asBigNumber } from '../../../../../common/utils/formatters'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; import { truncate, unit } from '../../../../utils/style'; import { ChartType, @@ -26,9 +26,17 @@ import { import { SparkPlot } from '../../../shared/charts/spark_plot'; import { ErrorDetailLink } from '../../../shared/links/apm/error_detail_link'; import { ErrorOverviewLink } from '../../../shared/links/apm/error_overview_link'; -import { ITableColumn, ManagedTable } from '../../../shared/managed_table'; +import { + ITableColumn, + ManagedTable, + TableOptions, +} from '../../../shared/managed_table'; import { TimestampTooltip } from '../../../shared/timestamp_tooltip'; import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options'; +import { + ErrorGroupItem, + useErrorGroupListData, +} from './use_error_group_list_data'; const GroupIdLink = euiStyled(ErrorDetailLink)` font-family: ${({ theme }) => theme.eui.euiCodeFontFamily}; @@ -44,7 +52,6 @@ const ErrorLink = euiStyled(ErrorOverviewLink)` const MessageLink = euiStyled(ErrorDetailLink)` font-family: ${({ theme }) => theme.eui.euiCodeFontFamily}; - font-size: ${({ theme }) => theme.eui.euiFontSizeM}; ${truncate('100%')}; `; @@ -52,79 +59,98 @@ const Culprit = euiStyled.div` font-family: ${({ theme }) => theme.eui.euiCodeFontFamily}; `; -type ErrorGroupItem = - APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'>['errorGroups'][0]; -type ErrorGroupDetailedStatistics = - APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>; - interface Props { - mainStatistics: ErrorGroupItem[]; serviceName: string; - detailedStatisticsLoading: boolean; - detailedStatistics: ErrorGroupDetailedStatistics; - initialSortField: string; - initialSortDirection: 'asc' | 'desc'; + isCompactMode?: boolean; + initialPageSize: number; comparisonEnabled?: boolean; - isLoading: boolean; + saveTableOptionsToUrl?: boolean; + showPerPageOptions?: boolean; } -function ErrorGroupList({ - mainStatistics, +const defaultSorting = { + field: 'occurrences' as const, + direction: 'desc' as const, +}; + +export function ErrorGroupList({ serviceName, - detailedStatisticsLoading, - detailedStatistics, + isCompactMode = false, + initialPageSize, comparisonEnabled, - initialSortField, - initialSortDirection, - isLoading, + saveTableOptionsToUrl, + showPerPageOptions = true, }: Props) { - const { query } = useApmParams('/services/{serviceName}/errors'); + const { query } = useAnyOfApmParams( + '/services/{serviceName}/overview', + '/services/{serviceName}/errors' + ); const { offset } = query; + + const [renderedItems, setRenderedItems] = useState([]); + + const [sorting, setSorting] = + useState['sort']>(defaultSorting); + + const { + setDebouncedSearchQuery, + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useErrorGroupListData({ renderedItems, sorting }); + + const isMainStatsLoading = isPending(mainStatisticsStatus); + const isDetailedStatsLoading = isPending(detailedStatisticsStatus); + const columns = useMemo(() => { - return [ - { - name: ( - <> - {i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { - defaultMessage: 'Group ID', - })}{' '} - - - ), - field: 'groupId', - sortable: false, - width: `${unit * 6}px`, - render: (_, { groupId }) => { - return ( - - {groupId.slice(0, 5) || NOT_AVAILABLE_LABEL} - - ); - }, + const groupIdColumn: ITableColumn = { + name: ( + <> + {i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { + defaultMessage: 'Group ID', + })}{' '} + + + ), + field: 'groupId', + sortable: false, + width: `${unit * 6}px`, + render: (_, { groupId }) => { + return ( + + {groupId.slice(0, 5) || NOT_AVAILABLE_LABEL} + + ); }, + }; + + return [ + ...(isCompactMode ? [] : [groupIdColumn]), { name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', { defaultMessage: 'Type', }), field: 'type', + width: `${unit * 10}px`, sortable: false, render: (_, { type }) => { return ( @@ -150,7 +176,7 @@ function ErrorGroupList({ ), field: 'message', sortable: false, - width: '50%', + width: '60%', render: (_, item) => { return ( @@ -165,37 +191,46 @@ function ErrorGroupList({ {item.name || NOT_AVAILABLE_LABEL} -
- - {item.culprit || NOT_AVAILABLE_LABEL} - + {isCompactMode ? null : ( + <> +
+ + {item.culprit || NOT_AVAILABLE_LABEL} + + + )}
); }, }, - { - name: '', - field: 'handled', - sortable: false, - align: RIGHT_ALIGNMENT, - render: (_, { handled }) => - handled === false && ( - - {i18n.translate('xpack.apm.errorsTable.unhandledLabel', { - defaultMessage: 'Unhandled', - })} - - ), - }, + ...(isCompactMode + ? [] + : [ + { + name: '', + field: 'handled', + sortable: false, + align: RIGHT_ALIGNMENT, + render: (_, { handled }) => + handled === false && ( + + {i18n.translate('xpack.apm.errorsTable.unhandledLabel', { + defaultMessage: 'Unhandled', + })} + + ), + } as ITableColumn, + ]), { field: 'lastSeen', sortable: true, name: i18n.translate('xpack.apm.errorsTable.lastSeenColumnLabel', { defaultMessage: 'Last seen', }), + width: `${unit * 6}px`, align: RIGHT_ALIGNMENT, render: (_, { lastSeen }) => lastSeen ? ( @@ -212,6 +247,7 @@ function ErrorGroupList({ sortable: true, dataType: 'number', align: RIGHT_ALIGNMENT, + width: `${unit * 12}px`, render: (_, { occurrences, groupId }) => { const currentPeriodTimeseries = detailedStatistics?.currentPeriod?.[groupId]?.timeseries; @@ -224,14 +260,14 @@ function ErrorGroupList({ >; }, [ + isCompactMode, serviceName, query, - detailedStatistics, + detailedStatistics?.currentPeriod, + detailedStatistics?.previousPeriod, + isDetailedStatsLoading, comparisonEnabled, - detailedStatisticsLoading, offset, ]); + const tableSearchBar = useMemo(() => { + return { + fieldsToSearch: ['name', 'groupId', 'culprit', 'type'] as Array< + keyof ErrorGroupItem + >, + maxCountExceeded: mainStatistics.maxCountExceeded, + onChangeSearchQuery: setDebouncedSearchQuery, + placeholder: i18n.translate( + 'xpack.apm.errorsTable.filterErrorsPlaceholder', + { defaultMessage: 'Search errors by message, type or culprit' } + ), + }; + }, [mainStatistics.maxCountExceeded, setDebouncedSearchQuery]); + return ( ); } - -export { ErrorGroupList }; diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/use_error_group_list_data.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/use_error_group_list_data.tsx new file mode 100644 index 00000000000000..3e3c4d3ea829ec --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/use_error_group_list_data.tsx @@ -0,0 +1,149 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { useStateDebounced } from '../../../../hooks/use_debounce'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { TableOptions } from '../../../shared/managed_table'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; +import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options'; + +type MainStatistics = + APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'>; +type DetailedStatistics = + APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>; + +export type ErrorGroupItem = MainStatistics['errorGroups'][0]; + +const INITIAL_MAIN_STATISTICS: MainStatistics & { requestId: string } = { + requestId: '', + errorGroups: [], + maxCountExceeded: false, +}; + +const INITIAL_STATE_DETAILED_STATISTICS: DetailedStatistics = { + currentPeriod: {}, + previousPeriod: {}, +}; + +export function useErrorGroupListData({ + renderedItems, + sorting, +}: { + renderedItems: ErrorGroupItem[]; + sorting: TableOptions['sort']; +}) { + const { serviceName } = useApmServiceContext(); + const [searchQuery, setDebouncedSearchQuery] = useStateDebounced(''); + + const { + query: { + environment, + kuery, + rangeFrom, + rangeTo, + offset, + comparisonEnabled, + }, + } = useAnyOfApmParams( + '/services/{serviceName}/overview', + '/services/{serviceName}/errors' + ); + + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + + const { + data: mainStatistics = INITIAL_MAIN_STATISTICS, + status: mainStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + sortField: sorting.field, + sortDirection: sorting.direction, + searchQuery, + }, + }, + } + ).then((response) => { + return { + ...response, + requestId: uuidv4(), + }; + }); + } + }, + [ + sorting.direction, + sorting.field, + start, + end, + serviceName, + environment, + kuery, + searchQuery, + ] + ); + + const { + data: detailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if (mainStatistics.requestId && renderedItems.length && start && end) { + return callApmApi( + 'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + numBuckets: 20, + offset: + comparisonEnabled && isTimeComparison(offset) + ? offset + : undefined, + }, + body: { + groupIds: JSON.stringify( + renderedItems.map(({ groupId }) => groupId).sort() + ), + }, + }, + } + ); + } + }, + // only fetches agg results when main statistics are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + [mainStatistics.requestId, renderedItems, comparisonEnabled, offset], + { preservePreviousData: false } + ); + + return { + setDebouncedSearchQuery, + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + }; +} diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index d6450ad4def573..f391e73012b403 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -13,166 +13,29 @@ import { EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { orderBy } from 'lodash'; import React from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; -import { - FETCH_STATUS, - isPending, - useFetcher, -} from '../../../hooks/use_fetcher'; -import { useTimeRange } from '../../../hooks/use_time_range'; -import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { FailedTransactionRateChart } from '../../shared/charts/failed_transaction_rate_chart'; -import { isTimeComparison } from '../../shared/time_comparison/get_comparison_options'; import { ErrorDistribution } from '../error_group_details/distribution'; import { ErrorGroupList } from './error_group_list'; -type ErrorGroupMainStatistics = - APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'>; -type ErrorGroupDetailedStatistics = - APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>; - -const INITIAL_STATE_MAIN_STATISTICS: { - errorGroupMainStatistics: ErrorGroupMainStatistics['errorGroups']; - requestId?: string; - currentPageGroupIds: ErrorGroupMainStatistics['errorGroups']; -} = { - errorGroupMainStatistics: [], - requestId: undefined, - currentPageGroupIds: [], -}; - -const INITIAL_STATE_DETAILED_STATISTICS: ErrorGroupDetailedStatistics = { - currentPeriod: {}, - previousPeriod: {}, -}; - export function ErrorGroupOverview() { const { serviceName } = useApmServiceContext(); const { - query: { - environment, - kuery, - sortField = 'occurrences', - sortDirection = 'desc', - rangeFrom, - rangeTo, - offset, - comparisonEnabled, - page = 0, - pageSize = 25, - }, + query: { environment, kuery, comparisonEnabled }, } = useApmParams('/services/{serviceName}/errors'); - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { errorDistributionData, status } = useErrorGroupDistributionFetcher({ - serviceName, - groupId: undefined, - environment, - kuery, - }); - - const { - data: errorGroupListData = INITIAL_STATE_MAIN_STATISTICS, - status: errorGroupListDataStatus, - } = useFetcher( - (callApmApi) => { - const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; - - if (start && end) { - return callApmApi( - 'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics', - { - params: { - path: { - serviceName, - }, - query: { - environment, - kuery, - start, - end, - sortField, - sortDirection: normalizedSortDirection, - }, - }, - } - ).then((response) => { - const currentPageGroupIds = orderBy( - response.errorGroups, - sortField, - sortDirection - ) - .slice(page * pageSize, (page + 1) * pageSize) - .map(({ groupId }) => groupId) - .sort(); - - return { - // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. - requestId: uuidv4(), - errorGroupMainStatistics: response.errorGroups, - currentPageGroupIds, - }; - }); - } - }, - [ + const { errorDistributionData, errorDistributionStatus } = + useErrorGroupDistributionFetcher({ + serviceName, + groupId: undefined, environment, kuery, - serviceName, - start, - end, - sortField, - sortDirection, - page, - pageSize, - ] - ); - - const { requestId, errorGroupMainStatistics, currentPageGroupIds } = - errorGroupListData; - - const { - data: errorGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, - status: errorGroupDetailedStatisticsStatus, - } = useFetcher( - (callApmApi) => { - if (requestId && currentPageGroupIds.length && start && end) { - return callApmApi( - 'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics', - { - params: { - path: { serviceName }, - query: { - environment, - kuery, - start, - end, - numBuckets: 20, - offset: - comparisonEnabled && isTimeComparison(offset) - ? offset - : undefined, - }, - body: { - groupIds: JSON.stringify(currentPageGroupIds), - }, - }, - } - ); - } - }, - // only fetches agg results when requestId changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], - { preservePreviousData: false } - ); + }); return ( @@ -182,7 +45,7 @@ export function ErrorGroupOverview() { diff --git a/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_group_details/error_group_details/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_group_details/error_group_details/index.tsx index ea03b6cc58ac85..b59f0064349bd6 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_group_details/error_group_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_group_details/error_group_details/index.tsx @@ -180,13 +180,15 @@ export function ErrorGroupDetails() { [environment, kueryWithMobileFilters, serviceName, start, end, groupId] ); - const { errorDistributionData, status: errorDistributionStatus } = - useErrorGroupDistributionFetcher({ - serviceName, - groupId, - environment, - kuery: kueryWithMobileFilters, - }); + const { + errorDistributionData, + errorDistributionStatus: errorDistributionStatus, + } = useErrorGroupDistributionFetcher({ + serviceName, + groupId, + environment, + kuery: kueryWithMobileFilters, + }); useEffect(() => { const selectedSample = errorSamplesData?.errorSampleIds.find( diff --git a/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_overview/errors_overview.tsx b/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_overview/errors_overview.tsx index 67214733bece41..1675932b26063f 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_overview/errors_overview.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/errors_and_crashes_overview/errors_overview.tsx @@ -85,12 +85,13 @@ export function MobileErrorsOverview() { kuery, }); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { errorDistributionData, status } = useErrorGroupDistributionFetcher({ - serviceName, - groupId: undefined, - environment, - kuery: kueryWithMobileFilters, - }); + const { errorDistributionData, errorDistributionStatus: status } = + useErrorGroupDistributionFetcher({ + serviceName, + groupId: undefined, + environment, + kuery: kueryWithMobileFilters, + }); const { data: errorGroupListData = INITIAL_STATE_MAIN_STATISTICS, status: errorGroupListDataStatus, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 1073a459cecbc8..55bde1c8fcf2b0 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -5,22 +5,20 @@ * 2.0. */ -import { - EuiCallOut, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiText, -} from '@elastic/eui'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import { APIReturnType } from '../../../services/rest/create_call_apm_api'; +import { useStateDebounced } from '../../../hooks/use_debounce'; import { ApmDocumentType } from '../../../../common/document_type'; -import { ServiceInventoryFieldName } from '../../../../common/service_inventory'; +import { + ServiceInventoryFieldName, + ServiceListItem, +} from '../../../../common/service_inventory'; import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; import { useApmParams } from '../../../hooks/use_apm_params'; -import { FETCH_STATUS, isPending } from '../../../hooks/use_fetcher'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useLocalStorage } from '../../../hooks/use_local_storage'; import { usePreferredDataSourceAndBucketSize } from '../../../hooks/use_preferred_data_source_and_bucket_size'; import { useProgressiveFetcher } from '../../../hooks/use_progressive_fetcher'; @@ -30,17 +28,19 @@ import { SearchBar } from '../../shared/search_bar/search_bar'; import { isTimeComparison } from '../../shared/time_comparison/get_comparison_options'; import { ServiceList } from './service_list'; import { orderServiceItems } from './service_list/order_service_items'; +import { SortFunction } from '../../shared/managed_table'; + +type MainStatisticsApiResponse = APIReturnType<'GET /internal/apm/services'>; -const initialData = { +const INITIAL_PAGE_SIZE = 25; +const INITIAL_DATA: MainStatisticsApiResponse & { requestId: string } = { requestId: '', items: [], - hasHistoricalData: true, - hasLegacyData: false, + serviceOverflowCount: 0, + maxCountExceeded: false, }; -const INITIAL_PAGE_SIZE = 25; - -function useServicesMainStatisticsFetcher() { +function useServicesMainStatisticsFetcher(searchQuery: string | undefined) { const { query: { rangeFrom, @@ -67,7 +67,7 @@ function useServicesMainStatisticsFetcher() { const shouldUseDurationSummary = !!preferred?.source?.hasDurationSummaryField; - const mainStatisticsFetch = useProgressiveFetcher( + const { data = INITIAL_DATA, status } = useProgressiveFetcher( (callApmApi) => { if (preferred) { return callApmApi('GET /internal/apm/services', { @@ -81,6 +81,7 @@ function useServicesMainStatisticsFetcher() { useDurationSummary: shouldUseDurationSummary, documentType: preferred.source.documentType, rollupInterval: preferred.source.rollupInterval, + searchQuery, }, }, }).then((mainStatisticsData) => { @@ -99,7 +100,8 @@ function useServicesMainStatisticsFetcher() { end, serviceGroup, preferred, - // not used, but needed to update the requestId to call the details statistics API when table is options are updated + searchQuery, + // not used, but needed to update the requestId to call the details statistics API when table options are updated page, pageSize, sortField, @@ -107,23 +109,15 @@ function useServicesMainStatisticsFetcher() { ] ); - return { - mainStatisticsFetch, - }; + return { mainStatisticsData: data, mainStatisticsStatus: status }; } function useServicesDetailedStatisticsFetcher({ mainStatisticsFetch, - initialSortField, - initialSortDirection, - tiebreakerField, + renderedItems, }: { - mainStatisticsFetch: ReturnType< - typeof useServicesMainStatisticsFetcher - >['mainStatisticsFetch']; - initialSortField: ServiceInventoryFieldName; - initialSortDirection: 'asc' | 'desc'; - tiebreakerField: ServiceInventoryFieldName; + mainStatisticsFetch: ReturnType; + renderedItems: ServiceListItem[]; }) { const { query: { @@ -133,10 +127,6 @@ function useServicesDetailedStatisticsFetcher({ kuery, offset, comparisonEnabled, - page = 0, - pageSize = INITIAL_PAGE_SIZE, - sortDirection = initialSortDirection, - sortField = initialSortField, }, } = useApmParams('/services'); @@ -150,22 +140,17 @@ function useServicesDetailedStatisticsFetcher({ numBuckets: 20, }); - const { data: mainStatisticsData = initialData } = mainStatisticsFetch; - - const currentPageItems = orderServiceItems({ - items: mainStatisticsData.items, - primarySortField: sortField as ServiceInventoryFieldName, - sortDirection, - tiebreakerField, - }).slice(page * pageSize, (page + 1) * pageSize); + const { mainStatisticsData, mainStatisticsStatus } = mainStatisticsFetch; const comparisonFetch = useProgressiveFetcher( (callApmApi) => { + const serviceNames = renderedItems.map(({ serviceName }) => serviceName); + if ( start && end && - currentPageItems.length && - mainStatisticsFetch.status === FETCH_STATUS.SUCCESS && + serviceNames.length > 0 && + mainStatisticsStatus === FETCH_STATUS.SUCCESS && dataSourceOptions ) { return callApmApi('POST /internal/apm/services/detailed_statistics', { @@ -184,12 +169,8 @@ function useServicesDetailedStatisticsFetcher({ bucketSizeInSeconds: dataSourceOptions.bucketSizeInSeconds, }, body: { - serviceNames: JSON.stringify( - currentPageItems - .map(({ serviceName }) => serviceName) - // Service name is sorted to guarantee the same order every time this API is called so the result can be cached. - .sort() - ), + // Service name is sorted to guarantee the same order every time this API is called so the result can be cached. + serviceNames: JSON.stringify(serviceNames.sort()), }, }, }); @@ -197,7 +178,7 @@ function useServicesDetailedStatisticsFetcher({ }, // only fetches detailed statistics when requestId is invalidated by main statistics api call or offset is changed // eslint-disable-next-line react-hooks/exhaustive-deps - [mainStatisticsData.requestId, offset, comparisonEnabled], + [mainStatisticsData.requestId, renderedItems, offset, comparisonEnabled], { preservePreviousData: false } ); @@ -205,21 +186,21 @@ function useServicesDetailedStatisticsFetcher({ } export function ServiceInventory() { - const { mainStatisticsFetch } = useServicesMainStatisticsFetcher(); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useStateDebounced(''); - const mainStatisticsItems = mainStatisticsFetch.data?.items ?? []; + const [renderedItems, setRenderedItems] = useState([]); - const displayHealthStatus = mainStatisticsItems.some( + const mainStatisticsFetch = + useServicesMainStatisticsFetcher(debouncedSearchQuery); + const { mainStatisticsData, mainStatisticsStatus } = mainStatisticsFetch; + + const displayHealthStatus = mainStatisticsData.items.some( (item) => 'healthStatus' in item ); - const hasKibanaUiLimitRestrictedData = - mainStatisticsFetch.data?.maxServiceCountExceeded; - - const serviceOverflowCount = - mainStatisticsFetch.data?.serviceOverflowCount ?? 0; + const serviceOverflowCount = mainStatisticsData?.serviceOverflowCount ?? 0; - const displayAlerts = mainStatisticsItems.some( + const displayAlerts = mainStatisticsData.items.some( (item) => ServiceInventoryFieldName.AlertsCount in item ); @@ -233,9 +214,7 @@ export function ServiceInventory() { const { comparisonFetch } = useServicesDetailedStatisticsFetcher({ mainStatisticsFetch, - initialSortField, - initialSortDirection, - tiebreakerField, + renderedItems, }); const { anomalyDetectionSetupState } = useAnomalyDetectionJobsContext(); @@ -249,23 +228,20 @@ export function ServiceInventory() { !userHasDismissedCallout && shouldDisplayMlCallout(anomalyDetectionSetupState); - const isLoading = isPending(mainStatisticsFetch.status); - - const isFailure = mainStatisticsFetch.status === FETCH_STATUS.FAILURE; - const noItemsMessage = ( - - {i18n.translate('xpack.apm.servicesTable.notFoundLabel', { - defaultMessage: 'No services found', - })} - - } - titleSize="s" - /> - ); - - const items = mainStatisticsItems; + const noItemsMessage = useMemo(() => { + return ( + + {i18n.translate('xpack.apm.servicesTable.notFoundLabel', { + defaultMessage: 'No services found', + })} + + } + titleSize="s" + /> + ); + }, []); const mlCallout = ( @@ -277,27 +253,16 @@ export function ServiceInventory() { ); - const kibanaUiServiceLimitCallout = ( - - - - - - - + const sortFn: SortFunction = useCallback( + (itemsToSort, sortField, sortDirection) => { + return orderServiceItems({ + items: itemsToSort, + primarySortField: sortField, + sortDirection, + tiebreakerField, + }); + }, + [tiebreakerField] ); return ( @@ -305,12 +270,10 @@ export function ServiceInventory() { {displayMlCallout && mlCallout} - {hasKibanaUiLimitRestrictedData && kibanaUiServiceLimitCallout} { - return orderServiceItems({ - items: itemsToSort, - primarySortField: sortField, - sortDirection, - tiebreakerField, - }); - }} + sortFn={sortFn} comparisonData={comparisonFetch?.data} noItemsMessage={noItemsMessage} initialPageSize={INITIAL_PAGE_SIZE} serviceOverflowCount={serviceOverflowCount} + onChangeSearchQuery={setDebouncedSearchQuery} + maxCountExceeded={mainStatisticsData?.maxCountExceeded ?? false} + onChangeRenderedItems={setRenderedItems} /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 4d3186c784447f..08e7a840b5dfbe 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -17,7 +17,13 @@ import { import { i18n } from '@kbn/i18n'; import { ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; import { TypeOf } from '@kbn/typed-react-router-config'; -import React, { useCallback, useMemo } from 'react'; +import { omit } from 'lodash'; +import React, { useMemo } from 'react'; +import { + FETCH_STATUS, + isFailure, + isPending, +} from '../../../../hooks/use_fetcher'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { ServiceInventoryFieldName, @@ -44,7 +50,12 @@ import { import { EnvironmentBadge } from '../../../shared/environment_badge'; import { ServiceLink } from '../../../shared/links/apm/service_link'; import { ListMetric } from '../../../shared/list_metric'; -import { ITableColumn, ManagedTable } from '../../../shared/managed_table'; +import { + ITableColumn, + ManagedTable, + SortFunction, + TableSearchBar, +} from '../../../shared/managed_table'; import { HealthBadge } from './health_badge'; type ServicesDetailedStatisticsAPIResponse = @@ -273,32 +284,28 @@ export function getServiceColumns({ } interface Props { + status: FETCH_STATUS; items: ServiceListItem[]; comparisonDataLoading: boolean; comparisonData?: ServicesDetailedStatisticsAPIResponse; noItemsMessage?: React.ReactNode; - isLoading: boolean; - isFailure?: boolean; displayHealthStatus: boolean; displayAlerts: boolean; initialSortField: ServiceInventoryFieldName; initialPageSize: number; initialSortDirection: 'asc' | 'desc'; - sortFn: ( - sortItems: ServiceListItem[], - sortField: ServiceInventoryFieldName, - sortDirection: 'asc' | 'desc' - ) => ServiceListItem[]; - + sortFn: SortFunction; serviceOverflowCount: number; + maxCountExceeded: boolean; + onChangeSearchQuery: (searchQuery: string) => void; + onChangeRenderedItems: (renderedItems: ServiceListItem[]) => void; } export function ServiceList({ + status, items, noItemsMessage, comparisonDataLoading, comparisonData, - isLoading, - isFailure, displayHealthStatus, displayAlerts, initialSortField, @@ -306,67 +313,59 @@ export function ServiceList({ initialPageSize, sortFn, serviceOverflowCount, + maxCountExceeded, + onChangeSearchQuery, + onChangeRenderedItems, }: Props) { const breakpoints = useBreakpoints(); const { link } = useApmRouter(); - const showTransactionTypeColumn = items.some( ({ transactionType }) => transactionType && !isDefaultTransactionType(transactionType) ); - const { - // removes pagination and sort instructions from the query so it won't be passed down to next route - query: { - page, - pageSize, - sortDirection: direction, - sortField: field, - ...query - }, - } = useApmParams('/services'); - + const { query } = useApmParams('/services'); const { kuery } = query; - const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ kuery, }); - const serviceColumns = useMemo( - () => - getServiceColumns({ - query, - showTransactionTypeColumn, - comparisonDataLoading, - comparisonData, - breakpoints, - showHealthStatusColumn: displayHealthStatus, - showAlertsColumn: displayAlerts, - link, - serviceOverflowCount, - }), - [ - query, + const serviceColumns = useMemo(() => { + return getServiceColumns({ + // removes pagination and sort instructions from the query so it won't be passed down to next route + query: omit(query, 'page', 'pageSize', 'sortDirection', 'sortField'), showTransactionTypeColumn, comparisonDataLoading, comparisonData, breakpoints, - displayHealthStatus, - displayAlerts, + showHealthStatusColumn: displayHealthStatus, + showAlertsColumn: displayAlerts, link, serviceOverflowCount, - ] - ); + }); + }, [ + query, + showTransactionTypeColumn, + comparisonDataLoading, + comparisonData, + breakpoints, + displayHealthStatus, + displayAlerts, + link, + serviceOverflowCount, + ]); - const handleSort = useCallback( - (itemsToSort, sortField, sortDirection) => - sortFn( - itemsToSort, - sortField as ServiceInventoryFieldName, - sortDirection + const tableSearchBar: TableSearchBar = useMemo(() => { + return { + fieldsToSearch: ['serviceName'], + maxCountExceeded, + onChangeSearchQuery, + placeholder: i18n.translate( + 'xpack.apm.servicesTable.filterServicesPlaceholder', + { defaultMessage: 'Search services by name' } ), - [sortFn] - ); + }; + }, [maxCountExceeded, onChangeSearchQuery]); return ( @@ -381,6 +380,24 @@ export function ServiceList({ )} + + {maxCountExceeded && ( + + + + + + )} + + - isLoading={isLoading} - error={isFailure} + isLoading={isPending(status)} + error={isFailure(status)} columns={serviceColumns} items={items} noItemsMessage={noItemsMessage} initialSortField={initialSortField} initialSortDirection={initialSortDirection} initialPageSize={initialPageSize} - sortFn={handleSort} + sortFn={sortFn} + onChangeRenderedItems={onChangeRenderedItems} + tableSearchBar={tableSearchBar} /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts index 89dade9d8d0cda..17c018e272ee85 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/order_service_items.ts @@ -49,7 +49,7 @@ export function orderServiceItems({ sortDirection, }: { items: ServiceListItem[]; - primarySortField: ServiceInventoryFieldName; + primarySortField: string; tiebreakerField: ServiceInventoryFieldName; sortDirection: 'asc' | 'desc'; }): ServiceListItem[] { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx index 515aadaf11b52e..7f81dfcc0b3b19 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.stories.tsx @@ -8,6 +8,7 @@ import { CoreStart } from '@kbn/core/public'; import { Meta, Story } from '@storybook/react'; import React, { ComponentProps } from 'react'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { ServiceList } from '.'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { ServiceInventoryFieldName } from '../../../../../common/service_inventory'; @@ -48,11 +49,11 @@ const stories: Meta = { }; export default stories; -export const Example: Story = (args) => { +export const ServiceListWithItems: Story = (args) => { return ; }; -Example.args = { - isLoading: false, +ServiceListWithItems.args = { + status: FETCH_STATUS.SUCCESS, items, displayHealthStatus: true, initialSortField: ServiceInventoryFieldName.HealthStatus, @@ -61,11 +62,11 @@ Example.args = { sortFn: (sortItems) => sortItems, }; -export const EmptyState: Story = (args) => { +export const ServiceListEmptyState: Story = (args) => { return ; }; -EmptyState.args = { - isLoading: false, +ServiceListEmptyState.args = { + status: FETCH_STATUS.SUCCESS, items: [], displayHealthStatus: true, initialSortField: ServiceInventoryFieldName.HealthStatus, @@ -78,7 +79,7 @@ export const WithHealthWarnings: Story = (args) => { return ; }; WithHealthWarnings.args = { - isLoading: false, + status: FETCH_STATUS.SUCCESS, initialPageSize: 25, items: items.map((item) => ({ ...item, @@ -87,12 +88,12 @@ WithHealthWarnings.args = { sortFn: (sortItems) => sortItems, }; -export const WithOverflowBucket: Story = (args) => { +export const ServiceListWithOverflowBucket: Story = (args) => { return ; }; -WithOverflowBucket.args = { - isLoading: false, +ServiceListWithOverflowBucket.args = { + status: FETCH_STATUS.SUCCESS, items: overflowItems, displayHealthStatus: false, initialSortField: ServiceInventoryFieldName.HealthStatus, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx index 74856cbca3b114..04659b6b211bc2 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/service_list.test.tsx @@ -15,7 +15,7 @@ import { apmRouter } from '../../../routing/apm_route_config'; import * as timeSeriesColor from '../../../shared/charts/helper/get_timeseries_color'; import * as stories from './service_list.stories'; -const { Example, EmptyState } = composeStories(stories); +const { ServiceListEmptyState, ServiceListWithItems } = composeStories(stories); const query = { rangeFrom: 'now-15m', @@ -56,13 +56,13 @@ describe('ServiceList', () => { }); it('renders empty state', async () => { - render(); + render(); expect(await screen.findByRole('table')).toBeInTheDocument(); }); it('renders with data', async () => { - render(); + render(); expect(await screen.findByRole('table')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 55972ede6e5602..4b2ab0ad6c3a8e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -139,6 +139,7 @@ export function ServiceOverview() { start={start} end={end} showPerPageOptions={false} + numberOfTransactionsPerPage={5} /> diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index 81e5509ca6239a..499729b31ae3bd 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -5,180 +5,20 @@ * 2.0. */ -import { - EuiBasicTable, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { orderBy } from 'lodash'; -import React, { useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options'; -import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; -import { ErrorOverviewLink } from '../../../shared/links/apm/error_overview_link'; -import { OverviewTableContainer } from '../../../shared/overview_table_container'; -import { getColumns } from '../../../shared/errors_table/get_columns'; +import React from 'react'; import { useApmParams } from '../../../../hooks/use_apm_params'; -import { useTimeRange } from '../../../../hooks/use_time_range'; +import { ErrorOverviewLink } from '../../../shared/links/apm/error_overview_link'; +import { ErrorGroupList } from '../../error_group_overview/error_group_list'; interface Props { serviceName: string; } -type ErrorGroupMainStatistics = - APIReturnType<'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics'>; -type ErrorGroupDetailedStatistics = - APIReturnType<'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics'>; - -type SortDirection = 'asc' | 'desc'; -type SortField = 'name' | 'lastSeen' | 'occurrences'; - -const PAGE_SIZE = 5; -const DEFAULT_SORT = { - direction: 'desc' as const, - field: 'occurrences' as const, -}; - -const INITIAL_STATE_MAIN_STATISTICS: { - items: ErrorGroupMainStatistics['errorGroups']; - totalItems: number; - requestId?: string; -} = { - items: [], - totalItems: 0, - requestId: undefined, -}; - -const INITIAL_STATE_DETAILED_STATISTICS: ErrorGroupDetailedStatistics = { - currentPeriod: {}, - previousPeriod: {}, -}; export function ServiceOverviewErrorsTable({ serviceName }: Props) { - const [tableOptions, setTableOptions] = useState<{ - pageIndex: number; - sort: { - direction: SortDirection; - field: SortField; - }; - }>({ - pageIndex: 0, - sort: DEFAULT_SORT, - }); - const { query } = useApmParams('/services/{serviceName}/overview'); - const { environment, kuery, rangeFrom, rangeTo, offset, comparisonEnabled } = - query; - - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - - const { pageIndex, sort } = tableOptions; - const { direction, field } = sort; - - const { data = INITIAL_STATE_MAIN_STATISTICS, status } = useFetcher( - (callApmApi) => { - if (!start || !end) { - return; - } - return callApmApi( - 'GET /internal/apm/services/{serviceName}/errors/groups/main_statistics', - { - params: { - path: { serviceName }, - query: { - environment, - kuery, - start, - end, - }, - }, - } - ).then((response) => { - const currentPageErrorGroups = orderBy( - response.errorGroups, - field, - direction - ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); - - return { - // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. - requestId: uuidv4(), - items: currentPageErrorGroups, - totalItems: response.errorGroups.length, - }; - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - environment, - kuery, - start, - end, - serviceName, - pageIndex, - direction, - field, - // not used, but needed to trigger an update when offset is changed either manually by user or when time range is changed - offset, - // not used, but needed to trigger an update when comparison feature is disabled/enabled by user - comparisonEnabled, - ] - ); - - const { requestId, items, totalItems } = data; - - const { - data: errorGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, - status: errorGroupDetailedStatisticsStatus, - } = useFetcher( - (callApmApi) => { - if (requestId && items.length && start && end) { - return callApmApi( - 'POST /internal/apm/services/{serviceName}/errors/groups/detailed_statistics', - { - params: { - path: { serviceName }, - query: { - environment, - kuery, - start, - end, - numBuckets: 20, - offset: - comparisonEnabled && isTimeComparison(offset) - ? offset - : undefined, - }, - body: { - groupIds: JSON.stringify( - items.map(({ groupId: groupId }) => groupId).sort() - ), - }, - }, - } - ); - } - }, - // only fetches agg results when requestId changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], - { preservePreviousData: false } - ); - - const errorGroupDetailedStatisticsLoading = - errorGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING; - - const columns = getColumns({ - serviceName, - errorGroupDetailedStatisticsLoading, - errorGroupDetailedStatistics, - comparisonEnabled, - query, - }); - return ( - - { - setTableOptions({ - pageIndex: newTableOptions.page?.index ?? 0, - sort: newTableOptions.sort - ? { - field: newTableOptions.sort.field as SortField, - direction: newTableOptions.sort.direction, - } - : DEFAULT_SORT, - }); - }} - sorting={{ - enableAllColumns: true, - sort, - }} - /> - + ); diff --git a/x-pack/plugins/apm/public/components/app/settings/custom_link/index.test.tsx b/x-pack/plugins/apm/public/components/app/settings/custom_link/index.test.tsx index 40f8f5ad1db253..37bf4c83c19604 100644 --- a/x-pack/plugins/apm/public/components/app/settings/custom_link/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/custom_link/index.test.tsx @@ -112,7 +112,7 @@ describe('CustomLink', () => { expect(createButton.disabled).toBeFalsy(); }); - it('enables edit button on custom link table when user has writte privileges', () => { + it('enables edit button on custom link table when user has write privileges', () => { const mockContext = getMockAPMContext({ canSave: true }); const { getAllByText } = render( diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 372138c1273a90..73f5e40aa2e2a2 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -91,7 +91,7 @@ export function TransactionOverview() { - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 25, - "pageSizeOptions": Array [ - 10, - 25, - 50, - ], - "showPerPageOptions": true, - "totalItemCount": 3, - } - } - responsive={true} - sorting={ - Object { - "sort": Object { - "direction": "asc", - "field": "name", - }, - } - } - tableLayout="fixed" -/> -`; - -exports[`ManagedTable should render when specifying initial values 1`] = ` - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 1, - "pageSize": 2, - "pageSizeOptions": Array [ - 10, - 25, - 50, - ], - "showPerPageOptions": false, - "totalItemCount": 3, - } - } - responsive={true} - sorting={ - Object { - "sort": Object { - "direction": "desc", - "field": "age", - }, - } - } - tableLayout="fixed" -/> -`; diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 88d9e88c5e7baa..7d6307d32ffb82 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -7,11 +7,30 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; -import { orderBy } from 'lodash'; -import React, { ReactNode, useCallback, useMemo } from 'react'; +import { isEmpty, merge, orderBy } from 'lodash'; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useHistory } from 'react-router-dom'; +import { apmEnableTableSearchBar } from '@kbn/observability-plugin/common'; import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { fromQuery, toQuery } from '../links/url_helpers'; +import { + getItemsFilteredBySearchQuery, + TableSearchBar, +} from '../table_search_bar/table_search_bar'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; + +type SortDirection = 'asc' | 'desc'; + +export interface TableOptions { + page: { index: number; size: number }; + sort: { direction: SortDirection; field: keyof T }; +} // TODO: this should really be imported from EUI export interface ITableColumn { @@ -26,143 +45,274 @@ export interface ITableColumn { render?: (value: any, item: T) => unknown; } -interface Props { - items: T[]; - columns: Array>; - initialPageSize: number; - initialPageIndex?: number; - initialSortField?: ITableColumn['field']; - initialSortDirection?: 'asc' | 'desc'; - showPerPageOptions?: boolean; - noItemsMessage?: React.ReactNode; - sortItems?: boolean; - sortFn?: SortFunction; - pagination?: boolean; - isLoading?: boolean; - error?: boolean; - tableLayout?: 'auto' | 'fixed'; +export interface TableSearchBar { + isEnabled?: boolean; + fieldsToSearch: Array; + maxCountExceeded: boolean; + placeholder: string; + onChangeSearchQuery: (searchQuery: string) => void; } const PAGE_SIZE_OPTIONS = [10, 25, 50]; -function defaultSortFn( +function defaultSortFn( items: T[], - sortField: string, - sortDirection: 'asc' | 'desc' + sortField: keyof T, + sortDirection: SortDirection ) { - return orderBy(items, sortField, sortDirection); + return orderBy(items, sortField, sortDirection) as T[]; } export type SortFunction = ( items: T[], - sortField: string, - sortDirection: 'asc' | 'desc' + sortField: keyof T, + sortDirection: SortDirection ) => T[]; -function UnoptimizedManagedTable(props: Props) { +export const shouldfetchServer = ({ + maxCountExceeded, + newSearchQuery, + oldSearchQuery, +}: { + maxCountExceeded: boolean; + newSearchQuery: string; + oldSearchQuery: string; +}) => maxCountExceeded || !newSearchQuery.includes(oldSearchQuery); + +function UnoptimizedManagedTable(props: { + items: T[]; + columns: Array>; + noItemsMessage?: React.ReactNode; + isLoading?: boolean; + error?: boolean; + + // pagination + pagination?: boolean; + initialPageSize: number; + initialPageIndex?: number; + initialSortField?: string; + initialSortDirection?: SortDirection; + showPerPageOptions?: boolean; + + // onChange handlers + onChangeRenderedItems?: (renderedItems: T[]) => void; + onChangeSorting?: (sorting: TableOptions['sort']) => void; + + // sorting + sortItems?: boolean; + sortFn?: SortFunction; + + tableLayout?: 'auto' | 'fixed'; + tableSearchBar?: TableSearchBar; + saveTableOptionsToUrl?: boolean; +}) { + const [searchQuery, setSearchQuery] = useState(''); const history = useHistory(); + const { core } = useApmPluginContext(); + const isTableSearchBarEnabled = core.uiSettings.get( + apmEnableTableSearchBar, + false + ); + const { items, columns, + noItemsMessage, + isLoading = false, + error = false, + + // pagination + pagination = true, initialPageIndex = 0, - initialPageSize, + initialPageSize = 10, initialSortField = props.columns[0]?.field || '', initialSortDirection = 'asc', showPerPageOptions = true, - noItemsMessage, + + // onChange handlers + onChangeRenderedItems = () => {}, + onChangeSorting = () => {}, + + // sorting sortItems = true, sortFn = defaultSortFn, - pagination = true, - isLoading = false, - error = false, + + saveTableOptionsToUrl = true, tableLayout, + tableSearchBar = { + isEnabled: false, + fieldsToSearch: [], + maxCountExceeded: false, + placeholder: 'Search...', + onChangeSearchQuery: () => {}, + }, } = props; const { urlParams: { - page = initialPageIndex, - pageSize = initialPageSize, - sortField = initialSortField, - sortDirection = initialSortDirection, + page: urlPageIndex = initialPageIndex, + pageSize: urlPageSize = initialPageSize, + sortField: urlSortField = initialSortField, + sortDirection: urlSortDirection = initialSortDirection, }, } = useLegacyUrlParams(); - const renderedItems = useMemo(() => { - const sortedItems = sortItems - ? sortFn(items, sortField, sortDirection as 'asc' | 'desc') - : items; - - return sortedItems.slice(page * pageSize, (page + 1) * pageSize); - }, [page, pageSize, sortField, sortDirection, items, sortItems, sortFn]); - - const sort = useMemo(() => { - return { + const getStateFromUrl = useCallback( + (): TableOptions => ({ + page: { index: urlPageIndex, size: urlPageSize }, sort: { - field: sortField as keyof T, - direction: sortDirection as 'asc' | 'desc', + field: urlSortField as keyof T, + direction: urlSortDirection as SortDirection, }, - }; - }, [sortField, sortDirection]); + }), + [urlPageIndex, urlPageSize, urlSortField, urlSortDirection] + ); + + // initialise table options state from url params + const [tableOptions, setTableOptions] = useState(getStateFromUrl()); + // update table options state when url params change + useEffect(() => setTableOptions(getStateFromUrl()), [getStateFromUrl]); + + // update table options state when `onTableChange` is invoked and persist to url const onTableChange = useCallback( - (options: { - page: { index: number; size: number }; - sort?: { field: keyof T; direction: 'asc' | 'desc' }; - }) => { - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - page: options.page.index, - pageSize: options.page.size, - sortField: options.sort?.field, - sortDirection: options.sort?.direction, - }), - }); + (newTableOptions: Partial>) => { + setTableOptions((oldTableOptions) => + merge({}, oldTableOptions, newTableOptions) + ); + + if (saveTableOptionsToUrl) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + page: newTableOptions.page?.index, + pageSize: newTableOptions.page?.size, + sortField: newTableOptions.sort?.field, + sortDirection: newTableOptions.sort?.direction, + }), + }); + } }, - [history] + [history, saveTableOptionsToUrl, setTableOptions] + ); + + const filteredItems = useMemo(() => { + return isEmpty(searchQuery) + ? items + : getItemsFilteredBySearchQuery({ + items, + fieldsToSearch: tableSearchBar.fieldsToSearch, + searchQuery, + }); + }, [items, searchQuery, tableSearchBar.fieldsToSearch]); + + const renderedItems = useMemo(() => { + const sortedItems = sortItems + ? sortFn( + filteredItems, + tableOptions.sort.field as keyof T, + tableOptions.sort.direction + ) + : filteredItems; + + return sortedItems.slice( + tableOptions.page.index * tableOptions.page.size, + (tableOptions.page.index + 1) * tableOptions.page.size + ); + }, [ + sortItems, + sortFn, + filteredItems, + tableOptions.sort.field, + tableOptions.sort.direction, + tableOptions.page.index, + tableOptions.page.size, + ]); + + useEffect(() => { + onChangeRenderedItems(renderedItems); + }, [onChangeRenderedItems, renderedItems]); + + const sorting = useMemo( + () => ({ sort: tableOptions.sort as TableOptions['sort'] }), + [tableOptions.sort] ); + useEffect(() => onChangeSorting(sorting.sort), [onChangeSorting, sorting]); + const paginationProps = useMemo(() => { if (!pagination) { return; } return { showPerPageOptions, - totalItemCount: items.length, - pageIndex: page, - pageSize, + totalItemCount: filteredItems.length, + pageIndex: tableOptions.page.index, + pageSize: tableOptions.page.size, pageSizeOptions: PAGE_SIZE_OPTIONS, }; - }, [showPerPageOptions, items, page, pageSize, pagination]); + }, [ + pagination, + showPerPageOptions, + filteredItems.length, + tableOptions.page.index, + tableOptions.page.size, + ]); - const showNoItemsMessage = useMemo(() => { - return isLoading - ? i18n.translate('xpack.apm.managedTable.loadingDescription', { - defaultMessage: 'Loading…', + const onChangeSearchQuery = useCallback( + (value: string) => { + setSearchQuery(value); + if ( + shouldfetchServer({ + maxCountExceeded: tableSearchBar.maxCountExceeded, + newSearchQuery: value, + oldSearchQuery: searchQuery, }) - : noItemsMessage; - }, [isLoading, noItemsMessage]); + ) { + tableSearchBar.onChangeSearchQuery(value); + } + }, + [searchQuery, tableSearchBar] + ); + + const isSearchBarEnabled = + isTableSearchBarEnabled && (tableSearchBar.isEnabled ?? true); return ( - // @ts-expect-error TS thinks pagination should be non-nullable, but it's not - >} // EuiBasicTableColumn is stricter than ITableColumn - sorting={sort} - onChange={onTableChange} - {...(paginationProps ? { pagination: paginationProps } : {})} - /> + <> + {isSearchBarEnabled ? ( + + ) : null} + + + loading={isLoading} + tableLayout={tableLayout} + error={ + error + ? i18n.translate('xpack.apm.managedTable.errorMessage', { + defaultMessage: 'Failed to fetch', + }) + : '' + } + noItemsMessage={ + isLoading + ? i18n.translate('xpack.apm.managedTable.loadingDescription', { + defaultMessage: 'Loading…', + }) + : noItemsMessage + } + items={renderedItems} + columns={columns as unknown as Array>} // EuiBasicTableColumn is stricter than ITableColumn + sorting={sorting} + onChange={onTableChange} + {...(paginationProps ? { pagination: paginationProps } : {})} + /> + ); } diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/managed_table.test.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/managed_table.test.tsx index a43d78887ec9fb..5bc6199aba22d2 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/managed_table.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/managed_table.test.tsx @@ -5,56 +5,62 @@ * 2.0. */ -import { shallow } from 'enzyme'; -import React from 'react'; -import { ITableColumn, UnoptimizedManagedTable } from '.'; - -interface Person { - name: string; - age: number; -} +import { shouldfetchServer } from '.'; describe('ManagedTable', () => { - const people: Person[] = [ - { name: 'Jess', age: 29 }, - { name: 'Becky', age: 43 }, - { name: 'Thomas', age: 31 }, - ]; - const columns: Array> = [ - { - field: 'name', - name: 'Name', - sortable: true, - render: (name) => `Name: ${name}`, - }, - { field: 'age', name: 'Age', render: (age) => `Age: ${age}` }, - ]; + describe('shouldfetchServer', () => { + it('returns true if maxCountExceeded is true', () => { + const result = shouldfetchServer({ + maxCountExceeded: true, + newSearchQuery: 'apple', + oldSearchQuery: 'banana', + }); + expect(result).toBeTruthy(); + }); - it('should render a page-full of items, with defaults', () => { - expect( - shallow( - - columns={columns} - items={people} - initialPageSize={25} - /> - ) - ).toMatchSnapshot(); - }); + it('returns true if newSearchQuery does not include oldSearchQuery', () => { + const result = shouldfetchServer({ + maxCountExceeded: false, + newSearchQuery: 'grape', + oldSearchQuery: 'banana', + }); + expect(result).toBeTruthy(); + }); + + it('returns false if maxCountExceeded is false and newSearchQuery includes oldSearchQuery', () => { + const result = shouldfetchServer({ + maxCountExceeded: false, + newSearchQuery: 'banana', + oldSearchQuery: 'ban', + }); + expect(result).toBeFalsy(); + }); + + it('returns true if maxCountExceeded is true even if newSearchQuery includes oldSearchQuery', () => { + const result = shouldfetchServer({ + maxCountExceeded: true, + newSearchQuery: 'banana', + oldSearchQuery: 'ban', + }); + expect(result).toBeTruthy(); + }); + + it('returns true if maxCountExceeded is true and newSearchQuery is empty', () => { + const result = shouldfetchServer({ + maxCountExceeded: true, + newSearchQuery: '', + oldSearchQuery: 'banana', + }); + expect(result).toBeTruthy(); + }); - it('should render when specifying initial values', () => { - expect( - shallow( - - columns={columns} - items={people} - initialSortField="age" - initialSortDirection="desc" - initialPageIndex={1} - initialPageSize={2} - showPerPageOptions={false} - /> - ) - ).toMatchSnapshot(); + it('returns false if maxCountExceeded is false and both search queries are empty', () => { + const result = shouldfetchServer({ + maxCountExceeded: false, + newSearchQuery: '', + oldSearchQuery: '', + }); + expect(result).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index cec958da0eb706..de17d0ccf6d4b3 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -52,6 +52,14 @@ function Wrapper({ children }: { children?: ReactNode }) { } describe('ServiceIcons', () => { + beforeAll(() => { + // Mocks console.warn so it won't polute tests output when testing the api throwing error + jest.spyOn(console, 'warn').mockImplementation(() => null); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); describe('icons', () => { it('Shows loading spinner while fetching data', () => { jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ diff --git a/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.test.ts b/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.test.ts new file mode 100644 index 00000000000000..ebbd5031c6380a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getItemsFilteredBySearchQuery } from './table_search_bar'; + +describe('getItemsFilteredBySearchQuery', () => { + const sampleItems = [ + { name: 'Apple', category: 'Fruit' }, + { name: 'Banana', category: 'Fruit' }, + { name: 'Carrot', category: 'Vegetable' }, + ]; + + it('should filter items based on full match', () => { + const result = getItemsFilteredBySearchQuery({ + items: sampleItems, + fieldsToSearch: ['name'], + searchQuery: 'Banana', + }); + expect(result).toEqual([{ name: 'Banana', category: 'Fruit' }]); + }); + + it('should filter items based on partial match', () => { + const result = getItemsFilteredBySearchQuery({ + items: sampleItems, + fieldsToSearch: ['name'], + searchQuery: 'car', + }); + expect(result).toEqual([{ name: 'Carrot', category: 'Vegetable' }]); + }); + + it('should be case-insensitive', () => { + const result = getItemsFilteredBySearchQuery({ + items: sampleItems, + fieldsToSearch: ['category'], + searchQuery: 'fruit', + }); + expect(result).toEqual([ + { name: 'Apple', category: 'Fruit' }, + { name: 'Banana', category: 'Fruit' }, + ]); + }); + + it('should handle undefined field values', () => { + const itemsWithUndefined = [ + { name: 'Apple', category: 'Fruit' }, + { name: 'Banana', category: undefined }, + ]; + const result = getItemsFilteredBySearchQuery({ + items: itemsWithUndefined, + fieldsToSearch: ['category'], + searchQuery: 'fruit', + }); + expect(result).toEqual([{ name: 'Apple', category: 'Fruit' }]); + }); + + it('should return an empty array if no match is found', () => { + const result = getItemsFilteredBySearchQuery({ + items: sampleItems, + fieldsToSearch: ['name'], + searchQuery: 'Grapes', + }); + expect(result).toEqual([]); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.tsx b/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.tsx new file mode 100644 index 00000000000000..7ff7322c98354b --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiFieldSearch } from '@elastic/eui'; +import React from 'react'; + +interface Props { + placeholder: string; + searchQuery: string; + onChangeSearchQuery: (value: string) => void; +} + +export function TableSearchBar({ + placeholder, + searchQuery, + onChangeSearchQuery, +}: Props) { + return ( + { + onChangeSearchQuery(e.target.value); + }} + /> + ); +} + +export function getItemsFilteredBySearchQuery({ + items, + fieldsToSearch, + searchQuery, +}: { + items: T[]; + fieldsToSearch: P[]; + searchQuery: string; +}) { + return items.filter((item) => { + return fieldsToSearch.some((field) => { + const fieldValue = item[field] as unknown as string | undefined; + return fieldValue?.toLowerCase().includes(searchQuery.toLowerCase()); + }); + }); +} diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx index 32329b81a1edba..083fd1e42f5f4f 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx @@ -7,7 +7,6 @@ import { EuiBadge, - EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -42,6 +41,7 @@ import { TRANSACTION_TYPE, } from '../../../../common/es_fields/apm'; import { fieldValuePairToKql } from '../../../../common/utils/field_value_pair_to_kql'; +import { ITableColumn } from '../managed_table'; type TransactionGroupMainStatistics = APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>; @@ -55,8 +55,8 @@ type TransactionGroupDetailedStatistics = export function getColumns({ serviceName, latencyAggregationType, - transactionGroupDetailedStatisticsLoading, - transactionGroupDetailedStatistics, + detailedStatisticsLoading, + detailedStatistics, comparisonEnabled, shouldShowSparkPlots = true, showAlertsColumn, @@ -67,8 +67,8 @@ export function getColumns({ }: { serviceName: string; latencyAggregationType?: LatencyAggregationType; - transactionGroupDetailedStatisticsLoading: boolean; - transactionGroupDetailedStatistics?: TransactionGroupDetailedStatistics; + detailedStatisticsLoading: boolean; + detailedStatistics?: TransactionGroupDetailedStatistics; comparisonEnabled?: boolean; shouldShowSparkPlots?: boolean; showAlertsColumn: boolean; @@ -76,7 +76,7 @@ export function getColumns({ transactionOverflowCount: number; link: any; query: TypeOf['query']; -}): Array> { +}): Array> { return [ ...(showAlertsColumn ? [ @@ -128,7 +128,7 @@ export function getColumns({ ); }, - } as EuiBasicTableColumn, + } as ITableColumn, ] : []), { @@ -162,20 +162,18 @@ export function getColumns({ align: RIGHT_ALIGNMENT, render: (_, { latency, name }) => { const currentTimeseries = - transactionGroupDetailedStatistics?.currentPeriod?.[name]?.latency; + detailedStatistics?.currentPeriod?.[name]?.latency; const previousTimeseries = - transactionGroupDetailedStatistics?.previousPeriod?.[name]?.latency; - + detailedStatistics?.previousPeriod?.[name]?.latency; const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( ChartType.LATENCY_AVG ); - return ( { const currentTimeseries = - transactionGroupDetailedStatistics?.currentPeriod?.[name]?.throughput; + detailedStatistics?.currentPeriod?.[name]?.throughput; const previousTimeseries = - transactionGroupDetailedStatistics?.previousPeriod?.[name] - ?.throughput; - + detailedStatistics?.previousPeriod?.[name]?.throughput; const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( ChartType.THROUGHPUT ); - return ( { const currentTimeseries = - transactionGroupDetailedStatistics?.currentPeriod?.[name]?.errorRate; + detailedStatistics?.currentPeriod?.[name]?.errorRate; const previousTimeseries = - transactionGroupDetailedStatistics?.previousPeriod?.[name]?.errorRate; - + detailedStatistics?.previousPeriod?.[name]?.errorRate; const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( ChartType.FAILED_TRANSACTION_RATE ); - return ( { const currentImpact = - transactionGroupDetailedStatistics?.currentPeriod?.[name]?.impact ?? - 0; + detailedStatistics?.currentPeriod?.[name]?.impact ?? 0; const previousImpact = - transactionGroupDetailedStatistics?.previousPeriod?.[name]?.impact; + detailedStatistics?.previousPeriod?.[name]?.impact; return ( diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index bd0e4f242cf39f..e4553dfee073a8 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -5,19 +5,12 @@ * 2.0. */ -import { - EuiBasicTable, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { v4 as uuidv4 } from 'uuid'; import { FormattedMessage } from '@kbn/i18n-react'; -import { orderBy } from 'lodash'; +import { compact } from 'lodash'; import React, { useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; -import { v4 as uuidv4 } from 'uuid'; import { ApmDocumentType } from '../../../../common/document_type'; import { getLatencyAggregationType, @@ -27,6 +20,7 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; +import { useStateDebounced } from '../../../hooks/use_debounce'; import { FETCH_STATUS, isPending, @@ -35,7 +29,7 @@ import { import { usePreferredDataSourceAndBucketSize } from '../../../hooks/use_preferred_data_source_and_bucket_size'; import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { TransactionOverviewLink } from '../links/apm/transaction_overview_link'; -import { fromQuery, toQuery } from '../links/url_helpers'; +import { ManagedTable, TableSearchBar } from '../managed_table'; import { OverviewTableContainer } from '../overview_table_container'; import { isTimeComparison } from '../time_comparison/get_comparison_options'; import { getColumns } from './get_columns'; @@ -43,29 +37,12 @@ import { getColumns } from './get_columns'; type ApiResponse = APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>; -interface InitialState { - requestId: string; - mainStatisticsData: ApiResponse & { - transactionGroupsTotalItems: number; - }; -} - -const INITIAL_STATE: InitialState = { +const INITIAL_STATE: ApiResponse & { requestId: string } = { requestId: '', - mainStatisticsData: { - transactionGroups: [], - maxTransactionGroupsExceeded: false, - transactionOverflowCount: 0, - transactionGroupsTotalItems: 0, - hasActiveAlerts: false, - }, -}; - -type SortField = 'name' | 'latency' | 'throughput' | 'errorRate' | 'impact'; -type SortDirection = 'asc' | 'desc'; -const DEFAULT_SORT = { - direction: 'desc' as const, - field: 'impact' as const, + transactionGroups: [], + maxCountExceeded: false, + transactionOverflowCount: 0, + hasActiveAlerts: false, }; interface Props { @@ -88,7 +65,7 @@ export function TransactionsTable({ hideViewTransactionsLink = false, hideTitle = false, isSingleColumn = true, - numberOfTransactionsPerPage = 5, + numberOfTransactionsPerPage = 10, showPerPageOptions = true, showMaxTransactionGroupsExceededWarning = false, environment, @@ -97,7 +74,6 @@ export function TransactionsTable({ end, saveTableOptionsToUrl = false, }: Props) { - const history = useHistory(); const { link } = useApmRouter(); const { @@ -106,10 +82,6 @@ export function TransactionsTable({ comparisonEnabled, offset, latencyAggregationType: latencyAggregationTypeFromQuery, - page: urlPage = 0, - pageSize: urlPageSize = numberOfTransactionsPerPage, - sortField: urlSortField = 'impact', - sortDirection: urlSortDirection = 'desc', }, } = useAnyOfApmParams( '/services/{serviceName}/transactions', @@ -122,192 +94,75 @@ export function TransactionsTable({ latencyAggregationTypeFromQuery ); - const [tableOptions, setTableOptions] = useState<{ - page: { index: number; size: number }; - sort: { direction: SortDirection; field: SortField }; - }>({ - page: { index: urlPage, size: urlPageSize }, - sort: { - field: urlSortField as SortField, - direction: urlSortDirection as SortDirection, - }, - }); - // SparkPlots should be hidden if we're in two-column view and size XL (1200px) const { isXl } = useBreakpoints(); const shouldShowSparkPlots = isSingleColumn || !isXl; - - const { page, sort } = tableOptions; - const { direction, field } = sort; - const { index, size } = page; - const { transactionType, serviceName } = useApmServiceContext(); + const [searchQuery, setSearchQueryDebounced] = useStateDebounced(''); - const preferred = usePreferredDataSourceAndBucketSize({ - start, + const [renderedItems, setRenderedItems] = useState< + ApiResponse['transactionGroups'] + >([]); + + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useTableData({ + comparisonEnabled, + currentPageItems: renderedItems, end, + environment, kuery, - numBuckets: 20, - type: ApmDocumentType.TransactionMetric, + latencyAggregationType, + offset, + searchQuery, + serviceName, + start, + transactionType, }); - const shouldUseDurationSummary = - latencyAggregationType === 'avg' && - preferred?.source?.hasDurationSummaryField; - - const { data = INITIAL_STATE, status } = useFetcher( - (callApmApi) => { - if (!latencyAggregationType || !transactionType || !preferred) { - return Promise.resolve(undefined); - } - return callApmApi( - 'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics', - { - params: { - path: { serviceName }, - query: { - environment, - kuery, - start, - end, - transactionType, - useDurationSummary: !!shouldUseDurationSummary, - latencyAggregationType: - latencyAggregationType as LatencyAggregationType, - documentType: preferred.source.documentType, - rollupInterval: preferred.source.rollupInterval, - }, - }, - } - ).then((response) => { - const currentPageTransactionGroups = orderBy( - response.transactionGroups, - [field], - [direction] - ).slice(index * size, (index + 1) * size); - - return { - // Everytime the main statistics is refetched, updates the requestId making the detailed API to be refetched. - requestId: uuidv4(), - mainStatisticsData: { - ...response, - transactionGroups: currentPageTransactionGroups, - transactionGroupsTotalItems: response.transactionGroups.length, - }, - }; - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - environment, - kuery, + const columns = useMemo(() => { + return getColumns({ serviceName, - start, - end, - transactionType, - latencyAggregationType, - index, - size, - direction, - field, - // not used, but needed to trigger an update when offset is changed either manually by user or when time range is changed - offset, - // not used, but needed to trigger an update when comparison feature is disabled/enabled by user + latencyAggregationType: latencyAggregationType as LatencyAggregationType, + detailedStatisticsLoading: isPending(detailedStatisticsStatus), + detailedStatistics, comparisonEnabled, - preferred, - ] - ); - - const { - requestId, - mainStatisticsData: { - transactionGroups, - maxTransactionGroupsExceeded, - transactionOverflowCount, - transactionGroupsTotalItems, - hasActiveAlerts, - }, - } = data; - - const { - data: transactionGroupDetailedStatistics, - status: transactionGroupDetailedStatisticsStatus, - } = useFetcher( - (callApmApi) => { - if ( - transactionGroupsTotalItems && - start && - end && - transactionType && - latencyAggregationType && - preferred - ) { - return callApmApi( - 'GET /internal/apm/services/{serviceName}/transactions/groups/detailed_statistics', - { - params: { - path: { serviceName }, - query: { - environment, - kuery, - start, - end, - bucketSizeInSeconds: preferred.bucketSizeInSeconds, - transactionType, - documentType: preferred.source.documentType, - rollupInterval: preferred.source.rollupInterval, - useDurationSummary: !!shouldUseDurationSummary, - latencyAggregationType: - latencyAggregationType as LatencyAggregationType, - transactionNames: JSON.stringify( - transactionGroups.map(({ name }) => name).sort() - ), - offset: - comparisonEnabled && isTimeComparison(offset) - ? offset - : undefined, - }, - }, - } - ); - } - }, - // only fetches detailed statistics when requestId is invalidated by main statistics api call - // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], - { preservePreviousData: false } - ); - - const columns = getColumns({ - serviceName, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, - transactionGroupDetailedStatisticsLoading: isPending( - transactionGroupDetailedStatisticsStatus - ), - transactionGroupDetailedStatistics, + shouldShowSparkPlots, + offset, + transactionOverflowCount: mainStatistics.transactionOverflowCount, + showAlertsColumn: mainStatistics.hasActiveAlerts, + link, + query, + }); + }, [ comparisonEnabled, - shouldShowSparkPlots, - offset, - transactionOverflowCount, - showAlertsColumn: hasActiveAlerts, + detailedStatistics, + detailedStatisticsStatus, + latencyAggregationType, link, + mainStatistics.hasActiveAlerts, + mainStatistics.transactionOverflowCount, + offset, query, - }); - - const pagination = useMemo( - () => ({ - pageIndex: index, - pageSize: size, - totalItemCount: transactionGroupsTotalItems, - showPerPageOptions, - }), - [index, size, transactionGroupsTotalItems, showPerPageOptions] - ); + serviceName, + shouldShowSparkPlots, + ]); - const sorting = useMemo( - () => ({ sort: { field, direction } }), - [field, direction] - ); + const tableSearchBar: TableSearchBar = + useMemo(() => { + return { + fieldsToSearch: ['name'], + maxCountExceeded: mainStatistics.maxCountExceeded, + onChangeSearchQuery: setSearchQueryDebounced, + placeholder: i18n.translate( + 'xpack.apm.transactionsTable.tableSearch.placeholder', + { defaultMessage: 'Search transactions by name' } + ), + }; + }, [mainStatistics.maxCountExceeded, setSearchQueryDebounced]); return ( )} - {showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && ( - - -

- -

-
-
- )} + {showMaxTransactionGroupsExceededWarning && + mainStatistics.maxCountExceeded && ( + + +

+ +

+
+
+ )} - - + - { - setTableOptions({ - page: { - index: newTableOptions.page?.index ?? 0, - size: - newTableOptions.page?.size ?? numberOfTransactionsPerPage, - }, - sort: newTableOptions.sort - ? { - field: newTableOptions.sort.field as SortField, - direction: newTableOptions.sort.direction, - } - : DEFAULT_SORT, - }); - if (saveTableOptionsToUrl) { - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - page: newTableOptions.page?.index, - pageSize: newTableOptions.page?.size, - sortField: newTableOptions.sort?.field, - sortDirection: newTableOptions.sort?.direction, - }), - }); - } - }} - /> - - + items={mainStatistics.transactionGroups} + columns={columns} + initialSortField="impact" + initialSortDirection="desc" + initialPageSize={numberOfTransactionsPerPage} + isLoading={mainStatisticsStatus === FETCH_STATUS.LOADING} + tableSearchBar={tableSearchBar} + showPerPageOptions={showPerPageOptions} + onChangeRenderedItems={setRenderedItems} + saveTableOptionsToUrl={saveTableOptionsToUrl} + /> +
); } + +function useTableData({ + comparisonEnabled, + currentPageItems, + end, + environment, + kuery, + latencyAggregationType, + offset, + searchQuery, + serviceName, + start, + transactionType, +}: { + comparisonEnabled: boolean | undefined; + currentPageItems: ApiResponse['transactionGroups']; + end: string; + environment: string; + kuery: string; + latencyAggregationType: LatencyAggregationType | undefined; + offset: string | undefined; + searchQuery: string; + serviceName: string; + start: string; + transactionType: string | undefined; +}) { + const preferredDataSource = usePreferredDataSourceAndBucketSize({ + start, + end, + kuery, + numBuckets: 20, + type: ApmDocumentType.TransactionMetric, + }); + + const shouldUseDurationSummary = + latencyAggregationType === 'avg' && + preferredDataSource?.source?.hasDurationSummaryField; + + const { data: mainStatistics = INITIAL_STATE, status: mainStatisticsStatus } = + useFetcher( + (callApmApi) => { + if ( + !latencyAggregationType || + !transactionType || + !preferredDataSource + ) { + return Promise.resolve(undefined); + } + return callApmApi( + 'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + transactionType, + useDurationSummary: !!shouldUseDurationSummary, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, + documentType: preferredDataSource.source.documentType, + rollupInterval: preferredDataSource.source.rollupInterval, + searchQuery, + }, + }, + } + ).then((mainStatisticsData) => { + return { requestId: uuidv4(), ...mainStatisticsData }; + }); + }, + [ + searchQuery, + end, + environment, + kuery, + latencyAggregationType, + preferredDataSource, + serviceName, + shouldUseDurationSummary, + start, + transactionType, + ] + ); + + const { data: detailedStatistics, status: detailedStatisticsStatus } = + useFetcher( + (callApmApi) => { + const transactionNames = compact( + currentPageItems.map(({ name }) => name) + ); + if ( + start && + end && + transactionType && + latencyAggregationType && + preferredDataSource && + transactionNames.length > 0 + ) { + return callApmApi( + 'GET /internal/apm/services/{serviceName}/transactions/groups/detailed_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + bucketSizeInSeconds: preferredDataSource.bucketSizeInSeconds, + transactionType, + documentType: preferredDataSource.source.documentType, + rollupInterval: preferredDataSource.source.rollupInterval, + useDurationSummary: !!shouldUseDurationSummary, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, + transactionNames: JSON.stringify(transactionNames.sort()), + offset: + comparisonEnabled && isTimeComparison(offset) + ? offset + : undefined, + }, + }, + } + ); + } + }, + // only fetches detailed statistics when `currentPageItems` is updated. + // eslint-disable-next-line react-hooks/exhaustive-deps + [mainStatistics.requestId, currentPageItems, offset, comparisonEnabled], + { preservePreviousData: false } + ); + + return { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + }; +} diff --git a/x-pack/plugins/apm/public/hooks/use_debounce.test.tsx b/x-pack/plugins/apm/public/hooks/use_debounce.test.tsx new file mode 100644 index 00000000000000..6701024eea9e95 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_debounce.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useStateDebounced } from './use_debounce'; // Replace 'your-module' with the actual module path + +describe('useStateDebounced', () => { + jest.useFakeTimers(); + beforeAll(() => { + // Mocks console.error so it won't polute tests output when testing the api throwing error + jest.spyOn(console, 'error').mockImplementation(() => null); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('returns the initial value and a debounced setter function', () => { + const { result } = renderHook(() => useStateDebounced('initialValue', 300)); + + const [debouncedValue, setValueDebounced] = result.current; + + expect(debouncedValue).toBe('initialValue'); + expect(typeof setValueDebounced).toBe('function'); + }); + + it('updates debounced value after a delay when setter function is called', () => { + const { result } = renderHook(() => useStateDebounced('initialValue')); + + act(() => { + result.current[1]('updatedValue'); + }); + expect(result.current[0]).toBe('initialValue'); + jest.advanceTimersByTime(300); + expect(result.current[0]).toBe('updatedValue'); + }); + + it('cancels previous debounced updates when new ones occur', () => { + const { result } = renderHook(() => useStateDebounced('initialValue', 400)); + + act(() => { + result.current[1]('updatedValue'); + }); + jest.advanceTimersByTime(150); + expect(result.current[0]).toBe('initialValue'); + act(() => { + result.current[1]('newUpdatedValue'); + }); + jest.advanceTimersByTime(400); + expect(result.current[0]).toBe('newUpdatedValue'); + }); +}); diff --git a/x-pack/plugins/apm/public/hooks/use_debounce.tsx b/x-pack/plugins/apm/public/hooks/use_debounce.tsx new file mode 100644 index 00000000000000..1aa2276e901b23 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_debounce.tsx @@ -0,0 +1,24 @@ +/* + * 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 { debounce } from 'lodash'; +import { useCallback, useState } from 'react'; + +export function useStateDebounced( + initialValue: T, + debounceDelay: number = 300 +) { + const [debouncedValue, setValue] = useState(initialValue); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const setValueDebounced = useCallback(debounce(setValue, debounceDelay), [ + setValue, + debounceDelay, + ]); + + return [debouncedValue, setValueDebounced] as const; +} diff --git a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx index bc8a2056cb41c8..66e4fa63ce6208 100644 --- a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx @@ -65,5 +65,8 @@ export function useErrorGroupDistributionFetcher({ ] ); - return { errorDistributionData: data, status }; + return { + errorDistributionData: data, + errorDistributionStatus: status, + }; } diff --git a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx index df4da04651228a..cfa65e001def76 100644 --- a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx @@ -30,6 +30,12 @@ export const isPending = (fetchStatus: FETCH_STATUS) => fetchStatus === FETCH_STATUS.LOADING || fetchStatus === FETCH_STATUS.NOT_INITIATED; +export const isFailure = (fetchStatus: FETCH_STATUS) => + fetchStatus === FETCH_STATUS.FAILURE; + +export const isSuccess = (fetchStatus: FETCH_STATUS) => + fetchStatus === FETCH_STATUS.SUCCESS; + export interface FetcherResult { data?: Data; status: FETCH_STATUS; diff --git a/x-pack/plugins/apm/public/hooks/use_preferred_data_source_and_bucket_size.ts b/x-pack/plugins/apm/public/hooks/use_preferred_data_source_and_bucket_size.ts index be99a537de011f..348d84883ba874 100644 --- a/x-pack/plugins/apm/public/hooks/use_preferred_data_source_and_bucket_size.ts +++ b/x-pack/plugins/apm/public/hooks/use_preferred_data_source_and_bucket_size.ts @@ -18,6 +18,7 @@ import { useTimeRangeMetadata } from '../context/time_range_metadata/use_time_ra * @param {number} numBuckets - The number of buckets. Should be 20 for SparkPlots or 100 for Other charts. */ + export function usePreferredDataSourceAndBucketSize< TDocumentType extends | ApmDocumentType.ServiceTransactionMetric diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_search.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_search.ts index 0017e948638281..4192d35d796f3c 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_search.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_search.ts @@ -8,6 +8,9 @@ import type { ESSearchRequest, ESSearchResponse } from '@kbn/es-types'; import { MlClient } from '../helpers/get_ml_client'; +export const ML_SERVICE_NAME_FIELD = 'partition_field_value'; +export const ML_TRANSACTION_TYPE_FIELD = 'by_field_value'; + interface SharedFields { job_id: string; bucket_span: number; @@ -44,9 +47,9 @@ type AnomalyDocument = MlRecord | MlModelPlot; export async function anomalySearch( mlAnomalySearch: Required['mlSystem']['mlAnomalySearch'], - params: TParams + params: TParams, + jobsIds = [] // pass an empty array of job ids to anomaly search so any validation is skipped ): Promise> { - const response = await mlAnomalySearch(params, []); - + const response = await mlAnomalySearch(params, jobsIds); return response as unknown as ESSearchResponse; } diff --git a/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts b/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts index 182fe0a1cdd8ae..6c8d878502b7ac 100644 --- a/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts +++ b/x-pack/plugins/apm/server/routes/errors/get_error_groups/get_error_group_main_statistics.ts @@ -10,6 +10,7 @@ import { kqlQuery, rangeQuery, termQuery, + wildcardQuery, } from '@kbn/observability-plugin/server'; import { ERROR_CULPRIT, @@ -17,6 +18,7 @@ import { ERROR_EXC_MESSAGE, ERROR_EXC_TYPE, ERROR_GROUP_ID, + ERROR_GROUP_NAME, ERROR_LOG_MESSAGE, SERVICE_NAME, TRANSACTION_NAME, @@ -28,15 +30,18 @@ import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm import { ApmDocumentType } from '../../../../common/document_type'; import { RollupInterval } from '../../../../common/rollup'; -export type ErrorGroupMainStatisticsResponse = Array<{ - groupId: string; - name: string; - lastSeen: number; - occurrences: number; - culprit: string | undefined; - handled: boolean | undefined; - type: string | undefined; -}>; +export interface ErrorGroupMainStatisticsResponse { + errorGroups: Array<{ + groupId: string; + name: string; + lastSeen: number; + occurrences: number; + culprit: string | undefined; + handled: boolean | undefined; + type: string | undefined; + }>; + maxCountExceeded: boolean; +} export async function getErrorGroupMainStatistics({ kuery, @@ -50,6 +55,7 @@ export async function getErrorGroupMainStatistics({ maxNumberOfErrorGroups = 500, transactionName, transactionType, + searchQuery, }: { kuery: string; serviceName: string; @@ -62,6 +68,7 @@ export async function getErrorGroupMainStatistics({ maxNumberOfErrorGroups?: number; transactionName?: string; transactionType?: string; + searchQuery?: string; }): Promise { // sort buckets by last occurrence of error const sortByLatestOccurrence = sortField === 'lastSeen'; @@ -72,6 +79,19 @@ export async function getErrorGroupMainStatistics({ ? { [maxTimestampAggKey]: sortDirection } : { _count: sortDirection }; + const shouldQuery = searchQuery + ? { + should: [ + ERROR_LOG_MESSAGE, + ERROR_EXC_MESSAGE, + ERROR_GROUP_NAME, + ERROR_EXC_TYPE, + ERROR_CULPRIT, + ].flatMap((field) => wildcardQuery(field, searchQuery)), + minimum_should_match: 1, + } + : {}; + const response = await apmEventClient.search( 'get_error_group_main_statistics', { @@ -96,6 +116,7 @@ export async function getErrorGroupMainStatistics({ ...environmentQuery(environment), ...kqlQuery(kuery), ], + ...shouldQuery, }, }, aggs: { @@ -133,7 +154,10 @@ export async function getErrorGroupMainStatistics({ } ); - return ( + const maxCountExceeded = + (response.aggregations?.error_groups.sum_other_doc_count ?? 0) > 0; + + const errorGroups = response.aggregations?.error_groups.buckets.map((bucket) => { return { groupId: bucket.key as string, @@ -147,6 +171,10 @@ export async function getErrorGroupMainStatistics({ bucket.sample.hits.hits[0]._source.error.exception?.[0].handled, type: bucket.sample.hits.hits[0]._source.error.exception?.[0].type, }; - }) ?? [] - ); + }) ?? []; + + return { + errorGroups, + maxCountExceeded, + }; } diff --git a/x-pack/plugins/apm/server/routes/errors/route.ts b/x-pack/plugins/apm/server/routes/errors/route.ts index a3e11887f1caf2..a76ab1df8a9b1a 100644 --- a/x-pack/plugins/apm/server/routes/errors/route.ts +++ b/x-pack/plugins/apm/server/routes/errors/route.ts @@ -47,6 +47,7 @@ const errorsMainStatisticsRoute = createApmServerRoute({ t.partial({ sortField: t.string, sortDirection: t.union([t.literal('asc'), t.literal('desc')]), + searchQuery: t.string, }), environmentRt, kueryRt, @@ -54,16 +55,21 @@ const errorsMainStatisticsRoute = createApmServerRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ( - resources - ): Promise<{ errorGroups: ErrorGroupMainStatisticsResponse }> => { + handler: async (resources): Promise => { const { params } = resources; const apmEventClient = await getApmEventClient(resources); const { serviceName } = params.path; - const { environment, kuery, sortField, sortDirection, start, end } = - params.query; + const { + environment, + kuery, + sortField, + sortDirection, + start, + end, + searchQuery, + } = params.query; - const errorGroups = await getErrorGroupMainStatistics({ + return await getErrorGroupMainStatistics({ environment, kuery, serviceName, @@ -72,9 +78,8 @@ const errorsMainStatisticsRoute = createApmServerRoute({ apmEventClient, start, end, + searchQuery, }); - - return { errorGroups }; }, }); @@ -97,11 +102,7 @@ const errorsMainStatisticsByTransactionNameRoute = createApmServerRoute({ ]), }), options: { tags: ['access:apm'] }, - handler: async ( - resources - ): Promise<{ - errorGroups: ErrorGroupMainStatisticsResponse; - }> => { + handler: async (resources): Promise => { const { params } = resources; const apmEventClient = await getApmEventClient(resources); const { serviceName } = params.path; @@ -115,7 +116,7 @@ const errorsMainStatisticsByTransactionNameRoute = createApmServerRoute({ maxNumberOfErrorGroups, } = params.query; - const errorGroups = await getErrorGroupMainStatistics({ + return await getErrorGroupMainStatistics({ environment, kuery, serviceName, @@ -126,8 +127,6 @@ const errorsMainStatisticsByTransactionNameRoute = createApmServerRoute({ transactionName, transactionType, }); - - return { errorGroups }; }, }); diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts index d46c43d2581660..eb0fb5ed62e052 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts @@ -8,9 +8,8 @@ import Boom from '@hapi/boom'; import { sortBy, uniqBy } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { ESSearchResponse } from '@kbn/es-types'; import type { MlAnomalyDetectors } from '@kbn/ml-plugin/server'; -import { rangeQuery } from '@kbn/observability-plugin/server'; +import { rangeQuery, wildcardQuery } from '@kbn/observability-plugin/server'; import { getSeverity, ML_ERRORS } from '../../../common/anomaly_detection'; import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; import { getServiceHealthStatus } from '../../../common/service_health_status'; @@ -20,6 +19,11 @@ import { getMlJobsWithAPMGroup } from '../../lib/anomaly_detection/get_ml_jobs_w import { MlClient } from '../../lib/helpers/get_ml_client'; import { apmMlAnomalyQuery } from '../../lib/anomaly_detection/apm_ml_anomaly_query'; import { AnomalyDetectorType } from '../../../common/anomaly_detection/apm_ml_detectors'; +import { + anomalySearch, + ML_SERVICE_NAME_FIELD, + ML_TRANSACTION_TYPE_FIELD, +} from '../../lib/anomaly_detection/anomaly_search'; export const DEFAULT_ANOMALIES: ServiceAnomaliesResponse = { mlJobIds: [], @@ -34,11 +38,13 @@ export async function getServiceAnomalies({ environment, start, end, + searchQuery, }: { mlClient?: MlClient; environment: string; start: number; end: number; + searchQuery?: string; }) { return withApmSpan('get_service_anomalies', async () => { if (!mlClient) { @@ -65,6 +71,7 @@ export async function getServiceAnomalies({ by_field_value: defaultTransactionTypes, }, }, + ...wildcardQuery(ML_SERVICE_NAME_FIELD, searchQuery), ] as estypes.QueryDslQueryContainer[], }, }, @@ -73,7 +80,7 @@ export async function getServiceAnomalies({ composite: { size: 5000, sources: [ - { serviceName: { terms: { field: 'partition_field_value' } } }, + { serviceName: { terms: { field: ML_SERVICE_NAME_FIELD } } }, { jobId: { terms: { field: 'job_id' } } }, ] as Array< Record @@ -84,7 +91,7 @@ export async function getServiceAnomalies({ top_metrics: { metrics: [ { field: 'actual' }, - { field: 'by_field_value' }, + { field: ML_TRANSACTION_TYPE_FIELD }, { field: 'result_type' }, { field: 'record_score' }, ], @@ -100,20 +107,16 @@ export async function getServiceAnomalies({ }; const [anomalyResponse, jobIds] = await Promise.all([ - // pass an empty array of job ids to anomaly search - // so any validation is skipped withApmSpan('ml_anomaly_search', () => - mlClient.mlSystem.mlAnomalySearch(params, []) + anomalySearch(mlClient.mlSystem.mlAnomalySearch, params) ), getMLJobIds(mlClient.anomalyDetectors, environment), ]); - const typedAnomalyResponse: ESSearchResponse = - anomalyResponse as any; const relevantBuckets = uniqBy( sortBy( // make sure we only return data for jobs that are available in this space - typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => + anomalyResponse.aggregations?.services.buckets.filter((bucket) => jobIds.includes(bucket.key.jobId as string) ) ?? [], // sort by job ID in case there are multiple jobs for one service to diff --git a/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups.ts b/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups.ts index 84ec4da4829e8b..a44a557e2b4de7 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server'; +import { + kqlQuery, + rangeQuery, + wildcardQuery, +} from '@kbn/observability-plugin/server'; import { ApmTransactionDocumentType } from '../../../common/document_type'; import { SERVICE_NAME, @@ -43,7 +47,7 @@ export interface TransactionGroups { export interface ServiceTransactionGroupsResponse { transactionGroups: TransactionGroups[]; - maxTransactionGroupsExceeded: boolean; + maxCountExceeded: boolean; transactionOverflowCount: number; hasActiveAlerts: boolean; } @@ -60,6 +64,7 @@ export async function getServiceTransactionGroups({ documentType, rollupInterval, useDurationSummary, + searchQuery, }: { environment: string; kuery: string; @@ -72,6 +77,7 @@ export async function getServiceTransactionGroups({ documentType: ApmTransactionDocumentType; rollupInterval: RollupInterval; useDurationSummary: boolean; + searchQuery?: string; }): Promise { const field = getDurationFieldForTransactions( documentType, @@ -107,6 +113,7 @@ export async function getServiceTransactionGroups({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), + ...wildcardQuery(TRANSACTION_NAME, searchQuery), ], }, }, @@ -169,7 +176,7 @@ export async function getServiceTransactionGroups({ ...transactionGroup, transactionType, })), - maxTransactionGroupsExceeded: + maxCountExceeded: (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) > 0, transactionOverflowCount: response.aggregations?.transaction_overflow_count.value ?? 0, diff --git a/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups_alerts.ts b/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups_alerts.ts index 9ad556df126ba6..178c5e186c3161 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups_alerts.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_transaction_groups_alerts.ts @@ -9,6 +9,7 @@ import { kqlQuery, termQuery, rangeQuery, + wildcardQuery, } from '@kbn/observability-plugin/server'; import { ALERT_RULE_PRODUCER, @@ -47,6 +48,7 @@ export async function getServiceTransactionGroupsAlerts({ start, end, environment, + searchQuery, }: { apmAlertsClient: ApmAlertsClient; kuery?: string; @@ -56,6 +58,7 @@ export async function getServiceTransactionGroupsAlerts({ start: number; end: number; environment?: string; + searchQuery?: string; }): Promise { const ALERT_RULE_PARAMETERS_AGGREGATION_TYPE = `${ALERT_RULE_PARAMETERS}.aggregationType`; @@ -72,6 +75,7 @@ export async function getServiceTransactionGroupsAlerts({ ...termQuery(SERVICE_NAME, serviceName), ...termQuery(TRANSACTION_TYPE, transactionType), ...environmentQuery(environment), + ...wildcardQuery(TRANSACTION_NAME, searchQuery), ], must: [ { diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts index 0b8a252da9a763..f2dbeb8410bbe5 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_health_statuses.ts @@ -18,6 +18,7 @@ interface AggregationParams { mlClient?: MlClient; start: number; end: number; + searchQuery: string | undefined; } export type ServiceHealthStatusesResponse = Array<{ @@ -30,6 +31,7 @@ export async function getHealthStatuses({ mlClient, start, end, + searchQuery, }: AggregationParams): Promise { if (!mlClient) { return []; @@ -40,6 +42,7 @@ export async function getHealthStatuses({ environment, start, end, + searchQuery, }); return anomalies.serviceAnomalies.map((anomalyStats) => { diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_service_alerts.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_service_alerts.ts index e47d9b61124cce..0fc76f803c440b 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_service_alerts.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_service_alerts.ts @@ -9,6 +9,7 @@ import { kqlQuery, termQuery, rangeQuery, + wildcardQuery, } from '@kbn/observability-plugin/server'; import { ALERT_RULE_PRODUCER, @@ -37,6 +38,7 @@ export async function getServicesAlerts({ start, end, environment, + searchQuery, }: { apmAlertsClient: ApmAlertsClient; kuery?: string; @@ -46,6 +48,7 @@ export async function getServicesAlerts({ start: number; end: number; environment?: string; + searchQuery?: string; }): Promise { const params = { size: 0, @@ -59,6 +62,7 @@ export async function getServicesAlerts({ ...kqlQuery(kuery), ...serviceGroupWithOverflowQuery(serviceGroup), ...termQuery(SERVICE_NAME, serviceName), + ...wildcardQuery(SERVICE_NAME, searchQuery), ...environmentQuery(environment), ], }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts index 7fbeb1cc9d24cb..a79e17c32c04b2 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_service_transaction_stats.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server'; +import { + kqlQuery, + rangeQuery, + wildcardQuery, +} from '@kbn/observability-plugin/server'; import { ApmDocumentType } from '../../../../common/document_type'; import { AGENT_NAME, @@ -45,6 +49,7 @@ interface AggregationParams { | ApmDocumentType.TransactionEvent; rollupInterval: RollupInterval; useDurationSummary: boolean; + searchQuery: string | undefined; } export interface ServiceTransactionStatsResponse { @@ -72,6 +77,7 @@ export async function getServiceTransactionStats({ documentType, rollupInterval, useDurationSummary, + searchQuery, }: AggregationParams): Promise { const outcomes = getOutcomeAggregation(documentType); @@ -108,6 +114,7 @@ export async function getServiceTransactionStats({ ...environmentQuery(environment), ...kqlQuery(kuery), ...serviceGroupWithOverflowQuery(serviceGroup), + ...wildcardQuery(SERVICE_NAME, searchQuery), ], }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts index c36754e4cf50f7..e57e8e9d20235f 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts @@ -24,7 +24,7 @@ export const MAX_NUMBER_OF_SERVICES = 1_000; export interface ServicesItemsResponse { items: MergedServiceStat[]; - maxServiceCountExceeded: boolean; + maxCountExceeded: boolean; serviceOverflowCount: number; } @@ -42,6 +42,7 @@ export async function getServicesItems({ documentType, rollupInterval, useDurationSummary, + searchQuery, }: { environment: string; kuery: string; @@ -56,6 +57,7 @@ export async function getServicesItems({ documentType: ApmServiceTransactionDocumentType; rollupInterval: RollupInterval; useDurationSummary: boolean; + searchQuery?: string; }): Promise { return withApmSpan('get_services_items', async () => { const commonParams = { @@ -69,11 +71,12 @@ export async function getServicesItems({ documentType, rollupInterval, useDurationSummary, + searchQuery, }; const [ { serviceStats, serviceOverflowCount }, - { services: servicesWithoutTransactions, maxServiceCountExceeded }, + { services: servicesWithoutTransactions, maxCountExceeded }, healthStatuses, alertCounts, ] = await Promise.all([ @@ -103,7 +106,7 @@ export async function getServicesItems({ healthStatuses, alertCounts, }) ?? [], - maxServiceCountExceeded, + maxCountExceeded, serviceOverflowCount, }; }); diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_without_transactions.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_without_transactions.ts index 0eedb8494f21bd..0ddae57603fe12 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_without_transactions.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_without_transactions.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server'; +import { + kqlQuery, + rangeQuery, + wildcardQuery, +} from '@kbn/observability-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { @@ -27,7 +31,7 @@ export interface ServicesWithoutTransactionsResponse { environments: string[]; agentName: AgentName; }>; - maxServiceCountExceeded: boolean; + maxCountExceeded: boolean; } export async function getServicesWithoutTransactions({ @@ -41,6 +45,7 @@ export async function getServicesWithoutTransactions({ randomSampler, documentType, rollupInterval, + searchQuery, }: { apmEventClient: APMEventClient; environment: string; @@ -52,6 +57,7 @@ export async function getServicesWithoutTransactions({ randomSampler: RandomSampler; documentType: ApmDocumentType; rollupInterval: RollupInterval; + searchQuery: string | undefined; }): Promise { const isServiceTransactionMetric = documentType === ApmDocumentType.ServiceTransactionMetric; @@ -83,6 +89,7 @@ export async function getServicesWithoutTransactions({ ...environmentQuery(environment), ...kqlQuery(kuery), ...serviceGroupWithOverflowQuery(serviceGroup), + ...wildcardQuery(SERVICE_NAME, searchQuery), ], }, }, @@ -116,6 +123,9 @@ export async function getServicesWithoutTransactions({ } ); + const maxCountExceeded = + (response.aggregations?.sample.services.sum_other_doc_count ?? 0) > 0; + return { services: response.aggregations?.sample.services.buckets.map((bucket) => { @@ -127,7 +137,6 @@ export async function getServicesWithoutTransactions({ agentName: bucket.latest.top[0].metrics[AGENT_NAME] as AgentName, }; }) ?? [], - maxServiceCountExceeded: - (response.aggregations?.sample.services.sum_other_doc_count ?? 0) > 0, + maxCountExceeded, }; } diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index 0a51a3e88379fa..e997c6d56c1b80 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -107,7 +107,10 @@ const servicesRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/services', params: t.type({ query: t.intersection([ - t.partial({ serviceGroup: t.string }), + t.partial({ + searchQuery: t.string, + serviceGroup: t.string, + }), t.intersection([ probabilityRt, t.intersection([ @@ -133,6 +136,7 @@ const servicesRoute = createApmServerRoute({ } = resources; const { + searchQuery, environment, kuery, start, @@ -175,6 +179,7 @@ const servicesRoute = createApmServerRoute({ documentType, rollupInterval, useDurationSummary, + searchQuery, }); }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/helper.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/helper.ts index 62082b2236dec7..55cdaebf267914 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/helper.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/helper.ts @@ -40,8 +40,8 @@ export function toESFormat(customLink: CustomLink): CustomLinkES { return { label, url, ...ESFilters }; } -export function splitFilterValueByComma(filterValue: Filter['value']) { - return filterValue +export function splitFilterValueByComma(searchQuery: Filter['value']) { + return searchQuery .split(',') .map((v) => v.trim()) .filter((v) => v); diff --git a/x-pack/plugins/apm/server/routes/transactions/route.ts b/x-pack/plugins/apm/server/routes/transactions/route.ts index 888cb82820cc1f..0796062227b086 100644 --- a/x-pack/plugins/apm/server/routes/transactions/route.ts +++ b/x-pack/plugins/apm/server/routes/transactions/route.ts @@ -73,10 +73,11 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ params: t.type({ path: t.type({ serviceName: t.string }), query: t.intersection([ + t.partial({ searchQuery: t.string }), environmentRt, - kueryRt, rangeRt, t.type({ + kuery: t.string, useDurationSummary: toBooleanRt, transactionType: t.string, latencyAggregationType: latencyAggregationTypeRt, @@ -106,6 +107,7 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ documentType, rollupInterval, useDurationSummary, + searchQuery, }, } = params; @@ -117,6 +119,7 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ latencyAggregationType, start, end, + searchQuery, }; const [serviceTransactionGroups, serviceTransactionGroupsAlerts] = @@ -134,11 +137,8 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ }), ]); - const { - transactionGroups, - maxTransactionGroupsExceeded, - transactionOverflowCount, - } = serviceTransactionGroups; + const { transactionGroups, maxCountExceeded, transactionOverflowCount } = + serviceTransactionGroups; const transactionGroupsWithAlerts = joinByKey( [...transactionGroups, ...serviceTransactionGroupsAlerts], @@ -147,7 +147,7 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ return { transactionGroups: transactionGroupsWithAlerts, - maxTransactionGroupsExceeded, + maxCountExceeded, transactionOverflowCount, hasActiveAlerts: !!serviceTransactionGroupsAlerts.length, }; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 3d9e87ccadf004..682d05e2f249fe 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -19,7 +19,7 @@ import { WrappedElasticsearchClientError, } from '../common/utils/unwrap_es_response'; -export { rangeQuery, kqlQuery, termQuery, termsQuery } from './utils/queries'; +export { rangeQuery, kqlQuery, termQuery, termsQuery, wildcardQuery } from './utils/queries'; export { getParsedFilterQuery } from './utils/get_parsed_filtered_query'; export { getInspectResponse } from '../common/utils/get_inspect_response'; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index f58b6dd1f9683a..0ccd75c9ae3a3e 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -298,8 +298,8 @@ export const uiSettings: Record = { }, }), schema: schema.boolean(), - value: false, - requiresPageReload: false, + value: true, + requiresPageReload: true, type: 'boolean', }, [apmAWSLambdaPriceFactor]: { diff --git a/x-pack/plugins/observability/server/utils/queries.test.ts b/x-pack/plugins/observability/server/utils/queries.test.ts new file mode 100644 index 00000000000000..0e34f840503866 --- /dev/null +++ b/x-pack/plugins/observability/server/utils/queries.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { wildcardQuery } from './queries'; // Replace 'your-module' with the actual module path + +describe('wildcardQuery', () => { + it('generates wildcard query with leading wildcard by default', () => { + const result = wildcardQuery('fieldName', 'value'); + expect(result).toEqual([ + { + wildcard: { + fieldName: { + value: '*value*', + case_insensitive: true, + }, + }, + }, + ]); + }); + + it('generates wildcard query without leading wildcard if specified in options', () => { + const result = wildcardQuery('fieldName', 'value', { leadingWildcard: false }); + expect(result).toEqual([ + { + wildcard: { + fieldName: { + value: 'value*', + case_insensitive: true, + }, + }, + }, + ]); + }); + + it('returns an empty array if value is undefined', () => { + const result = wildcardQuery('fieldName', undefined); + expect(result).toEqual([]); + }); +}); diff --git a/x-pack/plugins/observability/server/utils/queries.ts b/x-pack/plugins/observability/server/utils/queries.ts index 4715e0f398e4ad..bdacad577838c8 100644 --- a/x-pack/plugins/observability/server/utils/queries.ts +++ b/x-pack/plugins/observability/server/utils/queries.ts @@ -31,6 +31,27 @@ export function termQuery( return [{ term: { [field]: value } }]; } +export function wildcardQuery( + field: T, + value: string | undefined, + opts = { leadingWildcard: true } +): QueryDslQueryContainer[] { + if (isUndefinedOrNull(value)) { + return []; + } + + return [ + { + wildcard: { + [field]: { + value: opts.leadingWildcard ? `*${value}*` : `${value}*`, + case_insensitive: true, + }, + }, + }, + ]; +} + export function termsQuery( field: string, ...values: Array diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5eaceb182e5cdd..e4dab6524c3f4b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -9066,8 +9066,6 @@ "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "Version du service", "xpack.apm.serviceLink.otherBucketName": "Services restants", "xpack.apm.serviceLink.tooltip": "Le nombre de services instrumentés a atteint la capacité actuelle du serveur APM", - "xpack.apm.serviceList.ui.limit.warning.calloutDescription": "Le nombre maximal de services pouvant être affichés dans Kibana a été atteint. Essayez d'affiner les résultats à l'aide de la barre de requête, ou envisagez d'utiliser les groupes de services.", - "xpack.apm.serviceList.ui.limit.warning.calloutTitle": "Le nombre de services dépasse le nombre maximal autorisé d'affichages (1 000)", "xpack.apm.serviceMap.anomalyDetectionPopoverDisabled": "Affichez les indicateurs d'intégrité du service en activant la détection des anomalies dans les paramètres APM.", "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "Afficher les anomalies", "xpack.apm.serviceMap.anomalyDetectionPopoverNoData": "Nous n'avons pas trouvé de score d'anomalie dans la plage temporelle sélectionnée. Consultez les détails dans l'explorateur d'anomalies.", @@ -9131,9 +9129,6 @@ "xpack.apm.serviceOverview.embeddedMap.sessionCountry.metric.label": "Sessions par pays", "xpack.apm.serviceOverview.embeddedMap.sessionRegion.metric.label": "Sessions par région", "xpack.apm.serviceOverview.embeddedMap.title": "Régions géographiques", - "xpack.apm.serviceOverview.errorsTable.errorMessage": "Impossible de récupérer", - "xpack.apm.serviceOverview.errorsTable.loading": "Chargement...", - "xpack.apm.serviceOverview.errorsTable.noResults": "Aucune erreur trouvée", "xpack.apm.serviceOverview.errorsTableLinkText": "Afficher les erreurs", "xpack.apm.serviceOverview.errorsTableTitle": "Erreurs", "xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "Affichez les logs et les indicateurs de ce conteneur pour plus de détails.", @@ -9567,7 +9562,6 @@ "xpack.apm.transactions.sessionsChartTitle": "Sessions", "xpack.apm.transactionsCallout.cardinalityWarning.title": "Le nombre de groupes de transactions dépasse le nombre maximal (1 000) autorisé d'affichages.", "xpack.apm.transactionsCallout.transactionGroupLimit.exceeded": "Le nombre maximal de groupes de transactions affichés dans Kibana a été atteint. Essayez d'affiner les résultats à l'aide de la barre de requête.", - "xpack.apm.transactionsTable.errorMessage": "Impossible de récupérer", "xpack.apm.transactionsTable.linkText": "Afficher les transactions", "xpack.apm.transactionsTable.loading": "Chargement...", "xpack.apm.transactionsTable.noResults": "Aucune transaction trouvée", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c0d8dd949344c5..0bfb0520186b3e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9080,8 +9080,6 @@ "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "サービスバージョン", "xpack.apm.serviceLink.otherBucketName": "残りのサービス", "xpack.apm.serviceLink.tooltip": "実行されたサービス数がAPMサーバーの現在の能力に達しました。", - "xpack.apm.serviceList.ui.limit.warning.calloutDescription": "Kibanaで表示できるサービスの最大数に達しました。クエリバーを使用して結果を絞り込むか、サービスグループの使用を検討してください。", - "xpack.apm.serviceList.ui.limit.warning.calloutTitle": "サービス数が表示可能な最大数(1,000)を超えました。", "xpack.apm.serviceMap.anomalyDetectionPopoverDisabled": "APM 設定で異常検知を有効にすると、サービス正常性インジケーターが表示されます。", "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "異常を表示", "xpack.apm.serviceMap.anomalyDetectionPopoverNoData": "選択した時間範囲で、異常スコアを検出できませんでした。異常エクスプローラーで詳細を確認してください。", @@ -9145,9 +9143,6 @@ "xpack.apm.serviceOverview.embeddedMap.sessionCountry.metric.label": "国別セッション", "xpack.apm.serviceOverview.embeddedMap.sessionRegion.metric.label": "地域別セッション", "xpack.apm.serviceOverview.embeddedMap.title": "地域", - "xpack.apm.serviceOverview.errorsTable.errorMessage": "取得できませんでした", - "xpack.apm.serviceOverview.errorsTable.loading": "読み込み中...", - "xpack.apm.serviceOverview.errorsTable.noResults": "エラーが見つかりません", "xpack.apm.serviceOverview.errorsTableLinkText": "エラーを表示", "xpack.apm.serviceOverview.errorsTableTitle": "エラー", "xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "このコンテナーのログとインデックスを表示し、さらに詳細を確認できます。", @@ -9581,7 +9576,6 @@ "xpack.apm.transactions.sessionsChartTitle": "セッション", "xpack.apm.transactionsCallout.cardinalityWarning.title": "トランザクショングループ数が表示可能な最大数(1,000)を超えました。", "xpack.apm.transactionsCallout.transactionGroupLimit.exceeded": "Kibanaで表示されるトランザクショングループの最大数に達しました。クエリバーを使用して結果を絞り込んでください。", - "xpack.apm.transactionsTable.errorMessage": "取得できませんでした", "xpack.apm.transactionsTable.linkText": "トランザクションを表示", "xpack.apm.transactionsTable.loading": "読み込み中...", "xpack.apm.transactionsTable.noResults": "トランザクションが見つかりません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a893ee0a19dabb..7e238a0c10c644 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9174,8 +9174,6 @@ "xpack.apm.serviceIcons.serviceDetails.service.versionLabel": "服务版本", "xpack.apm.serviceLink.otherBucketName": "剩余服务", "xpack.apm.serviceLink.tooltip": "检测的服务数已达到 APM 服务器的当前容量", - "xpack.apm.serviceList.ui.limit.warning.calloutDescription": "已达到可在 Kibana 中查看的最大服务数。尝试通过使用查询栏来缩小结果范围,或考虑使用服务组。", - "xpack.apm.serviceList.ui.limit.warning.calloutTitle": "服务数超出了显示的允许最大值 (1,000)", "xpack.apm.serviceMap.anomalyDetectionPopoverDisabled": "通过在 APM 设置中启用异常检测来显示服务运行状况指标。", "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "查看异常", "xpack.apm.serviceMap.anomalyDetectionPopoverNoData": "在选定时间范围内找不到异常分数。请在 Anomaly Explorer 中查看详情。", @@ -9239,9 +9237,6 @@ "xpack.apm.serviceOverview.embeddedMap.sessionCountry.metric.label": "按国家/地区的会话", "xpack.apm.serviceOverview.embeddedMap.sessionRegion.metric.label": "按区域的会话", "xpack.apm.serviceOverview.embeddedMap.title": "地理区域", - "xpack.apm.serviceOverview.errorsTable.errorMessage": "无法提取", - "xpack.apm.serviceOverview.errorsTable.loading": "正在加载……", - "xpack.apm.serviceOverview.errorsTable.noResults": "未找到错误", "xpack.apm.serviceOverview.errorsTableLinkText": "查看错误", "xpack.apm.serviceOverview.errorsTableTitle": "错误", "xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle": "查看此容器的日志和指标以获取进一步详情。", @@ -9675,7 +9670,6 @@ "xpack.apm.transactions.sessionsChartTitle": "会话", "xpack.apm.transactionsCallout.cardinalityWarning.title": "事务组数目超出了显示的允许最大值 (1,000)。", "xpack.apm.transactionsCallout.transactionGroupLimit.exceeded": "已达到在 Kibana 中显示的最大事务组数目。尝试通过使用查询栏来缩小结果范围。", - "xpack.apm.transactionsTable.errorMessage": "无法提取", "xpack.apm.transactionsTable.linkText": "查看事务", "xpack.apm.transactionsTable.loading": "正在加载……", "xpack.apm.transactionsTable.noResults": "找不到任何事务", diff --git a/x-pack/test/apm_api_integration/tests/services/top_services.spec.ts b/x-pack/test/apm_api_integration/tests/services/top_services.spec.ts index b519a28de70e84..b6dc025ecef10b 100644 --- a/x-pack/test/apm_api_integration/tests/services/top_services.spec.ts +++ b/x-pack/test/apm_api_integration/tests/services/top_services.spec.ts @@ -56,7 +56,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(response.status).to.be(200); expect(response.body.items.length).to.be(0); - expect(response.body.maxServiceCountExceeded).to.be(false); + expect(response.body.maxCountExceeded).to.be(false); expect(response.body.serviceOverflowCount).to.be(0); }); } diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_main_statistics.spec.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_main_statistics.spec.ts index 3a7fedc76ef3e5..91682c0edf796e 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_main_statistics.spec.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_main_statistics.spec.ts @@ -68,7 +68,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { const transactionsGroupsPrimaryStatistics = await callApi(); expect(transactionsGroupsPrimaryStatistics.transactionGroups).to.empty(); - expect(transactionsGroupsPrimaryStatistics.maxTransactionGroupsExceeded).to.be(false); + expect(transactionsGroupsPrimaryStatistics.maxCountExceeded).to.be(false); }); } ); @@ -138,7 +138,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { transactionsGroupsPrimaryStatisticsWithDurationSummaryTrue, ].forEach((statistics) => { expect(statistics.transactionGroups.length).to.be(3); - expect(statistics.maxTransactionGroupsExceeded).to.be(false); + expect(statistics.maxCountExceeded).to.be(false); expect(statistics.transactionGroups.map(({ name }) => name)).to.eql( transactions.map(({ name }) => name) );