Skip to content
Merged
9 changes: 6 additions & 3 deletions x-pack/platform/plugins/shared/alerting_v2/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { CreateRuleRoute } from './routes/create_rule_route';
import { UpdateRuleRoute } from './routes/update_rule_route';
import { initializeRuleExecutorTaskDefinition } from './lib/rule_executor';
import { AlertingResourcesService } from './lib/services/alerting_resources_service';
import { LoggerService } from './lib/services/logger_service';
import { QueryServiceFactory } from './lib/services/query_service/query_service_factory';
import { StorageServiceFactory } from './lib/services/storage_service/storage_service_factory';
import { registerSavedObjects } from './saved_objects';
import type { AlertingServerStartDependencies } from './types';

Expand All @@ -27,16 +30,16 @@ export const config: PluginConfigDescriptor<PluginConfig> = {
};

export const module = new ContainerModule(({ bind }) => {
// Register HTTP routes via DI
bind(Route).toConstantValue(CreateRuleRoute);
bind(Route).toConstantValue(UpdateRuleRoute);

// Request-scoped rules client
bind(RulesClient).toSelf().inRequestScope();

// Singleton services
bind(AlertingRetryService).toSelf().inSingletonScope();
bind(AlertingResourcesService).toSelf().inSingletonScope();
bind(LoggerService).toSelf().inSingletonScope();
bind(QueryServiceFactory).toSelf().inSingletonScope();
bind(StorageServiceFactory).toSelf().inSingletonScope();

bind(OnSetup).toConstantValue((container) => {
const logger = container.get(Logger);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 type { Logger } from '@kbn/core/server';
import { loggerMock } from '@kbn/logging-mocks';
import { LoggerService } from './logger_service';

describe('LoggerService', () => {
let mockLogger: jest.Mocked<Logger>;
let loggerService: LoggerService;

beforeEach(() => {
mockLogger = loggerMock.create();
loggerService = new LoggerService(mockLogger);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('debug', () => {
it('should call logger.debug with the message', () => {
const message = 'Test debug message';

loggerService.debug({ message });

expect(mockLogger.debug).toHaveBeenCalledTimes(1);
expect(mockLogger.debug).toHaveBeenCalledWith(message);
});
});

describe('error', () => {
it('should call logger.error with error message and EcsError when only error is provided', () => {
const error = new Error('Test error');

loggerService.error({ error });

expect(mockLogger.error).toHaveBeenCalledTimes(1);
expect(mockLogger.error).toHaveBeenCalledWith(error.message, {
error: {
code: 'UNKNOWN_ERROR',
message: error.message,
stack_trace: error.stack,
type: 'Error',
},
});
});

it('should use the code and the type if provided', () => {
const error = new Error('Test error');
const code = 'CUSTOM_ERROR_CODE';
const type = 'CustomErrorType';

loggerService.error({ error, code, type });

expect(mockLogger.error).toHaveBeenCalledWith(error.message, {
error: {
code,
message: error.message,
stack_trace: error.stack,
type,
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 { inject, injectable } from 'inversify';
import type { Logger } from '@kbn/logging';
import { Logger as BaseLogger } from '@kbn/core-di';
import type { EcsError } from '@elastic/ecs';

interface DebugParams {
message: string;
}

interface InfoParams {
message: string;
}

interface WarnParams {
message: string;
}

interface ErrorParams {
error: Error;
code?: string;
type?: string;
}

@injectable()
export class LoggerService {
constructor(@inject(BaseLogger) private readonly logger: Logger) {}

public debug({ message }: DebugParams): void {
this.logger.debug(message);
}

public error({ error, code, type }: ErrorParams): void {
const ecsError = this.buildError({ error, code, type });
this.logger.error(error.message, {
error: ecsError,
});
}

public info({ message }: InfoParams): void {
this.logger.info(message);
}

public warn({ message }: WarnParams): void {
this.logger.warn(message);
}

private buildError({ error, code, type }: ErrorParams): EcsError {
return {
code: code ?? 'UNKNOWN_ERROR',
message: error.message,
stack_trace: error.stack,
type: type ?? 'Error',
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
* 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 { of, throwError } from 'rxjs';
import type { Logger } from '@kbn/core/server';
import type { IScopedSearchClient } from '@kbn/data-plugin/server';
import type { ESQLSearchResponse } from '@kbn/es-types';
import { loggerMock } from '@kbn/logging-mocks';
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
import { QueryService } from './query_service';
import { LoggerService } from '../logger_service';
import { httpServerMock } from '@kbn/core/server/mocks';

describe('QueryService', () => {
let mockSearchClient: jest.Mocked<IScopedSearchClient>;
let mockLogger: jest.Mocked<Logger>;
let mockLoggerService: LoggerService;
let esqlService: QueryService;

beforeEach(() => {
// @ts-expect-error - dataPluginMock is not typed correctly
mockSearchClient = dataPluginMock
.createStartContract()
.search.asScoped(httpServerMock.createKibanaRequest({}));

mockLogger = loggerMock.create();
mockLoggerService = new LoggerService(mockLogger);
esqlService = new QueryService(mockSearchClient, mockLoggerService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('executeQuery', () => {
const mockQuery = 'FROM .alerts-* | LIMIT 10';
const mockFilter = {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: '2025-01-01T00:00:00.000Z',
lte: '2025-01-02T00:00:00.000Z',
},
},
},
],
},
};

const mockParams = [
{ _tstart: '2025-01-01T00:00:00.000Z' },
{ _tend: '2025-01-02T00:00:00.000Z' },
];

const mockResponse: ESQLSearchResponse = {
columns: [
{ name: '@timestamp', type: 'date' },
{ name: 'rule_id', type: 'keyword' },
],
values: [
[new Date().toISOString(), 'rule-1'],
[new Date().toISOString(), 'rule-2'],
],
};

it('should successfully execute ES|QL query', async () => {
mockSearchClient.search.mockReturnValue(
of({
isRunning: false,
rawResponse: mockResponse,
})
);

const result = await esqlService.executeQuery({
query: mockQuery,
filter: mockFilter,
params: mockParams,
});

expect(mockSearchClient.search).toHaveBeenCalledTimes(1);
expect(mockSearchClient.search).toHaveBeenCalledWith(
{
params: {
query: mockQuery,
dropNullColumns: false,
filter: mockFilter,
params: mockParams,
},
},
{
strategy: 'esql',
}
);

expect(result).toEqual(mockResponse);
expect(mockLogger.error).not.toHaveBeenCalled();
});

it('should throw and log error when query execution fails', async () => {
const error = new Error('ES|QL syntax error');
mockSearchClient.search.mockReturnValue(throwError(() => error));

await expect(esqlService.executeQuery({ query: mockQuery })).rejects.toThrow(
'ES|QL syntax error'
);

expect(mockLogger.error).toHaveBeenCalled();
});
});

describe('queryResponseToRecords', () => {
it('should convert ES|QL response to array of objects', () => {
const mockResponse: ESQLSearchResponse = {
columns: [
{ name: 'rule_id', type: 'keyword' },
{ name: 'alert_series_id', type: 'keyword' },
{ name: '@timestamp', type: 'date' },
],
values: [
['rule-1', 'series-1', '2026-01-02T10:29:31.019Z'],
['rule-2', 'series-2', '2026-01-02T10:29:31.019Z'],
],
};

const result = esqlService.queryResponseToRecords(mockResponse);

expect(result).toHaveLength(2);
expect(result).toEqual([
{
'@timestamp': '2026-01-02T10:29:31.019Z',
rule_id: 'rule-1',
alert_series_id: 'series-1',
},
{
'@timestamp': '2026-01-02T10:29:31.019Z',
rule_id: 'rule-2',
alert_series_id: 'series-2',
},
]);
});

it('should handle missing column names in response', () => {
const mockResponse: ESQLSearchResponse = {
columns: [
{ name: 'rule_id', type: 'keyword' },
{ name: 'alert_series_id', type: 'keyword' },
],
values: [
['rule-1', 'series-1', '2026-01-02T10:29:31.019Z'],
['rule-2', 'series-2', '2026-01-02T10:29:31.019Z'],
],
};

const result = esqlService.queryResponseToRecords(mockResponse);

expect(result).toHaveLength(2);
expect(result).toEqual([
{
rule_id: 'rule-1',
alert_series_id: 'series-1',
},
{
rule_id: 'rule-2',
alert_series_id: 'series-2',
},
]);
});

it('should handle empty values response', () => {
const mockResponse: ESQLSearchResponse = {
columns: [{ name: 'field', type: 'keyword' }],
values: [],
};

const result = esqlService.queryResponseToRecords<{ field: string }>(mockResponse);

expect(result).toHaveLength(0);
expect(result).toEqual([]);
});

it('should handle empty columns response', () => {
const mockResponse: ESQLSearchResponse = {
columns: [],
values: [['value']],
};

const result = esqlService.queryResponseToRecords<{ field: string }>(mockResponse);

expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
});
});
Loading