diff --git a/x-pack/plugins/ml/common/types/kibana.ts b/x-pack/plugins/ml/common/types/kibana.ts index cbdc09fa01be3..7783a02c2dd37 100644 --- a/x-pack/plugins/ml/common/types/kibana.ts +++ b/x-pack/plugins/ml/common/types/kibana.ts @@ -7,8 +7,9 @@ // custom edits or fixes for default kibana types which are incomplete -import { SimpleSavedObject } from 'kibana/public'; -import { IndexPatternAttributes } from 'src/plugins/data/common'; +import type { SimpleSavedObject } from 'kibana/public'; +import type { IndexPatternAttributes } from 'src/plugins/data/common'; +import type { FieldFormatsRegistry } from '../../../../../src/plugins/field_formats/common'; export type IndexPatternTitle = string; @@ -26,3 +27,5 @@ export function isSavedSearchSavedObject( ): ss is SavedSearchSavedObject { return ss !== null; } + +export type FieldFormatsRegistryProvider = () => Promise; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 1196247fe4629..310ac5d65c986 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -17,7 +17,8 @@ "uiActions", "kibanaLegacy", "discover", - "triggersActionsUi" + "triggersActionsUi", + "fieldFormats" ], "optionalPlugins": [ "alerting", diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts index a5f433bcc3752..68a06919d03a3 100644 --- a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts @@ -87,7 +87,7 @@ export function registerJobsHealthAlertingRule( defaultActionMessage: i18n.translate( 'xpack.ml.alertTypes.jobsHealthAlertingRule.defaultActionMessage', { - defaultMessage: `Anomaly detection jobs health check result: + defaultMessage: `[\\{\\{rule.name\\}\\}] Anomaly detection jobs health check result: \\{\\{context.message\\}\\} \\{\\{#context.results\\}\\} Job ID: \\{\\{job_id\\}\\} diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts index ffaa26fc949ee..7192b9a919379 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts @@ -14,6 +14,7 @@ import { AnnotationService } from '../../models/annotation_service/annotation'; import { JobsHealthExecutorOptions } from './register_jobs_monitoring_rule_type'; import { JobAuditMessagesService } from '../../models/job_audit_messages/job_audit_messages'; import { DeepPartial } from '../../../common/types/common'; +import { FieldFormatsRegistryProvider } from '../../../common/types/kibana'; const MOCK_DATE_NOW = 1487076708000; @@ -148,8 +149,8 @@ describe('JobsHealthService', () => { } as unknown) as jest.Mocked; const jobAuditMessagesService = ({ - getJobsErrors: jest.fn().mockImplementation((jobIds: string) => { - return Promise.resolve({}); + getJobsErrorMessages: jest.fn().mockImplementation((jobIds: string) => { + return Promise.resolve([]); }), } as unknown) as jest.Mocked; @@ -159,11 +160,24 @@ describe('JobsHealthService', () => { debug: jest.fn(), } as unknown) as jest.Mocked; + const getFieldsFormatRegistry = jest.fn().mockImplementation(() => { + return Promise.resolve({ + deserialize: jest.fn().mockImplementation(() => { + return { + convert: jest.fn().mockImplementation((v) => { + return new Date(v).toUTCString(); + }), + }; + }), + }); + }) as jest.Mocked; + const jobHealthService: JobsHealthService = jobsHealthServiceProvider( mlClient, datafeedsService, annotationService, jobAuditMessagesService, + getFieldsFormatRegistry, logger ); @@ -275,11 +289,11 @@ describe('JobsHealthService', () => { job_id: 'test_job_01', annotation: 'Datafeed has missed 11 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 11, }, ], - message: '1 job is suffering from delayed data.', + message: 'Job test_job_01 is suffering from delayed data.', }, }, ]); @@ -333,7 +347,7 @@ describe('JobsHealthService', () => { datafeed_state: 'stopped', }, ], - message: 'Datafeed is not started for the following jobs:', + message: 'Datafeed is not started for job test_job_02', }, }, { @@ -342,12 +356,12 @@ describe('JobsHealthService', () => { results: [ { job_id: 'test_job_01', - log_time: 1626935914540, + log_time: 'Thu, 22 Jul 2021 06:38:34 GMT', memory_status: 'hard_limit', }, ], message: - '1 job reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', + 'Job test_job_01 reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', }, }, { @@ -358,18 +372,18 @@ describe('JobsHealthService', () => { job_id: 'test_job_01', annotation: 'Datafeed has missed 11 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 11, }, { job_id: 'test_job_02', annotation: 'Datafeed has missed 8 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 8, }, ], - message: '2 jobs are suffering from delayed data.', + message: 'Jobs test_job_01, test_job_02 are suffering from delayed data.', }, }, ]); diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts index bcae57e558573..ca63031f02e27 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { memoize, keyBy } from 'lodash'; -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { groupBy, keyBy, memoize } from 'lodash'; +import { KibanaRequest, Logger, SavedObjectsClientContract } from 'kibana/server'; import { i18n } from '@kbn/i18n'; -import { Logger } from 'kibana/server'; import { MlJob } from '@elastic/elasticsearch/api/types'; import { MlClient } from '../ml_client'; import { JobSelection } from '../../routes/schemas/alerting_schema'; @@ -19,6 +18,7 @@ import { GetGuards } from '../../shared_services/shared_services'; import { AnomalyDetectionJobsHealthAlertContext, DelayedDataResponse, + JobsErrorsResponse, JobsHealthExecutorOptions, MmlTestResponse, NotStartedDatafeedResponse, @@ -35,6 +35,7 @@ import { jobAuditMessagesProvider, JobAuditMessagesService, } from '../../models/job_audit_messages/job_audit_messages'; +import type { FieldFormatsRegistryProvider } from '../../../common/types/kibana'; interface TestResult { name: string; @@ -48,8 +49,18 @@ export function jobsHealthServiceProvider( datafeedsService: DatafeedsService, annotationService: AnnotationService, jobAuditMessagesService: JobAuditMessagesService, + getFieldsFormatRegistry: FieldFormatsRegistryProvider, logger: Logger ) { + /** + * Provides a callback for date formatting based on the Kibana settings. + */ + const getDateFormatter = memoize(async () => { + const fieldFormatsRegistry = await getFieldsFormatRegistry(); + const dateFormatter = fieldFormatsRegistry.deserialize({ id: 'date' }); + return dateFormatter.convert.bind(dateFormatter); + }); + /** * Extracts result list of jobs based on included and excluded selection of jobs and groups. * @param includeJobs @@ -121,6 +132,17 @@ export function jobsHealthServiceProvider( async (jobIds: string[]) => (await mlClient.getJobStats({ job_id: jobIds.join(',') })).body.jobs ); + /** Gets values for translation string */ + const getJobsAlertingMessageValues = >( + results: T + ) => { + const jobIds = (results || []).filter(isDefined).map((v) => v.job_id); + return { + count: jobIds.length, + jobsString: jobIds.join(', '), + }; + }; + return { /** * Gets not started datafeeds for opened jobs. @@ -164,13 +186,15 @@ export function jobsHealthServiceProvider( async getMmlReport(jobIds: string[]): Promise { const jobsStats = await getJobStats(jobIds); + const dateFormatter = await getDateFormatter(); + return jobsStats .filter((j) => j.state === 'opened' && j.model_size_stats.memory_status !== 'ok') .map(({ job_id: jobId, model_size_stats: modelSizeStats }) => { return { job_id: jobId, memory_status: modelSizeStats.memory_status, - log_time: modelSizeStats.log_time, + log_time: dateFormatter(modelSizeStats.log_time), model_bytes: modelSizeStats.model_bytes, model_bytes_memory_limit: modelSizeStats.model_bytes_memory_limit, peak_model_bytes: modelSizeStats.peak_model_bytes, @@ -203,13 +227,15 @@ export function jobsHealthServiceProvider( const defaultLookbackInterval = resolveLookbackInterval(resultJobs, datafeeds!); const earliestMs = getDelayedDataLookbackTimestamp(timeInterval, defaultLookbackInterval); - const annotations: DelayedDataResponse[] = ( + const getFormattedDate = await getDateFormatter(); + + return ( await annotationService.getDelayedDataAnnotations({ jobIds: resultJobIds, earliestMs, }) ) - .map((v) => { + .map((v) => { const match = v.annotation.match(/Datafeed has missed (\d+)\s/); const missedDocsCount = match ? parseInt(match[1], 10) : 0; return { @@ -235,9 +261,13 @@ export function jobsHealthServiceProvider( v.end_timestamp > getDelayedDataLookbackTimestamp(timeInterval, jobLookbackInterval); return isDocCountExceededThreshold && isEndTimestampWithinRange; + }) + .map((v) => { + return { + ...v, + end_timestamp: getFormattedDate(v.end_timestamp), + }; }); - - return annotations; }, /** * Retrieves a list of the latest errors per jobs. @@ -245,8 +275,25 @@ export function jobsHealthServiceProvider( * @param previousStartedAt Time of the previous rule execution. As we intend to notify * about an error only once, limit the scope of the errors search. */ - async getErrorsReport(jobIds: string[], previousStartedAt: Date) { - return await jobAuditMessagesService.getJobsErrors(jobIds, previousStartedAt.getTime()); + async getErrorsReport( + jobIds: string[], + previousStartedAt: Date + ): Promise { + const getFormattedDate = await getDateFormatter(); + + return ( + await jobAuditMessagesService.getJobsErrorMessages(jobIds, previousStartedAt.getTime()) + ).map((v) => { + return { + ...v, + errors: v.errors.map((e) => { + return { + ...e, + timestamp: getFormattedDate(e.timestamp), + }; + }), + }; + }); }, /** * Retrieves report grouped by test. @@ -275,6 +322,7 @@ export function jobsHealthServiceProvider( if (config.datafeed.enabled) { const response = await this.getNotStartedDatafeeds(jobIds); if (response && response.length > 0) { + const { count, jobsString } = getJobsAlertingMessageValues(response); results.push({ name: HEALTH_CHECK_NAMES.datafeed.name, context: { @@ -282,7 +330,9 @@ export function jobsHealthServiceProvider( message: i18n.translate( 'xpack.ml.alertTypes.jobsHealthAlertingRule.datafeedStateMessage', { - defaultMessage: 'Datafeed is not started for the following jobs:', + defaultMessage: + 'Datafeed is not started for {count, plural, one {job} other {jobs}} {jobsString}', + values: { count, jobsString }, } ), }, @@ -293,32 +343,54 @@ export function jobsHealthServiceProvider( if (config.mml.enabled) { const response = await this.getMmlReport(jobIds); if (response && response.length > 0) { - const hardLimitJobsCount = response.reduce((acc, curr) => { - return acc + (curr.memory_status === 'hard_limit' ? 1 : 0); - }, 0); + const { hard_limit: hardLimitJobs, soft_limit: softLimitJobs } = groupBy( + response, + 'memory_status' + ); + + const { + count: hardLimitCount, + jobsString: hardLimitJobsString, + } = getJobsAlertingMessageValues(hardLimitJobs); + const { + count: softLimitCount, + jobsString: softLimitJobsString, + } = getJobsAlertingMessageValues(softLimitJobs); + + let message = ''; + + if (hardLimitCount > 0) { + message = i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.mmlMessage', { + defaultMessage: `{count, plural, one {Job} other {Jobs}} {jobsString} reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.`, + values: { + count: hardLimitCount, + jobsString: hardLimitJobsString, + }, + }); + } + + if (softLimitCount > 0) { + if (message.length > 0) { + message += '\n'; + } + message += i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlSoftLimitMessage', + { + defaultMessage: + '{count, plural, one {Job} other {Jobs}} {jobsString} reached the soft model memory limit. Assign the job more memory or edit the datafeed filter to limit scope of analysis.', + values: { + count: softLimitCount, + jobsString: softLimitJobsString, + }, + } + ); + } results.push({ name: HEALTH_CHECK_NAMES.mml.name, context: { results: response, - message: - hardLimitJobsCount > 0 - ? i18n.translate( - 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlHardLimitMessage', - { - defaultMessage: - '{jobsCount, plural, one {# job} other {# jobs}} reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', - values: { jobsCount: hardLimitJobsCount }, - } - ) - : i18n.translate( - 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlSoftLimitMessage', - { - defaultMessage: - '{jobsCount, plural, one {# job} other {# jobs}} reached the soft model memory limit. Assign the job more memory or edit the datafeed filter to limit scope of analysis.', - values: { jobsCount: response.length }, - } - ), + message, }, }); } @@ -331,6 +403,8 @@ export function jobsHealthServiceProvider( config.delayedData.docsCount ); + const { count, jobsString } = getJobsAlertingMessageValues(response); + if (response.length > 0) { results.push({ name: HEALTH_CHECK_NAMES.delayedData.name, @@ -340,8 +414,8 @@ export function jobsHealthServiceProvider( 'xpack.ml.alertTypes.jobsHealthAlertingRule.delayedDataMessage', { defaultMessage: - '{jobsCount, plural, one {# job is} other {# jobs are}} suffering from delayed data.', - values: { jobsCount: response.length }, + '{count, plural, one {Job} other {Jobs}} {jobsString} {count, plural, one {is} other {are}} suffering from delayed data.', + values: { count, jobsString }, } ), }, @@ -352,6 +426,7 @@ export function jobsHealthServiceProvider( if (config.errorMessages.enabled && previousStartedAt) { const response = await this.getErrorsReport(jobIds, previousStartedAt); if (response.length > 0) { + const { count, jobsString } = getJobsAlertingMessageValues(response); results.push({ name: HEALTH_CHECK_NAMES.errorMessages.name, context: { @@ -360,8 +435,8 @@ export function jobsHealthServiceProvider( 'xpack.ml.alertTypes.jobsHealthAlertingRule.errorMessagesMessage', { defaultMessage: - '{jobsCount, plural, one {# job contains} other {# jobs contain}} errors in the messages.', - values: { jobsCount: response.length }, + '{count, plural, one {Job} other {Jobs}} {jobsString} {count, plural, one {contains} other {contain}} errors in the messages.', + values: { count, jobsString }, } ), }, @@ -390,12 +465,13 @@ export function getJobsHealthServiceProvider(getGuards: GetGuards) { return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(({ mlClient, scopedClient }) => + .ok(({ mlClient, scopedClient, getFieldsFormatRegistry }) => jobsHealthServiceProvider( mlClient, datafeedsProvider(scopedClient, mlClient), annotationServiceProvider(scopedClient), jobAuditMessagesProvider(scopedClient, mlClient), + getFieldsFormatRegistry, logger ).getTestsResults(...args) ); diff --git a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts index c49c169d3bd21..4844bf1a94707 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts @@ -22,8 +22,8 @@ import { AlertInstanceState, AlertTypeState, } from '../../../../alerting/common'; -import { JobsErrorsResponse } from '../../models/job_audit_messages/job_audit_messages'; -import { AlertExecutorOptions } from '../../../../alerting/server'; +import type { AlertExecutorOptions } from '../../../../alerting/server'; +import type { JobMessage } from '../../../common/types/audit_message'; type ModelSizeStats = MlJobStats['model_size_stats']; @@ -51,14 +51,19 @@ export interface DelayedDataResponse { /** Number of missed documents */ missed_docs_count: number; /** Timestamp of the latest finalized bucket with missing docs */ - end_timestamp: number; + end_timestamp: string; +} + +export interface JobsErrorsResponse { + job_id: string; + errors: Array & { timestamp: string }>; } export type AnomalyDetectionJobHealthResult = | MmlTestResponse | NotStartedDatafeedResponse | DelayedDataResponse - | JobsErrorsResponse[number]; + | JobsErrorsResponse; export type AnomalyDetectionJobsHealthAlertContext = { results: AnomalyDetectionJobHealthResult[]; @@ -143,7 +148,7 @@ export function registerJobsMonitoringRuleType({ const executionResult = await getTestsResults(options); if (executionResult.length > 0) { - logger.info( + logger.debug( `"${name}" rule is scheduling actions for tests: ${executionResult .map((v) => v.name) .join(', ')}` diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index fcda1a2a3ea73..69f5c8b36f10c 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -411,7 +411,10 @@ export function jobAuditMessagesProvider( * Retrieve list of errors per job. * @param jobIds */ - async function getJobsErrors(jobIds: string[], earliestMs?: number): Promise { + async function getJobsErrorMessages( + jobIds: string[], + earliestMs?: number + ): Promise { const { body } = await asInternalUser.search({ index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, @@ -471,6 +474,6 @@ export function jobAuditMessagesProvider( getJobAuditMessages, getAuditMessagesSummary, clearJobAuditMessages, - getJobsErrors, + getJobsErrorMessages, }; } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 35f66e86b955a..4dea3cc072ca5 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -17,6 +17,7 @@ import { IClusterClient, SavedObjectsServiceStart, SharedGlobalConfig, + UiSettingsServiceStart, } from 'kibana/server'; import type { SecurityPluginSetup } from '../../security/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -60,6 +61,7 @@ import { registerMlAlerts } from './lib/alerts/register_ml_alerts'; import { ML_ALERT_TYPES } from '../common/constants/alerts'; import { alertingRoutes } from './routes/alerting'; import { registerCollector } from './usage'; +import { FieldFormatsStart } from '../../../../src/plugins/field_formats/server'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; @@ -70,6 +72,8 @@ export class MlServerPlugin private mlLicense: MlLicense; private capabilities: CapabilitiesStart | null = null; private clusterClient: IClusterClient | null = null; + private fieldsFormat: FieldFormatsStart | null = null; + private uiSettings: UiSettingsServiceStart | null = null; private savedObjectsStart: SavedObjectsServiceStart | null = null; private spacesPlugin: SpacesPluginSetup | undefined; private security: SecurityPluginSetup | undefined; @@ -204,6 +208,8 @@ export class MlServerPlugin resolveMlCapabilities, () => this.clusterClient, () => getInternalSavedObjectsClient(), + () => this.uiSettings, + () => this.fieldsFormat, () => this.isMlReady ); @@ -223,7 +229,9 @@ export class MlServerPlugin return sharedServicesProviders; } - public start(coreStart: CoreStart): MlPluginStart { + public start(coreStart: CoreStart, plugins: PluginsStart): MlPluginStart { + this.uiSettings = coreStart.uiSettings; + this.fieldsFormat = plugins.fieldFormats; this.capabilities = coreStart.capabilities; this.clusterClient = coreStart.elasticsearch.client; this.savedObjectsStart = coreStart.savedObjects; diff --git a/x-pack/plugins/ml/server/shared_services/errors.test.ts b/x-pack/plugins/ml/server/shared_services/errors.test.ts new file mode 100644 index 0000000000000..727012595dff3 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/errors.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { + getCustomErrorClass, + MLClusterClientUninitialized, + MLUISettingsClientUninitialized, + MLFieldFormatRegistryUninitialized, +} from './errors'; + +describe('Custom errors', () => { + test('creates a custom error instance', () => { + const MLCustomError = getCustomErrorClass('MLCustomError'); + const errorInstance = new MLCustomError('farequote is not defined'); + expect(errorInstance.message).toBe('farequote is not defined'); + expect(errorInstance.name).toBe('MLCustomError'); + expect(errorInstance).toBeInstanceOf(MLCustomError); + // make sure that custom class extends Error + expect(errorInstance).toBeInstanceOf(Error); + }); + + test('MLClusterClientUninitialized', () => { + const errorInstance = new MLClusterClientUninitialized('cluster client is not initialized'); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLClusterClientUninitialized'); + expect(errorInstance).toBeInstanceOf(MLClusterClientUninitialized); + }); + + test('MLUISettingsClientUninitialized', () => { + const errorInstance = new MLUISettingsClientUninitialized('cluster client is not initialized'); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLUISettingsClientUninitialized'); + expect(errorInstance).toBeInstanceOf(MLUISettingsClientUninitialized); + }); + + test('MLFieldFormatRegistryUninitialized', () => { + const errorInstance = new MLFieldFormatRegistryUninitialized( + 'cluster client is not initialized' + ); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLFieldFormatRegistryUninitialized'); + expect(errorInstance).toBeInstanceOf(MLFieldFormatRegistryUninitialized); + }); +}); diff --git a/x-pack/plugins/ml/server/shared_services/errors.ts b/x-pack/plugins/ml/server/shared_services/errors.ts index 4b8e6625c5aef..39c629ad50f5f 100644 --- a/x-pack/plugins/ml/server/shared_services/errors.ts +++ b/x-pack/plugins/ml/server/shared_services/errors.ts @@ -5,9 +5,26 @@ * 2.0. */ -export class MLClusterClientUninitialized extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - } -} +export const getCustomErrorClass = (className: string) => { + const CustomError = class extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + // Override the error instance name + Object.defineProperty(this, 'name', { value: className }); + } + }; + // set class name dynamically + Object.defineProperty(CustomError, 'name', { value: className }); + return CustomError; +}; + +export const MLClusterClientUninitialized = getCustomErrorClass('MLClusterClientUninitialized'); + +export const MLUISettingsClientUninitialized = getCustomErrorClass( + 'MLUISettingsClientUninitialized' +); + +export const MLFieldFormatRegistryUninitialized = getCustomErrorClass( + 'MLFieldFormatRegistryUninitialized' +); diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 3766a48b0537d..5c8bbffe10aed 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { IClusterClient, IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { + IClusterClient, + IScopedClusterClient, + SavedObjectsClientContract, + UiSettingsServiceStart, +} from 'kibana/server'; import { SpacesPluginStart } from '../../../spaces/server'; import { KibanaRequest } from '../../.././../../src/core/server'; import { MlLicense } from '../../common/license'; @@ -23,7 +28,11 @@ import { } from './providers/anomaly_detectors'; import { ResolveMlCapabilities, MlCapabilitiesKey } from '../../common/types/capabilities'; import { hasMlCapabilitiesProvider, HasMlCapabilities } from '../lib/capabilities'; -import { MLClusterClientUninitialized } from './errors'; +import { + MLClusterClientUninitialized, + MLFieldFormatRegistryUninitialized, + MLUISettingsClientUninitialized, +} from './errors'; import { MlClient, getMlClient } from '../lib/ml_client'; import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; import { @@ -34,6 +43,8 @@ import { getJobsHealthServiceProvider, JobsHealthServiceProvider, } from '../lib/alerts/jobs_health_service'; +import type { FieldFormatsStart } from '../../../../../src/plugins/field_formats/server'; +import type { FieldFormatsRegistryProvider } from '../../common/types/kibana'; export type SharedServices = JobServiceProvider & AnomalyDetectorsProvider & @@ -64,6 +75,7 @@ interface OkParams { scopedClient: IScopedClusterClient; mlClient: MlClient; jobSavedObjectService: JobSavedObjectService; + getFieldsFormatRegistry: FieldFormatsRegistryProvider; } type OkCallback = (okParams: OkParams) => any; @@ -76,6 +88,8 @@ export function createSharedServices( resolveMlCapabilities: ResolveMlCapabilities, getClusterClient: () => IClusterClient | null, getInternalSavedObjectsClient: () => SavedObjectsClientContract | null, + getUiSettings: () => UiSettingsServiceStart | null, + getFieldsFormat: () => FieldFormatsStart | null, isMlReady: () => Promise ): { sharedServicesProviders: SharedServices; @@ -97,12 +111,18 @@ export function createSharedServices( internalSavedObjectsClient, authorization, getSpaces !== undefined, - isMlReady + isMlReady, + getUiSettings, + getFieldsFormat ); - const { hasMlCapabilities, scopedClient, mlClient, jobSavedObjectService } = getRequestItems( - request - ); + const { + hasMlCapabilities, + scopedClient, + mlClient, + jobSavedObjectService, + getFieldsFormatRegistry, + } = getRequestItems(request); const asyncGuards: Array> = []; const guards: Guards = { @@ -120,7 +140,7 @@ export function createSharedServices( }, async ok(callback: OkCallback) { await Promise.all(asyncGuards); - return callback({ scopedClient, mlClient, jobSavedObjectService }); + return callback({ scopedClient, mlClient, jobSavedObjectService, getFieldsFormatRegistry }); }, }; return guards; @@ -154,7 +174,9 @@ function getRequestItemsProvider( internalSavedObjectsClient: SavedObjectsClientContract, authorization: SecurityPluginSetup['authz'] | undefined, spaceEnabled: boolean, - isMlReady: () => Promise + isMlReady: () => Promise, + getUiSettings: () => UiSettingsServiceStart | null, + getFieldsFormat: () => FieldFormatsStart | null ) { return (request: KibanaRequest) => { const getHasMlCapabilities = hasMlCapabilitiesProvider(resolveMlCapabilities); @@ -177,6 +199,28 @@ function getRequestItemsProvider( throw new MLClusterClientUninitialized(`ML's cluster client has not been initialized`); } + const uiSettingsClient = getUiSettings()?.asScopedToClient(savedObjectsClient); + if (!uiSettingsClient) { + throw new MLUISettingsClientUninitialized(`ML's UI settings client has not been initialized`); + } + + const getFieldsFormatRegistry = async () => { + let fieldFormatRegistry; + try { + fieldFormatRegistry = await getFieldsFormat()!.fieldFormatServiceFactory(uiSettingsClient!); + } catch (e) { + // throw an custom error during the fieldFormatRegistry check + } + + if (!fieldFormatRegistry) { + throw new MLFieldFormatRegistryUninitialized( + `ML's field format registry has not been initialized` + ); + } + + return fieldFormatRegistry; + }; + if (request instanceof KibanaRequest) { hasMlCapabilities = getHasMlCapabilities(request); scopedClient = clusterClient.asScoped(request); @@ -190,6 +234,12 @@ function getRequestItemsProvider( }; mlClient = getMlClient(scopedClient, jobSavedObjectService); } - return { hasMlCapabilities, scopedClient, mlClient, jobSavedObjectService }; + return { + hasMlCapabilities, + scopedClient, + mlClient, + jobSavedObjectService, + getFieldsFormatRegistry, + }; }; } diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index b04b8d8601772..da83b03766af4 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -23,6 +23,10 @@ import type { PluginSetup as DataPluginSetup, PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; +import type { + FieldFormatsSetup, + FieldFormatsStart, +} from '../../../../src/plugins/field_formats/server'; export interface LicenseCheckResult { isAvailable: boolean; @@ -47,6 +51,7 @@ export interface SavedObjectsRouteDeps { export interface PluginsSetup { cloud: CloudSetup; data: DataPluginSetup; + fieldFormats: FieldFormatsSetup; features: FeaturesPluginSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; @@ -59,6 +64,7 @@ export interface PluginsSetup { export interface PluginsStart { data: DataPluginStart; + fieldFormats: FieldFormatsStart; spaces?: SpacesPluginStart; }