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 <maryam.saeidi@elastic.co>
This commit is contained in:
Anton Dosov 2024-12-11 10:39:01 +01:00 committed by GitHub
parent 4511abe03e
commit f1f3a4fddd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 421 additions and 113 deletions

View file

@ -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()', () => {

View file

@ -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<P extends SerializableRecord> implements LocatorPublic<P> {
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<P extends SerializableRecord> implements LocatorPublic<P> {
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(

View file

@ -10,3 +10,4 @@
export * from './types';
export * from './format_search_params';
export * from './parse_search_params';
export * from './space_url_parser';

View file

@ -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 /"`);
});
});

View file

@ -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}` || '/';
}

View file

@ -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<P extends SerializableRecord = unknown & Serial
/** Locator params. */
params: P;
}
export interface GetRedirectUrlOptions extends FormatSearchParamsOptions {
/**
* Optional space ID to use when generating the URL.
* If not provided:
* - on the client the current space ID will be used.
* - on the server the URL will be generated without a space ID.
*/
spaceId?: string;
}

View file

@ -15,7 +15,7 @@ import {
PersistableStateService,
VersionedState,
} from '@kbn/kibana-utils-plugin/common';
import type { FormatSearchParamsOptions } from './redirect';
import type { GetRedirectUrlOptions } from './redirect';
/**
* URL locator registry.
@ -88,7 +88,7 @@ export interface LocatorPublic<P extends SerializableRecord> 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

View file

@ -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"

View file

@ -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,

View file

@ -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 }
);
});
});

View file

@ -22,6 +22,7 @@ export interface GetViewInAppUrlArgs {
logsExplorerLocator?: LocatorPublic<LogsExplorerLocatorParams>;
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 }
);
};

View file

@ -38,6 +38,7 @@ const initialRuleState: TestRuleState = {
};
const fakeLogger = <Meta extends LogMeta = LogMeta>(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,

View file

@ -285,6 +285,7 @@ export const createCustomThresholdExecutor = ({
metrics: alertResults.length === 1 ? alertResults[0][group].metrics : [],
searchConfiguration: params.searchConfiguration,
startedAt: indexedStartedAt,
spaceId,
}),
...additionalContext,
},

View file

@ -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<LogsExplorerLocatorParsedParams>(
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({

View file

@ -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<LogsExplorerLocatorParsedParams>(
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);
});
});
});

View file

@ -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'));

View file

@ -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());

View file

@ -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())

View file

@ -26,4 +26,5 @@ export const deploymentAgnosticServices = _.pick(apiIntegrationServices, [
'retry',
'security',
'usageAPI',
'spaces',
]);