From f5088817570fdb05e3e4fa51954f846e43a49fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arturo=20Lidue=C3=B1a?= Date: Fri, 11 Sep 2026 11:27:57 +0200 Subject: [PATCH 1/2] [Obs AI Assistant] Skip log rate analysis and log categories when no entity filters are present (#290244) Closes Issue: https://github.com/elastic/sdh-kibana/issues/6510 ## Summary Fixes a performance issue where clicking "Help me understand this alert" on a `custom_threshold` alert that monitors a non-standard metric, one that produces no `service.name`, `host.name`, `container.id`, or `kubernetes.pod.name` in the alert document, causes the `/internal/observability/assistant/alert_details_contextual_insights` endpoint to hang indefinitely. ### Root cause `getLogRateAnalysisForAlert` and `getLogCategories` were pushed to the data-fetcher queue unconditionally, regardless of whether any entity context was available. When all entity values are `undefined`, `getShouldMatchOrNotExistFilter` returns an empty array, so the resulting ES queries carry no entity-scoping filters and fan out across **all** configured log sources. ### Fix Added a `hasEntityFilters` flag (true when at least one of `serviceName`, `hostName`, `containerId`, `kubernetesPodName` is defined) and wrapped both log data fetchers with `if (hasEntityFilters)`. When no entity context is present, running these analyses has no meaningful scope and would scan all log data without correlation to the triggering alert. (cherry picked from commit 384373226111f1aaf3369363ee97eedd803c3ac4) --- .../index.test.ts | 140 ++++++++++++++++++ .../index.ts | 112 +++++++------- 2 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.test.ts diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.test.ts b/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.test.ts new file mode 100644 index 0000000000000..7cf0564129a13 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { getAlertDetailsContextHandler } from '.'; +import { getLogRateAnalysisForAlert } from '../get_log_rate_analysis_for_alert'; +import { getLogCategories } from '../get_log_categories'; +import { getServiceNameFromSignals } from './get_service_name_from_signals'; +import { getContainerIdFromSignals } from './get_container_id_from_signals'; +import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client'; +import { getApmAlertsClient } from '../../../lib/helpers/get_apm_alerts_client'; +import { getMlClient } from '../../../lib/helpers/get_ml_client'; +import { getRandomSampler } from '../../../lib/helpers/get_random_sampler'; +import { getExitSpanChangePoints, getServiceChangePoints } from '../get_changepoints'; +import { getAnomalies } from '../get_apm_service_summary/get_anomalies'; + +jest.mock('../get_log_rate_analysis_for_alert'); +jest.mock('../get_log_categories'); +jest.mock('./get_service_name_from_signals'); +jest.mock('./get_container_id_from_signals'); +jest.mock('../../../lib/helpers/get_apm_event_client'); +jest.mock('../../../lib/helpers/get_apm_alerts_client'); +jest.mock('../../../lib/helpers/get_ml_client'); +jest.mock('../../../lib/helpers/get_random_sampler'); +jest.mock('../get_apm_service_summary'); +jest.mock('../get_apm_downstream_dependencies'); +jest.mock('../get_changepoints'); +jest.mock('../get_apm_service_summary/get_anomalies'); +jest.mock('./get_apm_errors'); + +const mockLogRateAnalysis = jest.mocked(getLogRateAnalysisForAlert); +const mockLogCategories = jest.mocked(getLogCategories); +const mockGetServiceName = jest.mocked(getServiceNameFromSignals); +const mockGetContainerId = jest.mocked(getContainerIdFromSignals); + +function buildMocks() { + (getApmEventClient as jest.Mock).mockResolvedValue({}); + (getApmAlertsClient as jest.Mock).mockResolvedValue({}); + (getMlClient as jest.Mock).mockResolvedValue({}); + (getRandomSampler as jest.Mock).mockResolvedValue({}); + (getExitSpanChangePoints as jest.Mock).mockResolvedValue([]); + (getServiceChangePoints as jest.Mock).mockResolvedValue([]); + (getAnomalies as jest.Mock).mockResolvedValue([]); + + mockLogRateAnalysis.mockResolvedValue({ logRateAnalysisType: 'spike', significantItems: [] }); + mockLogCategories.mockResolvedValue({ logCategories: [], entities: [] }); +} + +const mockApmCore = { + start: jest.fn().mockResolvedValue({}), +} as any; + +const mockResourcePlugins = { + observability: { + setup: { + getScopedAnnotationsClient: jest.fn().mockResolvedValue(undefined), + }, + }, + alerting: { + start: jest.fn().mockResolvedValue({ getRulesClientWithRequest: jest.fn() }), + }, + ruleRegistry: { + start: jest.fn().mockResolvedValue({ getRacClientWithRequest: jest.fn() }), + }, + logsDataAccess: { + start: jest.fn().mockResolvedValue({ + services: { + logSourcesServiceFactory: { + getScopedLogSourcesService: jest.fn().mockResolvedValue({}), + }, + }, + }), + }, + apmDataAccess: { + setup: { getApmIndices: jest.fn().mockResolvedValue({}) }, + }, +} as any; + +const mockRequestContext = { + core: Promise.resolve({ + elasticsearch: { client: { asCurrentUser: {} } }, + savedObjects: { client: {} }, + }), + licensing: {}, + request: {}, +} as any; + +const baseQuery = { + alert_started_at: '2024-01-01T00:00:00.000Z', + 'service.environment': undefined, + 'host.name': undefined, + 'kubernetes.pod.name': undefined, + alert_rule_parameter_time_size: undefined, + alert_rule_parameter_time_unit: undefined, + 'transaction.type': undefined, + 'transaction.name': undefined, +} as any; + +const mockLogger = { error: jest.fn() } as any; + +describe('getAlertDetailsContextHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + buildMocks(); + mockGetServiceName.mockResolvedValue(undefined); + mockGetContainerId.mockResolvedValue(undefined); + }); + + describe('when no entity context is present', () => { + it('does not invoke getLogRateAnalysisForAlert or getLogCategories', async () => { + const handler = getAlertDetailsContextHandler(mockApmCore, mockResourcePlugins, mockLogger); + + await handler(mockRequestContext, { + ...baseQuery, + 'host.name': undefined, + 'kubernetes.pod.name': undefined, + }); + + expect(mockLogRateAnalysis).not.toHaveBeenCalled(); + expect(mockLogCategories).not.toHaveBeenCalled(); + }); + }); + + describe('when at least one entity is present', () => { + it('invokes both getLogRateAnalysisForAlert and getLogCategories', async () => { + const handler = getAlertDetailsContextHandler(mockApmCore, mockResourcePlugins, mockLogger); + + await handler(mockRequestContext, { + ...baseQuery, + 'host.name': 'my-host', + }); + + expect(mockLogRateAnalysis).toHaveBeenCalledTimes(1); + expect(mockLogCategories).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts b/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts index 0defaf73c0be1..e29f07c56a699 100644 --- a/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts +++ b/x-pack/solutions/observability/plugins/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts @@ -130,6 +130,8 @@ export const getAlertDetailsContextHandler = ( const dataFetchers: Array<() => Promise> = []; + const hasEntityFilters = !!(serviceName || hostName || containerId || kubernetesPodName); + // service summary if (serviceName) { dataFetchers.push(async () => { @@ -168,69 +170,71 @@ export const getAlertDetailsContextHandler = ( }); } - // log rate analysis - dataFetchers.push(async () => { - const { logRateAnalysisType, significantItems } = await getLogRateAnalysisForAlert({ - esClient, - logSourcesService, - arguments: { - alertStartedAt: moment(alertStartedAt).toISOString(), - alertRuleParameterTimeSize: query.alert_rule_parameter_time_size - ? parseInt(query.alert_rule_parameter_time_size, 10) - : undefined, - alertRuleParameterTimeUnit: query.alert_rule_parameter_time_unit, - entities: { - 'service.name': serviceName, - 'host.name': hostName, - 'container.id': containerId, - 'kubernetes.pod.name': kubernetesPodName, + if (hasEntityFilters) { + // log rate analysis + dataFetchers.push(async () => { + const { logRateAnalysisType, significantItems } = await getLogRateAnalysisForAlert({ + esClient, + logSourcesService, + arguments: { + alertStartedAt: moment(alertStartedAt).toISOString(), + alertRuleParameterTimeSize: query.alert_rule_parameter_time_size + ? parseInt(query.alert_rule_parameter_time_size, 10) + : undefined, + alertRuleParameterTimeUnit: query.alert_rule_parameter_time_unit, + entities: { + 'service.name': serviceName, + 'host.name': hostName, + 'container.id': containerId, + 'kubernetes.pod.name': kubernetesPodName, + }, }, - }, - }); + }); + + if (logRateAnalysisType !== 'spike' || significantItems.length === 0) { + return { + key: 'logRateAnalysis', + description: + 'Log rate analysis did not identify any significant metadata or log patterns.', + data: [], + }; + } - if (logRateAnalysisType !== 'spike' || significantItems.length === 0) { return { key: 'logRateAnalysis', - description: - 'Log rate analysis did not identify any significant metadata or log patterns.', - data: [], + description: `Statistically significant log metadata and log message patterns occurring in the lookback period before the alert was triggered.`, + data: significantItems, }; - } - - return { - key: 'logRateAnalysis', - description: `Statistically significant log metadata and log message patterns occurring in the lookback period before the alert was triggered.`, - data: significantItems, - }; - }); + }); - // log categories - dataFetchers.push(async () => { - const downstreamDependencies = await downstreamDependenciesPromise; - const { logCategories, entities } = await getLogCategories({ - apmEventClient, - esClient, - logSourcesService, - arguments: { - start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), - end: alertStartedAt, - entities: { - 'service.name': serviceName, - 'host.name': hostName, - 'container.id': containerId, - 'kubernetes.pod.name': kubernetesPodName, + // log categories + dataFetchers.push(async () => { + const downstreamDependencies = await downstreamDependenciesPromise; + const { logCategories, entities } = await getLogCategories({ + apmEventClient, + esClient, + logSourcesService, + arguments: { + start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), + end: alertStartedAt, + entities: { + 'service.name': serviceName, + 'host.name': hostName, + 'container.id': containerId, + 'kubernetes.pod.name': kubernetesPodName, + }, }, - }, - }); + }); - const entitiesAsString = entities.map(({ key, value }) => `${key}:${value}`).join(', '); + const entitiesAsString = entities.map(({ key, value }) => `${key}:${value}`).join(', '); - return { - key: 'logCategories', - description: `Log events occurring up to 15 minutes before the alert was triggered. Filtered by the entities: ${entitiesAsString}`, - data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), - }; - }); + return { + key: 'logCategories', + description: `Log events occurring up to 15 minutes before the alert was triggered. Filtered by the entities: ${entitiesAsString}`, + data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), + }; + }); + } // apm errors if (serviceName) { From 0d1d8422d351707ac221a31c816f9a7fc5c99973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arturo=20Lidue=C3=B1a?= Date: Sun, 13 Sep 2026 08:23:05 +0200 Subject: [PATCH 2/2] update obs_alert_details_context tests --- .../trial/tests/obs_alert_details_context.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/x-pack/solutions/observability/test/observability_api_integration/trial/tests/obs_alert_details_context.ts b/x-pack/solutions/observability/test/observability_api_integration/trial/tests/obs_alert_details_context.ts index 45079c4f98324..711e19f52bafe 100644 --- a/x-pack/solutions/observability/test/observability_api_integration/trial/tests/obs_alert_details_context.ts +++ b/x-pack/solutions/observability/test/observability_api_integration/trial/tests/obs_alert_details_context.ts @@ -70,16 +70,8 @@ export default function ApiTest({ getService }: ObsFtrProviderContext) { }); }); - it('returns only 1 log category', async () => { - expect(response.body.alertContext).to.have.length(1); - - const logCategories = response.body.alertContext.find( - ({ key }) => key === 'logCategories' - )?.data as LogCategory[]; - - expect( - logCategories.map(({ errorCategory }: { errorCategory: string }) => errorCategory) - ).to.eql(['Error message from container my-container-a']); + it('returns nothing', async () => { + expect(response.body.alertContext).to.eql([]); }); }); @@ -317,13 +309,11 @@ export default function ApiTest({ getService }: ObsFtrProviderContext) { expect(serviceSummary).to.be(undefined); }); - it('returns 1 log category', async () => { + it('returns no log categories', async () => { const logCategories = response.body.alertContext.find( ({ key }) => key === 'logCategories' - )?.data as LogCategory[]; - expect( - logCategories.map(({ errorCategory }: { errorCategory: string }) => errorCategory) - ).to.eql(['Error message from service', 'Error message from container my-container-c']); + ); + expect(logCategories).to.be(undefined); }); });