Skip to content

Commit 28a9188

Browse files
committed
Create alert action publisher
1 parent e43a1b4 commit 28a9188

13 files changed

Lines changed: 437 additions & 23 deletions

File tree

x-pack/platform/plugins/shared/alerting_v2/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { bindOnSetup } from './setup/bind_on_setup';
1414
import { bindOnStart } from './setup/bind_on_start';
1515
import { bindRoutes } from './setup/bind_routes';
1616
import { bindServices } from './setup/bind_services';
17+
import { bindEvents } from './setup/bind_events';
1718
import { bindRuleExecutionServices } from './setup/bind_rule_executor';
1819
import { bindDispatcherExecutionServices } from './setup/bind_dispatcher_executor';
1920
import { bindTasks } from './setup/bind_tasks';
@@ -28,6 +29,7 @@ export const module = new ContainerModule((options) => {
2829
bindContract(options);
2930
bindRoutes(options);
3031
bindServices(options);
32+
bindEvents(options);
3133
bindRuleExecutionServices(options);
3234
bindDispatcherExecutionServices(options);
3335
bindTasks(options);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { EventBus } from '../event_bus';
9+
import { createEventBusMock } from '../event_bus/event_bus.mock';
10+
import type { AlertingDomainEvent } from '../domain_events';
11+
import { AlertActionEventPublisher } from './alert_action_event_publisher';
12+
13+
/**
14+
* Builds a real {@link AlertActionEventPublisher} wired to a jest-mocked
15+
* {@link EventBus}. Use this when you want to test the publisher's
16+
* translation/dispatch behaviour against assertions on the bus.
17+
*/
18+
export function createAlertActionEventPublisher(): {
19+
publisher: AlertActionEventPublisher;
20+
eventBus: jest.Mocked<EventBus<AlertingDomainEvent>>;
21+
} {
22+
const eventBus = createEventBusMock<AlertingDomainEvent>();
23+
24+
return {
25+
publisher: new AlertActionEventPublisher(eventBus),
26+
eventBus,
27+
};
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { EventBus } from '../event_bus';
9+
import type { AlertingDomainEvent } from '../domain_events';
10+
import { createAlertActionEventPublisher } from './alert_action_event_publisher.mock';
11+
import type { AlertActionEventPublisher } from './alert_action_event_publisher';
12+
import { EPISODE_ASSIGNED_EVENT_TYPE } from './events';
13+
14+
describe('AlertActionEventPublisher', () => {
15+
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
16+
17+
let publisher: AlertActionEventPublisher;
18+
let eventBus: jest.Mocked<EventBus<AlertingDomainEvent>>;
19+
20+
beforeEach(() => {
21+
({ publisher, eventBus } = createAlertActionEventPublisher());
22+
});
23+
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
afterAll(() => {
29+
jest.useRealTimers();
30+
});
31+
32+
describe('emitEpisodeAssigned', () => {
33+
it('publishes an `episode.assigned` event with the canonical envelope and payload shape', () => {
34+
publisher.emitEpisodeAssigned({
35+
groupHash: 'group-hash-1',
36+
episodeId: 'episode-1',
37+
ruleId: 'rule-1',
38+
spaceId: 'default',
39+
assigneeUid: 'user-uid-1',
40+
actorUid: 'actor-uid-1',
41+
occurredAt: '2025-02-02T12:34:56.000Z',
42+
});
43+
44+
expect(eventBus.publish).toHaveBeenCalledTimes(1);
45+
expect(eventBus.publish).toHaveBeenCalledWith({
46+
type: EPISODE_ASSIGNED_EVENT_TYPE,
47+
occurredAt: '2025-02-02T12:34:56.000Z',
48+
groupHash: 'group-hash-1',
49+
episodeId: 'episode-1',
50+
ruleId: 'rule-1',
51+
spaceId: 'default',
52+
actorUid: 'actor-uid-1',
53+
payload: {
54+
assigneeUid: 'user-uid-1',
55+
},
56+
});
57+
});
58+
59+
it('defaults `occurredAt` to the current ISO timestamp when omitted', () => {
60+
publisher.emitEpisodeAssigned({
61+
groupHash: 'group-hash-1',
62+
episodeId: 'episode-1',
63+
ruleId: 'rule-1',
64+
spaceId: 'default',
65+
assigneeUid: 'user-uid-1',
66+
actorUid: 'actor-uid-1',
67+
});
68+
69+
expect(eventBus.publish).toHaveBeenCalledWith(
70+
expect.objectContaining({ occurredAt: '2026-01-01T00:00:00.000Z' })
71+
);
72+
});
73+
74+
it('preserves a null assigneeUid in the payload (unassign case)', () => {
75+
publisher.emitEpisodeAssigned({
76+
groupHash: 'group-hash-1',
77+
episodeId: 'episode-1',
78+
ruleId: 'rule-1',
79+
spaceId: 'default',
80+
assigneeUid: null,
81+
actorUid: 'actor-uid-1',
82+
});
83+
84+
expect(eventBus.publish).toHaveBeenCalledWith(
85+
expect.objectContaining({ payload: { assigneeUid: null } })
86+
);
87+
});
88+
89+
it('preserves a null actorUid on the envelope (internal / system actor case)', () => {
90+
publisher.emitEpisodeAssigned({
91+
groupHash: 'group-hash-1',
92+
episodeId: 'episode-1',
93+
ruleId: 'rule-1',
94+
spaceId: 'default',
95+
assigneeUid: 'user-uid-1',
96+
actorUid: null,
97+
});
98+
99+
expect(eventBus.publish).toHaveBeenCalledWith(expect.objectContaining({ actorUid: null }));
100+
});
101+
});
102+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { inject, injectable } from 'inversify';
9+
import type { EventBus } from '../event_bus';
10+
import { AlertingDomainEventBusToken, type AlertingDomainEvent } from '../domain_events';
11+
import {
12+
EPISODE_ASSIGNED_EVENT_TYPE,
13+
type AlertActionEventEnvelope,
14+
type EpisodeAssignedEvent,
15+
} from './events';
16+
17+
/**
18+
* Caller-friendly inputs shared by every `emit*` method on the publisher.
19+
*
20+
* Each method extends this with its event-specific payload fields, kept
21+
* flat so callers construct a single object rather than a nested
22+
* `{ envelope, payload }` shape. The publisher does the lift.
23+
*/
24+
export interface BaseEmitAlertActionParams {
25+
readonly groupHash: string;
26+
readonly episodeId: string;
27+
readonly ruleId: string;
28+
readonly spaceId: string;
29+
/** Actor user-profile uid, or `null` for internal/system actors. */
30+
readonly actorUid: string | null;
31+
/**
32+
* ISO timestamp of when the action occurred. Defaults to
33+
* `new Date().toISOString()` when omitted.
34+
*/
35+
readonly occurredAt?: string;
36+
}
37+
38+
/** Caller-friendly parameters for {@link AlertActionEventPublisherContract.emitEpisodeAssigned}. */
39+
export interface EmitEpisodeAssignedParams extends BaseEmitAlertActionParams {
40+
/** New assignee user-profile uid, or `null` when unassigning. */
41+
readonly assigneeUid: string | null;
42+
}
43+
44+
/**
45+
* Public contract for the alert-action event publisher.
46+
*
47+
* One method per concrete event. Additional `emit*` methods will be added
48+
* as the other alert-action types (ack, snooze, tag, …) start publishing.
49+
*/
50+
export interface AlertActionEventPublisherContract {
51+
emitEpisodeAssigned(params: EmitEpisodeAssignedParams): void;
52+
}
53+
54+
/**
55+
* Singleton publisher of alert-action domain events onto the in-process
56+
* {@link EventBus}.
57+
*
58+
* Owns the construction of the canonical event shape (envelope + payload).
59+
* Each `emit*` method:
60+
*
61+
* 1. Lifts the common envelope from the caller's flat params via
62+
* {@link AlertActionEventPublisher#buildEnvelope}, including the
63+
* `occurredAt` timestamp default.
64+
* 2. Builds the event-specific `payload`.
65+
* 3. Tags the result with the event's `type` discriminator and publishes.
66+
*
67+
* Publishing is fire-and-forget — `publish` returns synchronously and
68+
* subscriber work runs on the next event-loop iteration. See
69+
* {@link EventBus} for the dispatch contract.
70+
*/
71+
@injectable()
72+
export class AlertActionEventPublisher implements AlertActionEventPublisherContract {
73+
constructor(
74+
@inject(AlertingDomainEventBusToken)
75+
private readonly eventBus: EventBus<AlertingDomainEvent>
76+
) {}
77+
78+
public emitEpisodeAssigned(params: EmitEpisodeAssignedParams): void {
79+
const event: EpisodeAssignedEvent = {
80+
type: EPISODE_ASSIGNED_EVENT_TYPE,
81+
...this.buildEnvelope(params),
82+
payload: {
83+
assigneeUid: params.assigneeUid,
84+
},
85+
};
86+
87+
this.eventBus.publish(event);
88+
}
89+
90+
private buildEnvelope(common: BaseEmitAlertActionParams): AlertActionEventEnvelope {
91+
return {
92+
occurredAt: common.occurredAt ?? new Date().toISOString(),
93+
groupHash: common.groupHash,
94+
episodeId: common.episodeId,
95+
ruleId: common.ruleId,
96+
spaceId: common.spaceId,
97+
actorUid: common.actorUid,
98+
};
99+
}
100+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
/**
9+
* Common envelope shared by every alert-action domain event.
10+
*
11+
* Captures the episode context (rule / group / episode / space), the actor
12+
* who performed the action, and when the action occurred. Lifted out of
13+
* individual events so subscribers can read this metadata uniformly
14+
* (without switching on `type`) and so the publisher can construct it
15+
* once per emit call.
16+
*/
17+
export interface AlertActionEventEnvelope {
18+
/** ISO timestamp of when the event occurred. */
19+
readonly occurredAt: string;
20+
readonly groupHash: string;
21+
readonly episodeId: string;
22+
readonly ruleId: string;
23+
readonly spaceId: string;
24+
/**
25+
* User-profile uid of the actor who performed the action, or `null`
26+
* when the action was performed by an internal/system context (no user).
27+
*/
28+
readonly actorUid: string | null;
29+
}
30+
31+
/**
32+
* Structure of every alert-action domain event.
33+
*
34+
* Concrete events specialise:
35+
* - `TType` — the string-literal discriminator (e.g. `'episode.assigned'`).
36+
* - `TPayload` — the event-specific payload shape.
37+
*
38+
* The shared envelope fields stay at the top level so subscribers can read
39+
* them uniformly. Per-event data lives under `payload`.
40+
*
41+
* @example
42+
* ```ts
43+
* type EpisodeAckedEvent = BaseAlertActionEvent<
44+
* 'episode.acked',
45+
* { reason: string | null }
46+
* >;
47+
* ```
48+
*/
49+
export interface BaseAlertActionEvent<TType extends string, TPayload extends object>
50+
extends AlertActionEventEnvelope {
51+
readonly type: TType;
52+
readonly payload: TPayload;
53+
}
54+
55+
/** Discriminator value for {@link EpisodeAssignedEvent}. */
56+
export const EPISODE_ASSIGNED_EVENT_TYPE = 'episode.assigned' as const;
57+
58+
/** Payload of {@link EpisodeAssignedEvent}. */
59+
export interface EpisodeAssignedPayload {
60+
/** New assignee user-profile uid, or `null` when unassigning. */
61+
readonly assigneeUid: string | null;
62+
}
63+
64+
/**
65+
* Domain event published when the assignee of an alerting episode changes —
66+
* either set to a specific user, or cleared to `null`.
67+
*/
68+
export type EpisodeAssignedEvent = BaseAlertActionEvent<
69+
typeof EPISODE_ASSIGNED_EVENT_TYPE,
70+
EpisodeAssignedPayload
71+
>;
72+
73+
/**
74+
* Discriminated union of every alert-action domain event.
75+
*
76+
* Extend this when a new alert-action event type is added (ack, snooze,
77+
* tag, …). Cross-domain events (rule executor, dispatcher) live under
78+
* their own unions and are composed into `AlertingDomainEvent` in
79+
* `lib/events/domain_events`.
80+
*/
81+
export type AlertActionEvent = EpisodeAssignedEvent;

x-pack/platform/plugins/shared/alerting_v2/server/lib/events/domain_events/index.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,52 @@
2121
* 3. Extend the {@link AlertingDomainEvent} union below.
2222
*/
2323

24+
import type { ServiceIdentifier } from 'inversify';
25+
import type { EventBus } from '../event_bus';
26+
import type { AlertActionEvent } from '../alert_action_event_publisher/events';
27+
28+
export type {
29+
AlertActionEvent,
30+
AlertActionEventEnvelope,
31+
BaseAlertActionEvent,
32+
EpisodeAssignedEvent,
33+
EpisodeAssignedPayload,
34+
} from '../alert_action_event_publisher/events';
35+
export { EPISODE_ASSIGNED_EVENT_TYPE } from '../alert_action_event_publisher/events';
36+
37+
/**
38+
* Discriminated union of every domain event the alerting framework publishes
39+
* on its event bus.
40+
*
41+
* Composed from per-subdomain unions so each subdomain owns its own catalog
42+
* (and its own envelope shape if it diverges). Today only the alert-action
43+
* subdomain publishes. Future subdomains (rule executor, dispatcher) extend
44+
* this union by adding their own sub-union here.
45+
*/
46+
export type AlertingDomainEvent = AlertActionEvent;
47+
2448
/**
25-
* Discriminated union of every domain event the alerting framework publishes on its event bus.
26-
* Extend this when a new publisher is introduced.
49+
* Inversify token for the singleton {@link EventBus} carrying every
50+
* {@link AlertingDomainEvent}.
51+
*
52+
* Typed against the {@link AlertingDomainEvent} discriminated union so that
53+
* subscribers receive a fully-narrowed event in their handler:
54+
*
55+
* ```ts
56+
* constructor(@inject(AlertingDomainEventBusToken)
57+
* private readonly bus: EventBus<AlertingDomainEvent>) {}
58+
*
59+
* this.bus.subscribe('episode.assigned', (event) => {
60+
* // `event` is `EpisodeAssignedEvent` here — no cast needed.
61+
* });
62+
* ```
63+
*
64+
* Publishers can rely on the same narrowing on their `publish` call. The
65+
* generic `EventBus<TEvent>` interface is contravariant in `TEvent` at its
66+
* input positions, so the underlying `AsyncDomainEventBus` singleton
67+
* (declared as `<DomainEvent>` by default) safely satisfies this narrower
68+
* contract.
2769
*/
28-
export type AlertingDomainEvent = Record<string, any>;
70+
export const AlertingDomainEventBusToken = Symbol.for(
71+
'alerting_v2.AlertingDomainEventBus'
72+
) as ServiceIdentifier<EventBus<AlertingDomainEvent>>;

0 commit comments

Comments
 (0)