From f1f3a4fddd34a08e33d2778a09f2166d6d1b02a0 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 11 Dec 2024 10:39:01 +0100 Subject: [PATCH] Fix Custom Threshold Rule `ViewInAppUrl` does not honor space (#201793) ## Summary Close https://github.com/elastic/kibana/issues/201378 Fix https://github.com/elastic/kibana/issues/201333 - [Share] Allow to pass `spaceId` to `getRedirectUrl` to build a URL with a specific `spaceId` - Fix Custom Threshold Rule ViewInAppUrl does not honor Space --------- Co-authored-by: Maryam Saeidi --- .../url_service/locators/locator.test.ts | 47 ++++- .../common/url_service/locators/locator.ts | 18 +- .../url_service/locators/redirect/index.ts | 1 + .../redirect/space_url_parser.test.ts | 47 +++++ .../locators/redirect/space_url_parser.ts | 31 ++++ .../url_service/locators/redirect/types.ts | 11 ++ .../common/url_service/locators/types.ts | 4 +- .../fake_hosts/ecs/fields/custom/system.yml | 4 + .../src/data_sources/fake_hosts/index.ts | 10 ++ .../get_view_in_app_url.test.ts | 169 +++++++++++------- .../get_view_in_app_url.ts | 17 +- .../custom_threshold_executor.test.ts | 9 +- .../custom_threshold_executor.ts | 1 + .../custom_threshold/avg_pct_fired.ts | 26 ++- .../{avg_us_fired.ts => avg_ticks_fired.ts} | 100 ++++++++--- .../alerting/custom_threshold/index.ts | 2 +- .../services/alerting_api.ts | 20 ++- .../services/data_view_api.ts | 16 +- .../services/deployment_agnostic_services.ts | 1 + 19 files changed, 421 insertions(+), 113 deletions(-) create mode 100644 src/plugins/share/common/url_service/locators/redirect/space_url_parser.test.ts create mode 100644 src/plugins/share/common/url_service/locators/redirect/space_url_parser.ts rename x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/{avg_us_fired.ts => avg_ticks_fired.ts} (71%) diff --git a/src/plugins/share/common/url_service/locators/locator.test.ts b/src/plugins/share/common/url_service/locators/locator.test.ts index 18bd21c0ed4fd..648695f71f008 100644 --- a/src/plugins/share/common/url_service/locators/locator.test.ts +++ b/src/plugins/share/common/url_service/locators/locator.test.ts @@ -13,8 +13,9 @@ import { KibanaLocation } from '../../../public'; import { LocatorGetUrlParams } from '.'; import { decompressFromBase64 } from 'lz-string'; -const setup = () => { - const baseUrl = 'http://localhost:5601'; +const setup = ( + { baseUrl = 'http://localhost:5601' }: { baseUrl: string } = { baseUrl: 'http://localhost:5601' } +) => { const version = '1.2.3'; const deps: LocatorDependencies = { baseUrl, @@ -88,6 +89,48 @@ describe('Locator', () => { baz: 'b', }); }); + + test('returns URL of the redirect endpoint with custom spaceid', async () => { + const { locator } = setup(); + const url = await locator.getRedirectUrl( + { foo: 'a', baz: 'b' }, + { spaceId: 'custom-space-id' } + ); + + expect(url).toBe( + 'http://localhost:5601/s/custom-space-id/app/r?l=TEST_LOCATOR&v=1.2.3&lz=N4IgZg9hIFwghiANCARvAXrNIC%2BQ' + ); + }); + + test('returns URL of the redirect endpoint with replaced spaceid', async () => { + const { locator } = setup({ baseUrl: 'http://localhost:5601/s/space-id' }); + const url = await locator.getRedirectUrl( + { foo: 'a', baz: 'b' }, + { spaceId: 'custom-space-id' } + ); + + expect(url).toBe( + 'http://localhost:5601/s/custom-space-id/app/r?l=TEST_LOCATOR&v=1.2.3&lz=N4IgZg9hIFwghiANCARvAXrNIC%2BQ' + ); + }); + + test('returns URL of the redirect endpoint without spaceid', async () => { + const { locator } = setup({ baseUrl: 'http://localhost:5601/s/space-id' }); + const url = await locator.getRedirectUrl({ foo: 'a', baz: 'b' }, { spaceId: 'default' }); + + expect(url).toBe( + 'http://localhost:5601/app/r?l=TEST_LOCATOR&v=1.2.3&lz=N4IgZg9hIFwghiANCARvAXrNIC%2BQ' + ); + }); + + test('returns URL of the redirect endpoint with untouched spaceId', async () => { + const { locator } = setup({ baseUrl: 'http://localhost:5601/s/space-id' }); + const url = await locator.getRedirectUrl({ foo: 'a', baz: 'b' }); + + expect(url).toBe( + 'http://localhost:5601/s/space-id/app/r?l=TEST_LOCATOR&v=1.2.3&lz=N4IgZg9hIFwghiANCARvAXrNIC%2BQ' + ); + }); }); describe('.navigate()', () => { diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index ba532449463f3..d479eac25c266 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -19,7 +19,13 @@ import type { LocatorNavigationParams, LocatorGetUrlParams, } from './types'; -import { formatSearchParams, FormatSearchParamsOptions, RedirectOptions } from './redirect'; +import { + formatSearchParams, + FormatSearchParamsOptions, + RedirectOptions, + GetRedirectUrlOptions, + addSpaceIdToPath, +} from './redirect'; export interface LocatorDependencies { /** @@ -92,7 +98,7 @@ export class Locator

implements LocatorPublic

{ return url; } - public getRedirectUrl(params: P, options: FormatSearchParamsOptions = {}): string { + public getRedirectUrl(params: P, options: GetRedirectUrlOptions = {}): string { const { baseUrl = '', version = '0.0.0' } = this.deps; const redirectOptions: RedirectOptions = { id: this.definition.id, @@ -100,12 +106,16 @@ export class Locator

implements LocatorPublic

{ params, }; const formatOptions: FormatSearchParamsOptions = { - ...options, lzCompress: options.lzCompress ?? true, }; const search = formatSearchParams(redirectOptions, formatOptions).toString(); + const path = '/app/r?' + search; - return baseUrl + '/app/r?' + search; + if (options.spaceId) { + return addSpaceIdToPath(baseUrl, options.spaceId, path); + } else { + return baseUrl + path; + } } public async navigate( diff --git a/src/plugins/share/common/url_service/locators/redirect/index.ts b/src/plugins/share/common/url_service/locators/redirect/index.ts index 9e2a9d8f433e9..7e37c2b8b6d23 100644 --- a/src/plugins/share/common/url_service/locators/redirect/index.ts +++ b/src/plugins/share/common/url_service/locators/redirect/index.ts @@ -10,3 +10,4 @@ export * from './types'; export * from './format_search_params'; export * from './parse_search_params'; +export * from './space_url_parser'; diff --git a/src/plugins/share/common/url_service/locators/redirect/space_url_parser.test.ts b/src/plugins/share/common/url_service/locators/redirect/space_url_parser.test.ts new file mode 100644 index 0000000000000..d6dc64c63b0f4 --- /dev/null +++ b/src/plugins/share/common/url_service/locators/redirect/space_url_parser.test.ts @@ -0,0 +1,47 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { addSpaceIdToPath } from './space_url_parser'; + +describe('addSpaceIdToPath', () => { + test('handles no parameters', () => { + expect(addSpaceIdToPath()).toEqual(`/`); + }); + + test('it adds to the basePath correctly', () => { + expect(addSpaceIdToPath('/my/base/path', 'url-context')).toEqual('/my/base/path/s/url-context'); + }); + + test('it appends the requested path to the end of the url context', () => { + expect(addSpaceIdToPath('/base', 'context', '/final/destination')).toEqual( + '/base/s/context/final/destination' + ); + }); + + test('it replaces existing space identifiers', () => { + expect(addSpaceIdToPath('/my/base/path/s/old-space/', 'new-space')).toEqual( + '/my/base/path/s/new-space' + ); + + expect(addSpaceIdToPath('/my/base/path/s/old-space-no-trailing', 'new-space')).toEqual( + '/my/base/path/s/new-space' + ); + }); + + test('it removes existing space identifier when spaceId is default', () => { + expect(addSpaceIdToPath('/my/base/path/s/old-space', 'default')).toEqual('/my/base/path'); + expect(addSpaceIdToPath('/my/base/path/s/old-space')).toEqual('/my/base/path'); + }); + + test('it throws an error when the requested path does not start with a slash', () => { + expect(() => { + addSpaceIdToPath('', '', 'foo'); + }).toThrowErrorMatchingInlineSnapshot(`"path must start with a /"`); + }); +}); diff --git a/src/plugins/share/common/url_service/locators/redirect/space_url_parser.ts b/src/plugins/share/common/url_service/locators/redirect/space_url_parser.ts new file mode 100644 index 0000000000000..9c9cb519d1c7f --- /dev/null +++ b/src/plugins/share/common/url_service/locators/redirect/space_url_parser.ts @@ -0,0 +1,31 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export function addSpaceIdToPath( + basePath: string = '/', + spaceId: string = '', + requestedPath: string = '' +): string { + if (requestedPath && !requestedPath.startsWith('/')) { + throw new Error(`path must start with a /`); + } + + if (basePath.includes('/s/')) { + // If the base path already contains a space identifier, remove it + basePath = basePath.replace(/\/s\/[^/]+/, ''); + } + + const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + + if (spaceId && spaceId !== 'default') { + return `${normalizedBasePath}/s/${spaceId}${requestedPath}`; + } + + return `${normalizedBasePath}${requestedPath}` || '/'; +} diff --git a/src/plugins/share/common/url_service/locators/redirect/types.ts b/src/plugins/share/common/url_service/locators/redirect/types.ts index dfd6211c97434..bbc1b14f2e428 100644 --- a/src/plugins/share/common/url_service/locators/redirect/types.ts +++ b/src/plugins/share/common/url_service/locators/redirect/types.ts @@ -8,6 +8,7 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import type { FormatSearchParamsOptions } from './format_search_params'; /** * @public @@ -27,3 +28,13 @@ export interface RedirectOptions

extends Persistable * @param params URL locator parameters. * @param options URL serialization options. */ - getRedirectUrl(params: P, options?: FormatSearchParamsOptions): string; + getRedirectUrl(params: P, options?: GetRedirectUrlOptions): string; /** * Navigate using the `core.application.navigateToApp()` method to a Kibana diff --git a/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/ecs/fields/custom/system.yml b/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/ecs/fields/custom/system.yml index 168a1028952f1..656c410a18e08 100644 --- a/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/ecs/fields/custom/system.yml +++ b/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/ecs/fields/custom/system.yml @@ -44,3 +44,7 @@ level: custom type: long description: "Number of outgoing bytes" + - name: core.system.ticks + level: custom + type: long + description: "The amount of CPU time spent in kernel space" diff --git a/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/index.ts b/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/index.ts index becee1697c0c8..c46b3c3e95bf7 100644 --- a/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/index.ts +++ b/x-pack/packages/kbn-data-forge/src/data_sources/fake_hosts/index.ts @@ -121,6 +121,11 @@ export const generateEvent: GeneratorFunction = (config, schedule, index, timest bytes: generateNetworkData(timestamp.toISOString()), }, }, + core: { + system: { + ticks: randomBetween(1_000_000, 1_500_100), + }, + }, }, metricset: { period: interval, @@ -159,6 +164,11 @@ export const generateEvent: GeneratorFunction = (config, schedule, index, timest bytes: generateNetworkData(timestamp.toISOString()), }, }, + core: { + system: { + ticks: randomBetween(1_000_000, 1_500_100), + }, + }, }, metricset: { period: interval, diff --git a/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.test.ts b/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.test.ts index 94ff0139414f0..e275a89e18b3a 100644 --- a/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.test.ts +++ b/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.test.ts @@ -59,15 +59,18 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: args.dataViewId, - timeRange: returnedTimeRange, - filters: [], - query: { - query: 'mockedFilter and mockedCountFilter', - language: 'kuery', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: args.dataViewId, + timeRange: returnedTimeRange, + filters: [], + query: { + query: 'mockedFilter and mockedCountFilter', + language: 'kuery', + }, }, - }); + {} + ); }); it('should call getRedirectUrl with only count filter', () => { @@ -85,15 +88,18 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: undefined, - timeRange: returnedTimeRange, - filters: [], - query: { - query: 'mockedCountFilter', - language: 'kuery', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [], + query: { + query: 'mockedCountFilter', + language: 'kuery', + }, }, - }); + {} + ); }); it('should call getRedirectUrl with only filter', () => { @@ -111,15 +117,18 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: undefined, - timeRange: returnedTimeRange, - filters: [], - query: { - query: 'mockedFilter', - language: 'kuery', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [], + query: { + query: 'mockedFilter', + language: 'kuery', + }, }, - }); + {} + ); }); it('should call getRedirectUrl with empty query if metrics and filter are not not provided', () => { @@ -130,15 +139,18 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: undefined, - timeRange: returnedTimeRange, - filters: [], - query: { - query: '', - language: 'kuery', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [], + query: { + query: '', + language: 'kuery', + }, }, - }); + {} + ); }); it('should call getRedirectUrl with empty if there are multiple metrics', () => { @@ -161,15 +173,18 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: undefined, - timeRange: returnedTimeRange, - filters: [], - query: { - query: '', - language: 'kuery', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [], + query: { + query: '', + language: 'kuery', + }, }, - }); + {} + ); }); it('should call getRedirectUrl with filters if group and searchConfiguration filter are provided', () => { @@ -217,33 +232,67 @@ describe('getViewInAppUrl', () => { }; expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); - expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith({ - dataset: undefined, - timeRange: returnedTimeRange, - filters: [ - { - meta: {}, - query: { - term: { - field: { - value: 'justTesting', + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [ + { + meta: {}, + query: { + term: { + field: { + value: 'justTesting', + }, }, }, }, - }, - { - meta: {}, - query: { - match_phrase: { - 'host.name': 'host-1', + { + meta: {}, + query: { + match_phrase: { + 'host.name': 'host-1', + }, }, }, + ], + query: { + query: 'mockedFilter', + language: 'kuery', + }, + }, + {} + ); + }); + + it('should call getRedirectUrl with spaceId', () => { + const spaceId = 'mockedSpaceId'; + const args: GetViewInAppUrlArgs = { + metrics: [ + { + name: 'A', + aggType: Aggregators.COUNT, + filter: 'mockedCountFilter', }, ], - query: { - query: 'mockedFilter', - language: 'kuery', + logsExplorerLocator, + startedAt, + endedAt, + spaceId, + }; + + expect(getViewInAppUrl(args)).toBe('mockedGetRedirectUrl'); + expect(logsExplorerLocator.getRedirectUrl).toHaveBeenCalledWith( + { + dataset: undefined, + timeRange: returnedTimeRange, + filters: [], + query: { + query: 'mockedCountFilter', + language: 'kuery', + }, }, - }); + { spaceId } + ); }); }); diff --git a/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.ts b/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.ts index 5411eff43bc3d..0d6095f6b520f 100644 --- a/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.ts +++ b/x-pack/plugins/observability_solution/observability/common/custom_threshold_rule/get_view_in_app_url.ts @@ -22,6 +22,7 @@ export interface GetViewInAppUrlArgs { logsExplorerLocator?: LocatorPublic; metrics?: CustomThresholdExpressionMetric[]; startedAt?: string; + spaceId?: string; } export const getViewInAppUrl = ({ @@ -32,6 +33,7 @@ export const getViewInAppUrl = ({ metrics = [], searchConfiguration, startedAt = new Date().toISOString(), + spaceId, }: GetViewInAppUrlArgs) => { if (!logsExplorerLocator) return ''; @@ -56,10 +58,13 @@ export const getViewInAppUrl = ({ query.query = searchConfigurationQuery; } - return logsExplorerLocator?.getRedirectUrl({ - dataset, - timeRange, - query, - filters: [...searchConfigurationFilters, ...groupFilters], - }); + return logsExplorerLocator?.getRedirectUrl( + { + dataset, + timeRange, + query, + filters: [...searchConfigurationFilters, ...groupFilters], + }, + { spaceId } + ); }; diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts index b8dff520ff119..fb5aef4e3ddcb 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts @@ -38,6 +38,7 @@ const initialRuleState: TestRuleState = { }; const fakeLogger = (msg: string, meta?: Meta) => {}; +const MOCKED_SPACE_ID = 'mockedSpaceId'; const logger = { trace: fakeLogger, @@ -90,7 +91,7 @@ const mockOptions = { }, trackedAlertsRecovered: {}, }, - spaceId: '', + spaceId: MOCKED_SPACE_ID, rule: { id: '', name: '', @@ -1563,7 +1564,7 @@ describe('The custom threshold alert type', () => { expect(services.alertsClient.setAlertData).toBeCalledTimes(1); expect(services.alertsClient.setAlertData).toBeCalledWith({ context: { - alertDetailsUrl: 'http://localhost:5601/app/observability/alerts/uuid-a', + alertDetailsUrl: `http://localhost:5601/s/${MOCKED_SPACE_ID}/app/observability/alerts/uuid-a`, viewInAppUrl: 'mockedViewInApp', group: [ { @@ -1584,6 +1585,7 @@ describe('The custom threshold alert type', () => { }); expect(getViewInAppUrl).lastCalledWith({ dataViewId: 'c34a7c79-a88b-4b4a-ad19-72f6d24104e4', + spaceId: MOCKED_SPACE_ID, groups: [ { field: 'host.name', @@ -1800,7 +1802,7 @@ describe('The custom threshold alert type', () => { await execute(true); const recentAlert = getLastReportedAlert(instanceID); expect(recentAlert?.context).toEqual({ - alertDetailsUrl: 'http://localhost:5601/app/observability/alerts/uuid-*', + alertDetailsUrl: `http://localhost:5601/s/${MOCKED_SPACE_ID}/app/observability/alerts/uuid-*`, reason: 'Average test.metric.3 reported no data in the last 1m', timestamp: STARTED_AT_MOCK_DATE.toISOString(), value: ['[NO DATA]', null], @@ -3438,6 +3440,7 @@ describe('The custom threshold alert type', () => { const execute = (alertOnNoData: boolean, sourceId: string = 'default') => executor({ ...mockOptions, + spaceId: '', services, params: { ...mockOptions.params, diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts index 591e8062d5ca7..72c9795122dc8 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts @@ -285,6 +285,7 @@ export const createCustomThresholdExecutor = ({ metrics: alertResults.length === 1 ? alertResults[0][group].metrics : [], searchConfiguration: params.searchConfiguration, startedAt: indexedStartedAt, + spaceId, }), ...additionalContext, }, diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_pct_fired.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_pct_fired.ts index 8ed42269e569b..f8f4a8a7df66b 100644 --- a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_pct_fired.ts +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_pct_fired.ts @@ -28,6 +28,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const dataViewApi = getService('dataViewApi'); const logger = getService('log'); const config = getService('config'); + const spacesService = getService('spaces'); const isServerless = config.get('serverless'); const expectedConsumer = isServerless ? 'observability' : 'logs'; let roleAuthc: RoleCredentials; @@ -39,6 +40,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const DATA_VIEW_TITLE = 'kbn-data-forge-fake_hosts.fake_hosts-*'; const DATA_VIEW_NAME = 'data-view-name'; const DATA_VIEW_ID = 'data-view-id'; + const SPACE_ID = 'test-space'; let dataForgeConfig: PartialConfig; let dataForgeIndices: string[]; let actionId: string; @@ -73,8 +75,15 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { name: DATA_VIEW_NAME, id: DATA_VIEW_ID, title: DATA_VIEW_TITLE, + spaceId: SPACE_ID, roleAuthc, }); + await spacesService.create({ + id: SPACE_ID, + name: 'Test Space', + disabledFeatures: [], + color: '#AABBCC', + }); }); after(async () => { @@ -98,11 +107,13 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); await dataViewApi.delete({ id: DATA_VIEW_ID, + spaceId: SPACE_ID, roleAuthc, }); await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]); await cleanup({ client: esClient, config: dataForgeConfig, logger }); await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); + await spacesService.delete(SPACE_ID); }); describe('Rule creation', () => { @@ -111,10 +122,12 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { roleAuthc, name: 'Index Connector: Threshold API test', indexName: ALERT_ACTION_INDEX, + spaceId: SPACE_ID, }); const createdRule = await alertingApi.createRule({ roleAuthc, + spaceId: SPACE_ID, tags: ['observability'], consumer: expectedConsumer, name: 'Threshold rule', @@ -174,12 +187,13 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { roleAuthc, ruleId, expectedStatus: 'active', + spaceId: SPACE_ID, }); expect(executionStatus).to.be('active'); }); it('should find the created rule with correct information about the consumer', async () => { - const match = await alertingApi.findInRules(roleAuthc, ruleId); + const match = await alertingApi.findInRules(roleAuthc, ruleId, SPACE_ID); expect(match).not.to.be(undefined); expect(match.consumer).to.be(expectedConsumer); }); @@ -204,7 +218,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { 'observability.rules.custom_threshold' ); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId); - expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default'); + expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain(SPACE_ID); expect(resp.hits.hits[0]._source) .property('kibana.alert.rule.tags') .contain('observability'); @@ -245,7 +259,9 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const { protocol, hostname, port } = kbnTestConfig.getUrlPartsWithStrippedDefaultPort(); expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.custom_threshold'); expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql( - `${protocol}://${hostname}${port ? `:${port}` : ''}/app/observability/alerts/${alertId}` + `${protocol}://${hostname}${ + port ? `:${port}` : '' + }/s/${SPACE_ID}/app/observability/alerts/${alertId}` ); expect(resp.hits.hits[0]._source?.reason).eql( `Average system.cpu.user.pct is 250%, above the threshold of 50%. (duration: 5 mins, data view: ${DATA_VIEW_NAME})` @@ -255,6 +271,10 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const parsedViewInAppUrl = parseSearchParams( new URL(resp.hits.hits[0]._source?.viewInAppUrl || '').search ); + const viewInAppUrlPathName = new URL(resp.hits.hits[0]._source?.viewInAppUrl || '') + .pathname; + + expect(viewInAppUrlPathName).contain(`/s/${SPACE_ID}/app/r`); expect(resp.hits.hits[0]._source?.viewInAppUrl).contain('LOGS_EXPLORER_LOCATOR'); expect(omit(parsedViewInAppUrl.params, 'timeRange.from')).eql({ diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_us_fired.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_ticks_fired.ts similarity index 71% rename from x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_us_fired.ts rename to x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_ticks_fired.ts index 05b6ded5191d1..852363b0d8593 100644 --- a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_us_fired.ts +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/avg_ticks_fired.ts @@ -5,45 +5,44 @@ * 2.0. */ -import moment from 'moment'; -import { format } from 'url'; +import { omit } from 'lodash'; import expect from '@kbn/expect'; +import { cleanup, generate, Dataset, PartialConfig } from '@kbn/data-forge'; import { COMPARATORS } from '@kbn/alerting-comparators'; -import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; import { Aggregators } from '@kbn/observability-plugin/common/custom_threshold_rule/types'; import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/custom_threshold/constants'; import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { parseSearchParams } from '@kbn/share-plugin/common/url_service'; import { kbnTestConfig } from '@kbn/test'; import type { InternalRequestHeader, RoleCredentials } from '@kbn/ftr-common-functional-services'; import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context'; -import { getSyntraceClient, generateData } from './helpers/syntrace'; -import { ActionDocument } from './types'; +import { ISO_DATE_REGEX } from './constants'; +import { ActionDocument, LogsExplorerLocatorParsedParams } from './types'; export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { - const start = moment(Date.now()).subtract(10, 'minutes').valueOf(); - const end = moment(Date.now()).add(15, 'minutes').valueOf(); const esClient = getService('es'); const samlAuth = getService('samlAuth'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const esDeleteAllIndices = getService('esDeleteAllIndices'); const alertingApi = getService('alertingApi'); const dataViewApi = getService('dataViewApi'); + const logger = getService('log'); const config = getService('config'); - const kibanaServerConfig = config.get('servers.kibana'); const isServerless = config.get('serverless'); const expectedConsumer = isServerless ? 'observability' : 'logs'; - const kibanaUrl = format(kibanaServerConfig); + const spacesService = getService('spaces'); let roleAuthc: RoleCredentials; let internalReqHeader: InternalRequestHeader; - describe('AVG - US - FIRED', () => { + describe('AVG - TICKS - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; - const DATA_VIEW = 'traces-apm*,metrics-apm*,logs-apm*'; + const DATA_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*'; const DATA_VIEW_ID = 'data-view-id'; const DATA_VIEW_NAME = 'test-data-view-name'; - - let synthtraceEsClient: ApmSynthtraceEsClient; + const SPACE_ID = 'test-space'; + let dataForgeConfig: PartialConfig; + let dataForgeIndices: string[]; let actionId: string; let ruleId: string; let alertId: string; @@ -51,14 +50,47 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { before(async () => { roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin'); internalReqHeader = samlAuth.getInternalRequestHeader(); - synthtraceEsClient = await getSyntraceClient({ esClient, kibanaUrl }); - await generateData({ synthtraceEsClient, start, end }); + dataForgeConfig = { + schedule: [ + { + template: 'good', + start: 'now-10m', + end: 'now+5m', + metrics: [ + { + name: 'system.core.system.ticks', + method: 'linear', + start: 10_000_000, + end: 10_000_000, + }, + ], + }, + ], + indexing: { + dataset: 'fake_hosts' as Dataset, + eventsPerCycle: 1, + interval: 10000, + alignEventsToInterval: true, + }, + }; + dataForgeIndices = await generate({ client: esClient, config: dataForgeConfig, logger }); + await alertingApi.waitForDocumentInIndex({ + indexName: dataForgeIndices.join(','), + docCountTarget: 270, + }); await dataViewApi.create({ name: DATA_VIEW_NAME, id: DATA_VIEW_ID, title: DATA_VIEW, + spaceId: SPACE_ID, roleAuthc, }); + await spacesService.create({ + id: SPACE_ID, + name: 'Test Space', + disabledFeatures: [], + color: '#AABBCC', + }); }); after(async () => { @@ -70,7 +102,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { .delete(`/api/actions/connector/${actionId}`) .set(roleAuthc.apiKeyHeader) .set(internalReqHeader); - await esDeleteAllIndices([ALERT_ACTION_INDEX]); + await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]); await esClient.deleteByQuery({ index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, query: { term: { 'kibana.alert.rule.uuid': ruleId } }, @@ -79,11 +111,13 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { index: '.kibana-event-log-*', query: { term: { 'kibana.alert.rule.consumer': expectedConsumer } }, }); - await synthtraceEsClient.clean(); await dataViewApi.delete({ id: DATA_VIEW_ID, + spaceId: SPACE_ID, roleAuthc, }); + await cleanup({ client: esClient, config: dataForgeConfig, logger }); + await spacesService.delete(SPACE_ID); await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); }); @@ -93,10 +127,12 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { roleAuthc, name: 'Index Connector: Threshold API test', indexName: ALERT_ACTION_INDEX, + spaceId: SPACE_ID, }); const createdRule = await alertingApi.createRule({ roleAuthc, + spaceId: SPACE_ID, tags: ['observability'], consumer: expectedConsumer, name: 'Threshold rule', @@ -109,7 +145,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { timeSize: 5, timeUnit: 'm', metrics: [ - { name: 'A', field: 'span.self_time.sum.us', aggType: Aggregators.AVERAGE }, + { name: 'A', field: 'system.core.system.ticks', aggType: Aggregators.AVERAGE }, ], }, ], @@ -134,6 +170,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { alertDetailsUrl: '{{context.alertDetailsUrl}}', reason: '{{context.reason}}', value: '{{context.value}}', + viewInAppUrl: '{{context.viewInAppUrl}}', }, ], }, @@ -154,6 +191,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { roleAuthc, ruleId, expectedStatus: 'active', + spaceId: SPACE_ID, }); expect(executionStatus).to.be('active'); }); @@ -178,7 +216,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { 'observability.rules.custom_threshold' ); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId); - expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default'); + expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain(SPACE_ID); expect(resp.hits.hits[0]._source) .property('kibana.alert.rule.tags') .contain('observability'); @@ -203,7 +241,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { threshold: [7500000], timeSize: 5, timeUnit: 'm', - metrics: [{ name: 'A', field: 'span.self_time.sum.us', aggType: 'avg' }], + metrics: [{ name: 'A', field: 'system.core.system.ticks', aggType: 'avg' }], }, ], alertOnNoData: true, @@ -220,12 +258,30 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const { protocol, hostname, port } = kbnTestConfig.getUrlPartsWithStrippedDefaultPort(); expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.custom_threshold'); expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql( - `${protocol}://${hostname}${port ? `:${port}` : ''}/app/observability/alerts/${alertId}` + `${protocol}://${hostname}${ + port ? `:${port}` : '' + }/s/${SPACE_ID}/app/observability/alerts/${alertId}` ); expect(resp.hits.hits[0]._source?.reason).eql( - `Average span.self_time.sum.us is 10,000,000, above the threshold of 7,500,000. (duration: 5 mins, data view: ${DATA_VIEW_NAME})` + `Average system.core.system.ticks is 10,000,000, above the threshold of 7,500,000. (duration: 5 mins, data view: ${DATA_VIEW_NAME})` ); expect(resp.hits.hits[0]._source?.value).eql('10,000,000'); + + const parsedViewInAppUrl = parseSearchParams( + new URL(resp.hits.hits[0]._source?.viewInAppUrl || '').search + ); + const viewInAppUrlPathName = new URL(resp.hits.hits[0]._source?.viewInAppUrl || '') + .pathname; + + expect(viewInAppUrlPathName).contain(`/s/${SPACE_ID}/app/r`); + expect(resp.hits.hits[0]._source?.viewInAppUrl).contain('LOGS_EXPLORER_LOCATOR'); + expect(omit(parsedViewInAppUrl.params, 'timeRange.from')).eql({ + dataset: DATA_VIEW_ID, + timeRange: { to: 'now' }, + query: { query: '', language: 'kuery' }, + filters: [], + }); + expect(parsedViewInAppUrl.params.timeRange.from).match(ISO_DATE_REGEX); }); }); }); diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/index.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/index.ts index 96a3351043ae6..45a8f2d8b1b40 100644 --- a/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/alerting/custom_threshold/index.ts @@ -11,7 +11,7 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) describe('Custom Threshold rule', () => { loadTestFile(require.resolve('./avg_pct_fired')); loadTestFile(require.resolve('./avg_pct_no_data')); - loadTestFile(require.resolve('./avg_us_fired')); + loadTestFile(require.resolve('./avg_ticks_fired')); loadTestFile(require.resolve('./custom_eq_avg_bytes_fired')); loadTestFile(require.resolve('./documents_count_fired')); loadTestFile(require.resolve('./group_by_fired')); diff --git a/x-pack/test/api_integration/deployment_agnostic/services/alerting_api.ts b/x-pack/test/api_integration/deployment_agnostic/services/alerting_api.ts index d6660581938fa..855d5bd3cdff8 100644 --- a/x-pack/test/api_integration/deployment_agnostic/services/alerting_api.ts +++ b/x-pack/test/api_integration/deployment_agnostic/services/alerting_api.ts @@ -942,14 +942,16 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide ruleId, expectedStatus, roleAuthc, + spaceId, }: { ruleId: string; expectedStatus: string; roleAuthc: RoleCredentials; + spaceId?: string; }) { return await retry.tryForTime(retryTimeout, async () => { const response = await supertestWithoutAuth - .get(`/api/alerting/rule/${ruleId}`) + .get(`${spaceId ? '/s/' + spaceId : ''}/api/alerting/rule/${ruleId}`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .timeout(requestTimeout); @@ -1034,13 +1036,15 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide name, indexName, roleAuthc, + spaceId, }: { name: string; indexName: string; roleAuthc: RoleCredentials; + spaceId?: string; }) { const { body } = await supertestWithoutAuth - .post(`/api/actions/connector`) + .post(`${spaceId ? '/s/' + spaceId : ''}/api/actions/connector`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ @@ -1063,6 +1067,7 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide schedule, consumer, roleAuthc, + spaceId, }: { ruleTypeId: string; name: string; @@ -1080,9 +1085,10 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide schedule?: { interval: string }; consumer: string; roleAuthc: RoleCredentials; + spaceId?: string; }) { const { body } = await supertestWithoutAuth - .post(`/api/alerting/rule`) + .post(`${spaceId ? '/s/' + spaceId : ''}/api/alerting/rule`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ @@ -1118,17 +1124,17 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide }); }, - async findInRules(roleAuthc: RoleCredentials, ruleId: string) { + async findInRules(roleAuthc: RoleCredentials, ruleId: string, spaceId?: string) { const response = await supertestWithoutAuth - .get('/api/alerting/rules/_find') + .get(`${spaceId ? '/s/' + spaceId : ''}/api/alerting/rules/_find`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()); return response.body.data.find((obj: any) => obj.id === ruleId); }, - async searchRules(roleAuthc: RoleCredentials, filter: string) { + async searchRules(roleAuthc: RoleCredentials, filter: string, spaceId?: string) { return supertestWithoutAuth - .get('/api/alerting/rules/_find') + .get(`${spaceId ? '/s/' + spaceId : ''}/api/alerting/rules/_find`) .query({ filter }) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()); diff --git a/x-pack/test/api_integration/deployment_agnostic/services/data_view_api.ts b/x-pack/test/api_integration/deployment_agnostic/services/data_view_api.ts index 33e829d8c9e39..6b03bdf46b273 100644 --- a/x-pack/test/api_integration/deployment_agnostic/services/data_view_api.ts +++ b/x-pack/test/api_integration/deployment_agnostic/services/data_view_api.ts @@ -18,14 +18,16 @@ export function DataViewApiProvider({ getService }: DeploymentAgnosticFtrProvide id, name, title, + spaceId, }: { roleAuthc: RoleCredentials; id: string; name: string; title: string; + spaceId?: string; }) { const { body } = await supertestWithoutAuth - .post(`/api/content_management/rpc/create`) + .post(`${spaceId ? '/s/' + spaceId : ''}/api/content_management/rpc/create`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .set(samlAuth.getCommonRequestHeader()) @@ -48,9 +50,17 @@ export function DataViewApiProvider({ getService }: DeploymentAgnosticFtrProvide return body; }, - async delete({ roleAuthc, id }: { roleAuthc: RoleCredentials; id: string }) { + async delete({ + roleAuthc, + id, + spaceId, + }: { + roleAuthc: RoleCredentials; + id: string; + spaceId?: string; + }) { const { body } = await supertestWithoutAuth - .post(`/api/content_management/rpc/delete`) + .post(`${spaceId ? '/s/' + spaceId : ''}/api/content_management/rpc/delete`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .set(samlAuth.getCommonRequestHeader()) diff --git a/x-pack/test/api_integration/deployment_agnostic/services/deployment_agnostic_services.ts b/x-pack/test/api_integration/deployment_agnostic/services/deployment_agnostic_services.ts index 9623df1bebbd0..08a085e2fcd9b 100644 --- a/x-pack/test/api_integration/deployment_agnostic/services/deployment_agnostic_services.ts +++ b/x-pack/test/api_integration/deployment_agnostic/services/deployment_agnostic_services.ts @@ -26,4 +26,5 @@ export const deploymentAgnosticServices = _.pick(apiIntegrationServices, [ 'retry', 'security', 'usageAPI', + 'spaces', ]);