Integrating Incidents¶
An integration can show the incidents of its objects in its own views and let users change their relation to them, without sending them to Icinga Notifications Web. Icinga DB Web, for example, lists the subscribers and recipients in the detail of a host or service, and offers to subscribe to or unsubscribe from the incident of an object next to its other quick actions.
This page covers Icinga\Module\Notifications\Integrations\V1\Incidents and Incident, and the exception
Icinga\Module\Notifications\Integrations\Exception\IncidentNotFoundException. See the overview
for prerequisites and the public API boundary.
Concepts¶
Object and ID tags. An incident belongs to an object, and an object is identified by the set of ID tags
(tags) of the events the source submitted for it, e.g. {"host": "web-01", "service": "ssh", "environment": "…"}.
You always look incidents up by ID tags, never by an ID of Icinga Notifications Web. This has two consequences:
- The tags you use to look up an incident must be exactly those the source submits, every tag and every value. If you leave a tag out or use a different spelling, you do not find an incident, and nothing tells you why. See Consistency.
- Only open incidents are found. A closed incident is not visible through these classes.
Roles. A user (via the contact with the same username) has at most one of these roles for an incident:
| Role | Meaning |
|---|---|
recipient |
The contact is a configured recipient, i.e. an escalation of an event rule notifies the contact directly |
subscriber |
The contact has subscribed to the incident and receives its notifications |
manager |
The contact has taken the incident over, e.g. by acknowledging the problem. There is at most one manager |
null |
The contact has no role for the incident, or there is no contact for the user |
The manager is a special subscriber. In Icinga DB Web, acknowledging a problem makes the user the manager of its incident, removing the acknowledgement removes the manager.
Roles of contacts that are reached only through a contact group or a schedule are not reflected in getRole(). See
getRecipients() for these.
Who Can Subscribe and Manage¶
A user can only have a role if a contact with the same username exists in Icinga Notifications. Check it before you offer an action, and explain the reason if you cannot:
use Icinga\Module\Notifications\Integrations\V1\Incidents;
Incidents::canSubscribe($user); // bool, whether the user can subscribe to incidents
Incidents::canManage($user); // bool, whether the user can manage incidents
Both take an Icinga\User and are false if no such contact exists. Currently, both check the same condition, but
check the one that matches the action you offer. Icinga DB Web disables the Subscribe and Acknowledge actions with a
tooltip that says that no Icinga Notifications contact is configured for the account.
Warning
These classes do not check any permissions or restrictions of Icinga Web. It is up to you to decide whether a user may see an incident of an object and whether they may change their role. Check that against your own permissions and the user’s restrictions before you call them. Do not look up incidents for objects the user is not allowed to see. The names of subscribers and recipients are also personal data.
Finding Incidents¶
There are three factories on Incidents. Choose by what you know about the objects.
| Factory | You pass | Returns | Typical use |
|---|---|---|---|
Incidents::get($tags) |
The full ID tags of one object | One Incident, lazy |
Detail view of an object |
Incidents::getAll($tagSets) |
The full ID tags of many objects | Incidents, iterable, countable |
Multi-select actions, lists |
Incidents::matchAll($tagSets) |
Partial tags, null means “absent” |
Incidents, iterable, countable |
Everything below a host, filters |
All three read through the module’s database, only return open incidents and throw InvalidArgumentException if
$tags/$tagSets is empty or contains an empty set.
get()¶
public static function get(array $tags): Incident
Returns a handle to the open incident of the object with exactly the given ID tags. It is lazy, no query is
made until you use it. If there is no open incident, the first call on the instance throws an
IncidentNotFoundException. Treat that as the normal “no incident” case, not as an error:
use Icinga\Module\Notifications\Integrations\Exception\IncidentNotFoundException;
use Icinga\Module\Notifications\Integrations\V1\Incidents;
$incident = Incidents::get(['host' => 'web-01', 'service' => 'ssh', 'environment' => $environment]);
try {
$role = $incident->getRole($user);
} catch (IncidentNotFoundException) {
// The object has no open incident, show nothing
}
Note
After the first IncidentNotFoundException, do not use the instance again. Further calls throw a
LogicException instead, as the instance has used up its query. Get a new one with Incidents::get(), or handle
the exception once at the first call as above.
The incident is loaded once, by the first call. isMuted() and getObjectIdTags() keep returning what it had then
for the lifetime of the instance, the other methods query the current state on each call. Instances are cheap, get a
new one per request.
getAll()¶
public static function getAll(iterable $tagSets): static
Returns the open incidents of many objects, each given with its full ID tags, e.g. all selected hosts and services of a list. Objects without an incident are simply absent from the result.
Incidents is iterable and countable. It yields an Incident for each match, which never throws an
IncidentNotFoundException. The query runs on first use, and the result is cached, so you can iterate it more than once
and count it afterwards.
$incidents = Incidents::getAll(array_map(
fn (ExampleObject $object) => $object->getIdTags(),
$selectedObjects
));
foreach ($incidents as $incident) {
$incident->addSubscriber($user->getUsername(), []);
}
Methods of the yielded Incident may query the database each time. For a lot of objects, do not call several of them
per incident in a loop unless you have to.
matchAll()¶
public static function matchAll(iterable $tagSets): static
Use this if you do not have the full tags, e.g. to find the incident of a host and of all of its services.
An absent tag matches any value. A tag set to null requires the tag to be absent. It returns the same as
getAll(), but you may get multiple incidents for one entry.
// host icinga2 and all of its services
Incidents::matchAll([['host' => 'icinga2']]);
// only the ssh service on icinga2
Incidents::matchAll([['host' => 'icinga2', 'service' => 'ssh']]);
// only the host icinga2, none of its services
Incidents::matchAll([['host' => 'icinga2', 'service' => null]]);
// the hosts a and b and all of their services
Incidents::matchAll([['host' => 'a'], ['host' => 'b']]);
Prefer getAll() whenever you have the full tags. matchAll() is less precise: in the example above, an object of an
unrelated source that only happens to have a host tag with the same value matches as well. Include a tag that
identifies your source, such as Icinga DB’s environment.
Reading the State of an Incident¶
All methods below throw an IncidentNotFoundException if the Incident was created by Incidents::get() and there is
no open incident. They reflect the state at the time of the call, see
asynchronous role changes.
getRole()¶
public function getRole(\Icinga\User $user): ?string
The role of the user’s contact: 'manager', 'subscriber', 'recipient' or null, if the user has none or there is
no contact for the username. Only the contact itself counts, not the groups or schedules it is part of.
getObjectIdTags()¶
public function getObjectIdTags(): array
The full ID tags of the incident’s object, array<string, string>. This is how you map the incidents you got from
getAll() or matchAll() back to your objects.
isMuted()¶
public function isMuted(): bool
Whether the incident is muted. A muted incident does not notify its recipients.
hasManager()¶
public function hasManager(): bool
Whether anybody manages the incident. Use it to decide whether to offer to take over the incident.
getSubscribers()¶
public function getSubscribers(): array
The managers and subscribers of the incident, as a list of
[
'name' => 'Jane Doe', // string, the full name of the contact
'username' => 'jdoe', // ?string, the username of the contact
'role' => 'manager', // 'manager' | 'subscriber'
'roleChangedAt' => new DateTime(…) // DateTime, when the contact got the role
]
getRecipients()¶
public function getRecipients(): array
The recipients that the event rules configured for the incident, as a list of
[
'type' => 'contact', // 'contact' | 'contactgroup' | 'schedule'
'name' => 'Jane Doe', // string, the full name of a contact, else the name of the group or schedule
'username' => 'jdoe', // ?string, the username of a contact, `null` for groups and schedules
'roleChangedAt' => new DateTime(…) // DateTime, when the entry was last changed
]
A recipient that is a contact group or a schedule is listed as such, not resolved to the contacts that are currently notified.
Changing Roles¶
public function addManager(string $username, array $eventTypes, ?UuidInterface $uuid = null): static
public function addSubscriber(string $username, array $eventTypes, ?UuidInterface $uuid = null): static
public function removeManager(?UuidInterface $uuid = null): static
public function removeSubscriber(string $username, ?UuidInterface $uuid = null): static
Each method requests a role change and returns the incident. They throw an IncidentNotFoundException if there
is no open incident, and an InvalidArgumentException if there is no contact with the given username
(addManager(), addSubscriber() and removeSubscriber()). Database failures are thrown as they are.
| Method | Requests | Nothing is requested if |
|---|---|---|
addManager($username, []) |
Make the contact the manager, only subscribed to incident updates | The incident already has a manager, whoever it is, including the contact |
addSubscriber($username, ['downtime_start']) |
Make the contact a subscriber, also subscribed to downtime starts | The contact already is a subscriber or the manager |
removeManager() |
Demote the current manager, whoever it is, to subscriber | The incident has no manager |
removeSubscriber($username) |
Remove the subscription of the contact | The contact is not a subscriber, e.g. the manager or a recipient |
Notes on the semantics:
- There is one manager per incident. Offer
addManager()only ifhasManager()isfalse. If somebody else is the manager, there is nothing to take over, the request is silently skipped. removeManager()has no username. It demotes whoever the manager is. If you want to demote only a specific user, checkgetRole($user) === 'manager'before.- Role changes requested through this API have to pass event types. The daemon only notifies subscribers and managers
about events with
incident: falsefor the event types they chose, so contacts that got their role this way only receive such notifications they want. They still receive the notifications of the incident itself in any case. - The change is for the contact with the given
$username, which is usually the current user’s. You may pass other usernames, but the pending state described below is only tracked for the session that requested the change.
Role Changes Are Asynchronous¶
A role change is not applied when the method returns. It is queued for the daemon, which applies it as soon as it processes its queue, usually within a few seconds. Until then, the state methods above do not reflect the change. Do not render the new state directly after a request. Redirect, and use the pending state to show that something is going on.
What the daemon does with a request:
- It applies the change to the object’s incident that is open when the daemon processes it. If the incident recovered in the meantime, the change has no effect. If the object has a newer open incident by then, the change applies to that one.
- If the daemon decides that the request cannot be fulfilled, e.g. the incident has a manager already, the request is marked as failed. The user is told that their request could not be processed.
- If the daemon does not process the request within 30 seconds, e.g. because it is not running, the user is told that the daemon might not be running. Nothing cancels the request, so the daemon may still apply it later.
Correlating Role Changes with Source Events¶
All four methods take an optional Ramsey\Uuid\UuidInterface $uuid. By default a new one is generated. Pass one if
the role change represents an event of your source, so that the change can be correlated with the source’s history.
The typical case is an acknowledgement. When a user acknowledges a problem in Icinga DB Web, Icinga DB Web makes them
the manager of the incident (addManager()), and Icinga DB submits an event for the acknowledgement it sees in
Icinga 2. Both stand for the same occurrence and should have the same ID. Icinga DB Web derives the ID of the
acknowledgement event from the same information that Icinga DB uses, and passes it as $uuid.
- The UUID is used as the ID of the event the daemon creates for the role change, which appears in its history. Events and role changes share one queue, and the daemon ignores an ID it already processed, to protect against events that are delivered twice.
- A role change with a
$uuidthat was used before is ignored, it is assumed to be the same event delivered again. The daemon keeps processed entries for a few minutes at least, so do not count on a reused UUID to work later. Never reuse a UUID for a different role change. Derive it deterministically from the occurrence, e.g. from the time of the acknowledgement, or leave it out. - If you do not have an event to correlate, pass nothing.
Pending State¶
As the changes are asynchronous, the user needs feedback. For the current user’s session, the Incident remembers
the role changes it requested, and tells you whether one is still pending.
public function getPendingAction(): ?string
public function getPendingJobAttributes(): array
getPendingAction()returns'manage','unmanage','subscribe'or'unsubscribe', ornullif no request is pending. It isnullas well if somebody else queued a change, or if the user’s session does not know about it, e.g. another browser. It is alsonullonce the daemon finished the request, or after 30 seconds without a result. There is one tracked request per incident. A new request replaces the one before. Do not accept a new request while one is pending, as Icinga Notifications Web’s own quick actions do.getPendingJobAttributes()returns HTML attributes for the element of the quick action, e.g. its button, while the request is pending:disabled, atitle(“Your request is being processed”) and adata-notifications-jobattribute with the URL to poll the state of the request. It returns an empty array if nothing is pending.
Apply the attributes to the element, and Icinga Notifications Web does the rest. Its JavaScript finds elements with
data-notifications-job in the columns of Icinga Web after they are rendered, polls the state once per second and, once
the daemon is done, reloads the closest container that holds the element. On errors, or if the daemon did not react
in time, it shows a notification. The element has to be inside one of the columns of Icinga Web, which is then reloaded
as a whole. Your view must render the state correctly again when it is reloaded.
$button = $form->getElement('btn_submit');
if ($incident->getPendingAction() === 'subscribe') {
$button->getAttributes()
->add('class', 'active')
->set($incident->getPendingJobAttributes());
}
This also works for users who may not access Icinga Notifications Web at all, i.e. who lack the permission
module/notifications: the endpoint is available to every logged in user, and the JavaScript is loaded for every user
while the module is enabled. The state is only reported for requests of the user’s own session.
Example¶
This is a simplified end-to-end example, modeled after Icinga DB Web. It has an object with the ID tags
host and service, shows a subscribe or unsubscribe action in the detail view of the object and handles it.
Permissions of the integration itself are omitted, add them. It uses only the public API.
The one place that knows how ID tags are built. They have to match the tags of the events the source submits:
namespace Icinga\Module\Example\Notifications;
use Icinga\Module\Example\Model\ExampleObject;
use Icinga\Module\Notifications\Integrations\V1\Incident;
use Icinga\Module\Notifications\Integrations\V1\Incidents;
use ipl\Stdlib\Seq;
class IncidentFinder
{
public static function forObject(ExampleObject $object): Incident
{
return Incidents::get(self::tagsOf($object));
}
/**
* @param iterable<ExampleObject> $objects
*/
public static function forObjects(iterable $objects): Incidents
{
return Incidents::getAll(Seq::map($objects, fn (ExampleObject $object) => self::tagsOf($object)));
}
private static function tagsOf(ExampleObject $object): array
{
return isset($object->service)
? ['host' => $object->host, 'service' => $object->service]
: ['host' => $object->host];
}
}
A form for the action, which can render itself as pending:
namespace Icinga\Module\Example\Notifications;
use Icinga\Web\Session;
use ipl\Html\Form;
use ipl\Web\Common\CsrfCounterMeasure;
class SubscriptionForm extends Form
{
use CsrfCounterMeasure;
protected $defaultAttributes = ['class' => 'inline'];
protected ?array $pendingJobAttributes = null;
public function __construct(private readonly bool $subscribe)
{
}
/**
* @param array $attributes The result of Incident::getPendingJobAttributes()
*/
public function setPendingJob(array $attributes): static
{
$this->pendingJobAttributes = $attributes;
return $this;
}
protected function assemble(): void
{
$this->addCsrfCounterMeasure(Session::getSession()->getId());
$this->addElement('submitButton', 'btn_submit', [
'label' => $this->subscribe ? 'Subscribe' : 'Unsubscribe'
]);
if ($this->pendingJobAttributes !== null) {
// Disables the button and lets quick-action.js reload the container once the request is processed
$this->getElement('btn_submit')->getAttributes()
->add('class', 'active')
->set($this->pendingJobAttributes);
}
}
}
The detail view decides which action to offer. It treats the absence of an incident as the normal case:
use Icinga\Application\Icinga;
use Icinga\Module\Example\Notifications\IncidentFinder;
use Icinga\Module\Example\Notifications\SubscriptionForm;
use Icinga\Module\Notifications\Integrations\Exception\IncidentNotFoundException;
use Icinga\Module\Notifications\Integrations\V1\Incidents;
// In the code that assembles the detail of an $object for a $user
if (Icinga::app()->getModuleManager()->hasEnabled('notifications') && Incidents::canSubscribe($user)) {
$incident = IncidentFinder::forObject($object);
try {
// The first call that needs the incident throws if there is none
$role = $incident->getRole($user);
if ($role !== 'manager') { // Managers are subscribed implicitly
$action = $role === 'subscriber' ? 'unsubscribe' : 'subscribe';
$form = (new SubscriptionForm($action === 'subscribe'))
->setAction($object->getActionUrl($action));
if ($incident->getPendingAction() === $action) {
$form->setPendingJob($incident->getPendingJobAttributes());
}
$detail->addHtml($form);
}
} catch (IncidentNotFoundException) {
// The object has no open incident, there is nothing to subscribe to
}
}
If canSubscribe() is false, render the action disabled with a title that explains why instead of hiding it.
The controller action handles the submission. After it, it redirects instead of rendering any state, as that cannot have changed yet:
use Icinga\Application\Logger;
use Icinga\Module\Example\Notifications\IncidentFinder;
use Icinga\Module\Example\Notifications\SubscriptionForm;
use Icinga\Module\Notifications\Integrations\Exception\IncidentNotFoundException;
use Icinga\Web\Controller;
use Icinga\Web\Notification;
use ipl\Html\Form;
use Throwable;
class ObjectController extends Controller
{
public function subscribeAction(): void
{
$object = $this->fetchObject(); // Simplified, the integration's own way to load the object
$username = $this->Auth()->getUser()->getUsername();
$form = (new SubscriptionForm(true))
->on(Form::ON_SUBMIT, function () use ($object, $username) {
try {
IncidentFinder::forObject($object)->addSubscriber($username, []);
Notification::success($this->translate('Your request has been submitted'));
} catch (IncidentNotFoundException) {
// The incident was closed in the meantime
Notification::warning($this->translate('There is no open incident anymore'));
} catch (Throwable $e) {
Logger::error('Failed to subscribe user "%s": %s', $username, $e);
Notification::error($this->translate('Failed to submit your request'));
}
$this->redirectNow($object->getDetailUrl());
})
->handleRequest($this->getServerRequest());
$this->addContent($form);
}
}
For a multi-select of objects, use getAll() and iterate the result, as in the getAll() example. Icinga DB
Web does that in its library/Icingadb/Notifications/ namespace. See SubscribeIncidents for subscribing and
ManagesIncidents for managing, which also derives the UUIDs of acknowledgements. Its quick actions are in
library/Icingadb/Widget/Detail/QuickActions.php.