From 5ff5626bfc9bbdfca81a43af303929df1f846ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 30 Jan 2024 16:07:46 +0100 Subject: [PATCH] [APM] Add table search to services, transactions and errors (#174490) Closes: #127036 This adds the ability to easily search for data in tables. The search will be performed server side if there are more results than initially returned by Elasticsearch. If all results were returned the search is performed client side to provide a more snappy experience. The feature is guarded by a feature flag (disabled by default) and only available for services, transactions and errors table. # Transactions ![quick-filtering](https://github.com/elastic/kibana/assets/209966/20684b88-a103-4000-a012-ee6e35479b44) # Errors ![error3](https://github.com/elastic/kibana/assets/209966/c7f09dd9-24a5-482a-ae72-4c4477f65d3a) **Dependencies:** - https://github.com/elastic/kibana/pull/173973 - https://github.com/elastic/kibana/pull/174746 - https://github.com/elastic/kibana/pull/174750 --------- Co-authored-by: Caue Marcondes Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/lib/apm/apm_fields.ts | 1 + .../src/lib/apm/instance.ts | 3 +- .../src/scenarios/helpers/exception_types.ts | 42 ++ .../src/scenarios/logs_and_metrics.ts | 5 +- .../src/scenarios/many_dependencies.ts | 73 +++ .../src/scenarios/many_errors.ts | 16 +- .../src/scenarios/many_instances.ts | 21 +- .../src/scenarios/many_services.ts | 16 +- .../src/scenarios/many_transactions.ts | 12 +- .../scenarios/service_many_dependencies.ts | 67 --- .../src/scenarios/simple_trace.ts | 5 +- .../service_inventory/service_inventory.cy.ts | 25 + .../index.tsx | 28 +- .../app/error_group_details/index.tsx | 2 +- .../error_group_list.stories.tsx | 150 ++--- .../error_group_list/index.tsx | 249 +++++--- .../use_error_group_list_data.tsx | 149 +++++ .../app/error_group_overview/index.tsx | 160 +---- .../error_group_details/index.tsx | 16 +- .../errors_overview.tsx | 13 +- .../app/service_inventory/index.tsx | 187 +++--- .../service_inventory/service_list/index.tsx | 130 +++-- .../service_list/order_service_items.ts | 2 +- .../service_list/service_list.stories.tsx | 21 +- .../service_list/service_list.test.tsx | 6 +- .../components/app/service_overview/index.tsx | 1 + .../service_overview_errors_table/index.tsx | 233 +------- .../app/settings/custom_link/index.test.tsx | 2 +- .../app/transaction_overview/index.tsx | 2 +- .../__snapshots__/managed_table.test.tsx.snap | 129 ---- .../components/shared/managed_table/index.tsx | 330 ++++++++--- .../managed_table/managed_table.test.tsx | 100 ++-- .../shared/service_icons/index.test.tsx | 8 + .../table_search_bar/table_search_bar.test.ts | 67 +++ .../table_search_bar/table_search_bar.tsx | 49 ++ .../shared/transactions_table/get_columns.tsx | 44 +- .../shared/transactions_table/index.tsx | 551 ++++++++---------- .../apm/public/hooks/use_debounce.test.tsx | 55 ++ .../plugins/apm/public/hooks/use_debounce.tsx | 24 + .../use_error_group_distribution_fetcher.tsx | 5 +- .../plugins/apm/public/hooks/use_fetcher.tsx | 6 + ...e_preferred_data_source_and_bucket_size.ts | 1 + .../lib/anomaly_detection/anomaly_search.ts | 9 +- .../get_error_group_main_statistics.ts | 52 +- .../plugins/apm/server/routes/errors/route.ts | 31 +- .../service_map/get_service_anomalies.ts | 23 +- .../get_service_transaction_groups.ts | 13 +- .../get_service_transaction_groups_alerts.ts | 4 + .../get_services/get_health_statuses.ts | 3 + .../get_services/get_service_alerts.ts | 4 + .../get_service_transaction_stats.ts | 9 +- .../get_services/get_services_items.ts | 9 +- .../get_services_without_transactions.ts | 17 +- .../apm/server/routes/services/route.ts | 7 +- .../routes/settings/custom_link/helper.ts | 4 +- .../apm/server/routes/transactions/route.ts | 14 +- x-pack/plugins/observability/server/index.ts | 2 +- .../observability/server/ui_settings.ts | 4 +- .../server/utils/queries.test.ts | 42 ++ .../observability/server/utils/queries.ts | 21 + .../translations/translations/fr-FR.json | 6 - .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../tests/services/top_services.spec.ts | 2 +- ...ransactions_groups_main_statistics.spec.ts | 4 +- 65 files changed, 1778 insertions(+), 1520 deletions(-) create mode 100644 packages/kbn-apm-synthtrace/src/scenarios/helpers/exception_types.ts create mode 100644 packages/kbn-apm-synthtrace/src/scenarios/many_dependencies.ts delete mode 100644 packages/kbn-apm-synthtrace/src/scenarios/service_many_dependencies.ts create mode 100644 x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/use_error_group_list_data.tsx delete mode 100644 x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/table_search_bar/table_search_bar.tsx create mode 100644 x-pack/plugins/apm/public/hooks/use_debounce.test.tsx create mode 100644 x-pack/plugins/apm/public/hooks/use_debounce.tsx create mode 100644 x-pack/plugins/observability/server/utils/queries.test.ts 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 3ee43dc63f04f0b..d55d34cc188418b 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 3dd5ec30933c6d4..12e9454a7770a2f 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 000000000000000..a7e7ba34c484642 --- /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 7532b3dc9477cd8..4227f003771efe4 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 000000000000000..61ecb7832f0f310 --- /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 7948688610f5617..6f9849615b78e6b 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 4774839c717275a..8c57a37177f8529 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 1af9f2f9e3b5a5f..19da565ab886085 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 895d413235512d0..ce9af03c8e49254 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 a548e55f575a4f1..000000000000000 --- 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 c93e37b4f99b372..1d5ee6f53d63ca9 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 889724f664f9bb8..c0c3c032a0e61f5 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 9aeff02dff4f3d4..858d80e645ae485 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 e0133ba6a6695b0..7f0f2bda106ba20 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 c61a37fa86e5e89..d1183c91cb3e41b 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 819b75a44c7b17e..3fbc22b845d3382 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 000000000000000..3e3c4d3ea829ec9 --- /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 d6450ad4def5736..f391e73012b4033 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 ea03b6cc58ac85e..b59f0064349bd6c 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 67214733bece41c..1675932b26063f4 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 1073a459cecbc8c..55bde1c8fcf2b0d 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 4d3186c784447f0..08e7a840b5dfbe6 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 89dade9d8d0cda9..17c018e272ee859 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 515aadaf11b52e9..7f81dfcc0b3b196 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 74856cbca3b1141..04659b6b211bc2e 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 55972ede6e56022..4b2ab0ad6c3a8eb 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 81e5509ca6239ac..499729b31ae3bdf 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 40f8f5ad1db253f..37bf4c83c19604b 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 372138c1273a907..73f5e40aa2e2a25 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 88d9e88c5e7baa2..7d6307d32ffb829 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 a43d78887ec9fbe..5bc6199aba22d2c 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 cec958da0eb7063..de17d0ccf6d4b35 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 000000000000000..ebbd5031c6380ab --- /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 000000000000000..7ff7322c98354b5 --- /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 32329b81a1edba2..083fd1e42f5f4f1 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 bd0e4f242cf39f4..e4553dfee073a88 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 000000000000000..6701024eea9e954 --- /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 000000000000000..1aa2276e901b23f --- /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 bc8a2056cb41c85..66e4fa63ce62088 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 df4da04651228ad..cfa65e001def76d 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 be99a537de011f8..348d84883ba8747 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 0017e948638281f..4192d35d796f3ce 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 182fe0a1cdd8ae8..6c8d878502b7ac4 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 a3e11887f1caf25..a76ab1df8a9b1a6 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 d46c43d25816606..eb0fb5ed62e0525 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 84ec4da4829e8b4..a44a557e2b4de79 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 9ad556df126ba6e..178c5e186c31614 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 0b8a252da9a7630..f2dbeb8410bbe59 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 e47d9b61124cce1..0fc76f803c440b9 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 7fbeb1cc9d24cb1..a79e17c32c04b2c 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 c36754e4cf50f79..e57e8e9d20235f0 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 0eedb8494f21bd6..0ddae57603fe126 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 0a51a3e88379fa9..e997c6d56c1b807 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 62082b2236dec7a..55cdaebf2679145 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 888cb82820cc1f8..0796062227b086a 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 3d9e87ccadf004a..682d05e2f249fe8 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 f58b6dd1f9683a7..0ccd75c9ae3a3e6 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 000000000000000..0e34f8405038662 --- /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 4715e0f398e4ad6..bdacad577838c82 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 5eaceb182e5cdde..e4dab6524c3f4b0 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 c0d8dd949344c53..0bfb0520186b3e5 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 a893ee0a19dabbf..7e238a0c10c6443 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 b519a28de70e841..b6dc025ecef10b2 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 3a7fedc76ef3e5e..91682c0edf796e1 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) );