Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
afdf505
add rule doctor page and flags
dominiqueclarke Apr 23, 2026
1a881bc
add rule doctor index
dominiqueclarke Apr 23, 2026
214234b
adjust settings
dominiqueclarke Apr 23, 2026
c744e40
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Apr 23, 2026
7b3cbcb
automatic cleaning of documents
dominiqueclarke Apr 24, 2026
47ed058
Merge branch 'feat/rule-doctor-page-and-flags' of https://github.com/…
dominiqueclarke Apr 24, 2026
7bb3a01
add rule doctor indices
dominiqueclarke Apr 24, 2026
8885e13
merge main
dominiqueclarke Apr 28, 2026
e506602
adjust terminology
dominiqueclarke Apr 28, 2026
7f0be25
adjust types and remove unnecessary code
dominiqueclarke Apr 29, 2026
ce53f8c
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Apr 29, 2026
c78f916
adjust schemas
dominiqueclarke Apr 29, 2026
f127fc8
merge origin
dominiqueclarke Apr 29, 2026
b243bf2
add initial rule doctor run api with deduplication workflow
dominiqueclarke Apr 30, 2026
3ccdbe3
merge main
dominiqueclarke Apr 30, 2026
5ff485a
Changes from node scripts/lint_ts_projects --fix
kibanamachine Apr 30, 2026
50aa327
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Apr 30, 2026
3cb178e
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Apr 30, 2026
34abf2f
tighten insight schema: make rule_ids, current, proposed required; ke…
dominiqueclarke Apr 30, 2026
50951c9
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Apr 30, 2026
58f8add
adjust types
dominiqueclarke Apr 30, 2026
4ab3b50
Merge branch 'feat/rule-doctor-execution' of https://github.com/domin…
dominiqueclarke Apr 30, 2026
368957b
adjust tests
dominiqueclarke May 4, 2026
a9c56fb
merge upstream
dominiqueclarke May 4, 2026
88ed922
adjust tests
dominiqueclarke May 4, 2026
64c0ba7
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine May 4, 2026
2d0682d
address test feedback
dominiqueclarke May 4, 2026
81d32c6
Merge branch 'feat/rule-doctor-execution' of https://github.com/domin…
dominiqueclarke May 4, 2026
3e6da64
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine May 4, 2026
aa5d989
merge upstream
dominiqueclarke May 4, 2026
c76ef8d
Merge branch 'feat/rule-doctor-execution' of https://github.com/domin…
dominiqueclarke May 4, 2026
d9fb6b4
update scout tests
dominiqueclarke May 5, 2026
30a57be
adjust scout tests
dominiqueclarke May 5, 2026
f6e74de
merge upstream
dominiqueclarke May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export const ALERTING_V2_INTERNAL_SUGGEST_USER_PROFILES_API_PATH =
'/api/alerting/v2/internal/user_profiles/_suggest' as const;
export const ALERTING_V2_RULE_DOCTOR_INSIGHTS_API_PATH =
'/api/alerting/v2/rule_doctor/insights' as const;
export const ALERTING_V2_RULE_DOCTOR_RUN_API_PATH = '/api/alerting/v2/rule_doctor/run' as const;
1 change: 1 addition & 0 deletions x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"security",
"encryptedSavedObjects",
"workflowsManagement",
"workflowsExtensions",
"expressions",
"uiActions",
"fieldFormats",
Expand Down
6 changes: 6 additions & 0 deletions x-pack/platform/plugins/shared/alerting_v2/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ dependsOn:
- '@kbn/unified-doc-viewer-plugin'
- '@kbn/core-user-profile-browser'
- '@kbn/user-profile-components'
- '@kbn/core-logging-server-mocks'
- '@kbn/core-ui-settings-server-mocks'
- '@kbn/core-saved-objects-server-mocks'
- '@kbn/core-ui-settings-server'
- '@kbn/management-settings-ids'
- '@kbn/workflows-extensions'
- '@kbn/esql-types'
- '@kbn/agent-builder-plugin'
- '@kbn/agent-builder-server'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@

import type { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks';

import {
RULE_DOCTOR_INSIGHTS_INDEX,
type RuleDoctorInsightDoc,
} from '../../resources/indices/rule_doctor_insights';
import type { LoggerServiceContract } from '../services/logger_service/logger_service';
import { createMockResourceManager } from '../services/resource_service/resource_manager.mock';
import { RuleDoctorInsightsClient } from './rule_doctor_insights_client';

const mockLoggerService: jest.Mocked<LoggerServiceContract> = {
Expand All @@ -37,8 +39,8 @@ const makeInsight = (overrides: Partial<RuleDoctorInsightDoc> = {}): RuleDoctorI
justification: '',
rule_ids: ['rule-1', 'rule-2'],
data: {},
current: null,
proposed: null,
current: {},
proposed: {},
space_id: 'default',
...overrides,
});
Expand Down Expand Up @@ -260,4 +262,31 @@ describe('RuleDoctorInsightsClient', () => {
);
});
});

describe('ensureIndex', () => {
it('calls ensureResourceRegistered when resourceManager is provided', async () => {
const resourceManager = createMockResourceManager();
resourceManager.ensureResourceRegistered.mockResolvedValue(undefined);
const rawLogger = loggingSystemMock.createLogger();

const clientWithRM = new RuleDoctorInsightsClient(
esClient,
mockLoggerService,
resourceManager,
rawLogger
);
await clientWithRM.ensureIndex();

expect(resourceManager.ensureResourceRegistered).toHaveBeenCalledWith(
`index:${RULE_DOCTOR_INSIGHTS_INDEX}`,
expect.objectContaining({ initialize: expect.any(Function) }),
{ optional: true }
);
});

it('is a no-op when resourceManager is not provided', async () => {
const clientWithoutRM = new RuleDoctorInsightsClient(esClient, mockLoggerService);
await expect(clientWithoutRM.ensureIndex()).resolves.toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,47 @@

import Boom from '@hapi/boom';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import { inject, injectable } from 'inversify';
import type { LoggerServiceContract } from '../services/logger_service/logger_service';
import { LoggerServiceToken } from '../services/logger_service/logger_service';
import type { ResourceManagerContract } from '../services/resource_service/resource_manager';
import { IndexInitializer } from '../services/resource_service/index_initializer';
import {
RULE_DOCTOR_INSIGHTS_INDEX,
getRuleDoctorInsightsResourceDefinition,
ruleDoctorInsightStatus,
type RuleDoctorInsightDoc,
type RuleDoctorInsightStatus,
} from '../../resources/indices/rule_doctor_insights';
import type { ListInsightsParams, ListInsightsResult, BulkIndexInsightsResult } from './types';
import type {
ListInsightsParams,
ListInsightsResult,
BulkIndexInsightsResult,
BulkDismissInsightsResult,
PersistFindingsResult,
} from './types';

const DEFAULT_PAGE_SIZE = 20;

@injectable()
export class RuleDoctorInsightsClient {
constructor(
private readonly esClient: ElasticsearchClient,
@inject(LoggerServiceToken) private readonly logger: LoggerServiceContract
@inject(LoggerServiceToken) private readonly logger: LoggerServiceContract,
private readonly resourceManager?: ResourceManagerContract,
private readonly rawLogger?: Logger
Comment on lines +38 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are they optional? and why are they not @injected ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered myself below

) {}

public async ensureIndex(): Promise<void> {
if (!this.resourceManager || !this.rawLogger) {
return;
}
const def = getRuleDoctorInsightsResourceDefinition();
const initializer = new IndexInitializer(this.rawLogger, this.esClient, def);
await this.resourceManager.ensureResourceRegistered(def.key, initializer, { optional: true });
}

public async listInsights(params: ListInsightsParams): Promise<ListInsightsResult> {
const { from = 0, size = DEFAULT_PAGE_SIZE } = params;

Expand Down Expand Up @@ -140,6 +161,58 @@ export class RuleDoctorInsightsClient {
return { indexed, failed };
}

public async bulkDismissInsights(
insightIds: string[],
spaceId: string
): Promise<BulkDismissInsightsResult> {
if (insightIds.length === 0) {
return { dismissed: 0, failed: 0 };
}

const operations = insightIds.flatMap((insightId) => [
{ update: { _index: RULE_DOCTOR_INSIGHTS_INDEX, _id: `${spaceId}:${insightId}` } },
{ doc: { status: ruleDoctorInsightStatus.dismissed } },
]);

const response = await this.esClient.bulk({ operations, refresh: 'wait_for' });

const failed = response.items.filter((item) => item.update?.error).length;
const dismissed = insightIds.length - failed;

if (response.errors) {
this.logger.warn({
message: `RuleDoctorInsightsClient: failed to dismiss ${failed} insights`,
});
}

this.logger.debug({
message: `RuleDoctorInsightsClient: dismissed ${dismissed} insights (${failed} failed)`,
});

return { dismissed, failed };
}

public async persistFindings(params: {
insights: RuleDoctorInsightDoc[];
dismissIds: string[];
spaceId: string;
}): Promise<PersistFindingsResult> {
const { insights, dismissIds, spaceId } = params;

const indexResult = await this.bulkIndexInsights(insights);
const dismissResult = await this.bulkDismissInsights(dismissIds, spaceId);

this.logger.debug({
message: `RuleDoctorInsightsClient: persisted ${indexResult.indexed} insights (${indexResult.failed} failed), dismissed ${dismissResult.dismissed}`,
});

return {
indexed: indexResult.indexed,
failed: indexResult.failed + dismissResult.failed,
dismissed: dismissResult.dismissed,
};
}

private buildFilterQuery(params: ListInsightsParams): QueryDslQueryContainer {
const filters: QueryDslQueryContainer[] = [{ term: { space_id: params.spaceId } }];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,14 @@ export interface BulkIndexInsightsResult {
indexed: number;
failed: number;
}

export interface BulkDismissInsightsResult {
dismissed: number;
failed: number;
}

export interface PersistFindingsResult {
indexed: number;
failed: number;
dismissed: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export function createMockResourceManager() {
registerResource: jest.fn(),
startInitialization: jest.fn(),
waitUntilReady: jest.fn(),
isRegistered: jest.fn(),
isReady: jest.fn(),
ensureResourceReady: jest.fn(),
ensureResourceRegistered: jest.fn(),
} satisfies ResourceManagerMock;

return resourceManager;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,45 @@ describe('ResourceManager', () => {
}
});

it('isRegistered returns true for registered resources and false for unknown keys', () => {
const manager = createManager();
const init = createInitializer(async () => {});

expect(manager.isRegistered('r1')).toBe(false);

manager.registerResource('r1', init);
expect(manager.isRegistered('r1')).toBe(true);
expect(manager.isRegistered('unknown')).toBe(false);
});

describe('ensureResourceRegistered', () => {
it('registers and initializes a new resource', async () => {
const manager = createManager();
const init = createInitializer(async () => {});

await manager.ensureResourceRegistered('lazy', init);

expect(manager.isRegistered('lazy')).toBe(true);
expect(manager.isReady('lazy')).toBe(true);
expect(init.initialize).toHaveBeenCalledTimes(1);
});

it('skips registration when the resource already exists', async () => {
const manager = createManager();
const first = createInitializer(async () => {});
const second = createInitializer(async () => {});

manager.registerResource('r1', first);
manager.startInitialization();
await manager.waitUntilReady();

await manager.ensureResourceRegistered('r1', second);

expect(first.initialize).toHaveBeenCalledTimes(1);
expect(second.initialize).not.toHaveBeenCalled();
});
});

it('throws if ensureResourceReady is called for an unregistered resource', async () => {
const manager = createManager();
await expect(manager.ensureResourceReady('missing')).rejects.toThrow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ export interface ResourceManagerContract {
): void;
startInitialization(options?: { resourceKeys?: string[] }): void;
waitUntilReady(): Promise<void>;
isRegistered(key: string): boolean;
isReady(key: string): boolean;
ensureResourceReady(key: string): Promise<void>;
ensureResourceRegistered(
key: string,
initializer: IResourceInitializer,
options?: RegisterResourceOptions
): Promise<void>;
}

@injectable()
Expand Down Expand Up @@ -132,6 +138,10 @@ export class ResourceManager implements ResourceManagerContract {
}
}

public isRegistered(key: string): boolean {
return this.resources.has(key);
}

public isReady(key: string): boolean {
return this.resources.get(key)?.status === 'ready';
}
Expand All @@ -145,6 +155,23 @@ export class ResourceManager implements ResourceManagerContract {
await this.initResource(key);
}

/**
* Register a resource if not already registered, then ensure it is ready.
*
* Safe to call from concurrent requests: only the first caller registers;
* subsequent callers coalesce on the existing initialization promise.
*/
public async ensureResourceRegistered(
key: string,
initializer: IResourceInitializer,
options?: RegisterResourceOptions
): Promise<void> {
if (!this.resources.has(key)) {
this.registerResource(key, initializer, options);
}
await this.ensureResourceReady(key);
}

private async initResource(key: string): Promise<void> {
const state = this.resources.get(key);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,17 @@ export const ruleDoctorInsightDocSchema = z.object({
justification: z
.string()
.describe('Reasoning for why this insight was raised and the proposed action'),
rule_ids: z
.array(z.string())
.optional()
.default([])
.describe('IDs of the alerting rules involved'),
rule_ids: z.array(z.string()).default([]).describe('IDs of the alerting rules involved'),
data: z
.record(z.string(), z.any())
.optional()
.describe('Arbitrary structured data supporting the insight'),
current: z
.record(z.string(), z.unknown())
.nullable()
.describe('Current rule configuration snapshot'),
current: z.record(z.string(), z.unknown()).describe('Current rule configuration snapshot'),
proposed: z
.record(z.string(), z.unknown())
.nullable()
.describe('Proposed rule configuration after applying the action'),
.describe(
'Object keyed by rule_id with post-action config for each rule (null for deleted rules)'
),
diffs: z
.array(
z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export {
ALERTING_V2_MATCHER_VALUE_SUGGESTIONS_API_PATH,
ALERTING_V2_INTERNAL_SUGGEST_USER_PROFILES_API_PATH,
ALERTING_V2_RULE_DOCTOR_INSIGHTS_API_PATH,
ALERTING_V2_RULE_DOCTOR_RUN_API_PATH,
} from '@kbn/alerting-v2-constants';
Loading
Loading