Skip to main content

Creating alert channel types

An alert channel type is represented by a DataElement that typically has a name ending in "Channel", such as "EmailChannel" for example. These elements are marked by the alert.channel. A channel element's fields can be used as runtime configuration for a channel. For example, a field for EmailChannel could be emailAddress. A channel (instance of the element) would then be a specific email address to send an alert to.

Option
alert.channel DataElement

This option should be applied to any DataElement that represents a channel type. Adding this option registers it as a channel type for the expanders for the alerting system.

caution

The name of an alert channel type (the element with the alert.channel option) MUST be unique in an application. Two components cannot define a channel type with the same name.

<options>
<alert.channel/>
</options>

Implementation

For a channel type DataElement, an implementation bean will be generated as <packageName>.alerts.<DataElement>AlertDispatcher. This bean has Local and Remote interfaces, as JNDI will be used to resolve them, so they can be called from the alerting component using RMI. All relevant data is resolved in this bean and presented as arguments to the dispatchToChannel() method, which is where the implementation to send the alert can be added.

Example of implementation bean

This is an example of an implementation that writes an alert to a standard logging object.

package net.democritus.alerting.impl.alerts;

import net.democritus.alerting.impl.LoggingChannelDataRefConverter;
import net.democritus.alerting.impl.LoggingChannelDetails;
import net.democritus.alerting.impl.LoggingChannelLocalAgent;

import net.democritus.alerting.AlertDetails;
import net.democritus.alerting.AlertDispatchDetails;
import net.democritus.alerting.AlertLocalAgent;

import net.democritus.sys.Context;
import net.democritus.sys.CrudsResult;
import net.democritus.sys.DataRef;
import net.democritus.sys.ParameterContext;
import net.democritus.validation.Result;

import javax.ejb.Local;
import javax.ejb.Remote;
import javax.ejb.Stateless;

// @anchor:imports:start
// @anchor:imports:end
// anchor:custom-imports:start
import net.democritus.alerting.alerts.AlertPriority;

import net.palver.logging.Logger;
import net.palver.logging.LoggerFactory;
// anchor:custom-imports:end

// @feature:alerting
@Stateless(name = "LoggingChannelAlertDispatcherBean")
@Local(LoggingChannelAlertDispatcherLocal.class)
@Remote(LoggingChannelAlertDispatcherRemote.class)
public final class LoggingChannelAlertDispatcher
implements LoggingChannelAlertDispatcherLocal, LoggingChannelAlertDispatcherRemote {

// @anchor:fields:start
// @anchor:fields:end
// anchor:custom-fields:start
private static final Logger LOGGER = LoggerFactory.getLogger(LoggingChannelAlertDispatcher.class);
// anchor:custom-fields:end

@Override
public void dispatch(final ParameterContext<AlertDispatchDetails> alertDispatchDetailsParameter) {
final Context context = alertDispatchDetailsParameter.getContext();
final AlertDispatchDetails alertDispatch = alertDispatchDetailsParameter.getValue();

final LoggingChannelDataRefConverter dataRefConverter = new LoggingChannelDataRefConverter();
final Result<DataRef> channelRefResult = dataRefConverter.fromString(alertDispatch.getChannel());
final DataRef channelRef = channelRefResult.getValueOrElseThrow(
res -> new IllegalArgumentException("Failed to convert channel to DataRef for requested channel type: " +
alertDispatch.getChannel()));

final LoggingChannelLocalAgent loggingChannelAgent = LoggingChannelLocalAgent.getLoggingChannelAgent(context);
final CrudsResult<LoggingChannelDetails> channelDetailsResult = loggingChannelAgent.getDetails(channelRef);
final LoggingChannelDetails channel = channelDetailsResult.getValueOrElseThrow(
res -> new IllegalArgumentException("Failed to get details for channel: " + alertDispatch.getChannel()));

final AlertLocalAgent alertAgent = AlertLocalAgent.getAlertAgent(context);
final CrudsResult<AlertDetails> alertResult = alertAgent.getDetails(alertDispatch.getAlert());
final AlertDetails alert = alertResult.getValueOrElseThrow(
ref -> new IllegalArgumentException("Failed to get alert details for alert: " + alertDispatch.getAlert()));

dispatchToChannel(alertDispatch, alert, channel, context);
}

private void dispatchToChannel(final AlertDispatchDetails alertDispatch,
final AlertDetails alert, final LoggingChannelDetails channel,
final Context context) {
// @anchor:dispatch:start
// @anchor:dispatch:end
// anchor:custom-dispatch:start
final AlertPriority alertPriority = AlertPriority.valueOf(alert.getPriority().getName());
final String message = String.format("[%s] %s - %s", alertPriority.name(), alert.getTitle(), alert.getMessage());
switch (alertPriority) {
case CRITICAL, HIGH -> {
if (LOGGER.isErrorEnabled()) {
LOGGER.error(message);
}
}
case MEDIUM -> {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn(message);
}
}
case LOW -> {
if (LOGGER.isInfoEnabled()) {
LOGGER.info(message);
}
}
case NONE -> {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(message);
}
}
}
// anchor:custom-dispatch:end
}

// @anchor:methods:start
// @anchor:methods:end
// anchor:custom-methods:start
// anchor:custom-methods:end

}

Properties

There's some configuration that you should not have in the database or have to define for each channel. A good example of this would be the API key to access some API to send messages to. Typically only one of these is required per channel type. Therefore, it is possible to define properties in the alerting.ns.properties file for a channel and refer to them using a key that is (partially) defined in a Field marked with the alert.configName option.

Option
alert.configName Field

This option is added to a field of an alert channel element, to indicate that it contains the name of the configuration properties group for the channel in the alerting.ns.properties file. When present, two getAlertProperty() methods will be generated in the implementation bean for the alert. One for required values that returns a String or throws an exception if undefined. The other returns an Optional<String> with Optional.empty() if it is not defined.

<options>
<alert.configName/>
</options>

The properties can be retrieved with one of the two getAlertProperty(channel, propertyName) methods. The keys for the properties are defined as alerts.<dataElement>.<fieldName>.<propertyName>, where <dataElement> is the name of the channel DataElement in camelCase, <fieldName> is the name of the field with the alert.configName option and <propertyName> is the name of the value passed as propertyName to the getAlertProperty() method.

Example

For the default SlackChannel channel type, one mandatory property is the API key for Slack. This could be defined as:

alerts.slackChannel.nsxSlackServer.apiToken=my-apikey-goes-here

To use this apiKey, a channel configured in the database would have nsxSlackServer set for the config Field, which has the alert.configName option in the model. The value for the alert that is being processed would then be retrieved in the implementation bean as getAlertProperty(channel, "apiToken").

The idea is that you can configure more than one server to send Slack messages to if needed and you refer to the config group with the apikey for that server for each channel that is configured to send messages to that server. The channel to send to on the other hand, is a field in the channel type element, so the actual channel entry in the database will send to a specific Slack channel.

Templating

For some channel types, it makes sense that there can be one or more templates to conveniently format the content that is transmitted for an alert. To facilitate this, a system based on the StringTemplate based system can be added to a channel that works somewhat similarly to the expansion framework.

Option
alert.canTemplate DataElement

This option generates a templating engine for the channel type that it is applied to. The Templater class that is expanded will be accessible through the templater field that is added to the implementation bean for the channel type.

<options>
<alert.canTemplate/>
</options>

When the option alert.canTemplate is added, the expanders will generate a Templater class for the channel type. This class is fully expanded and generates some mappings that are passed to the string template, but allows you to also add additional fields from the involved objects, such as fields from the alert itself or the alert channel.

info

It is also possible to look up additional data from the database if for example an element was added to link additional fields to the Alert element from another DataElement. Use this approach with care, the system is intended to send simple alerts.

Example of a Templater class

This is an example of an implementation that performs templating for an email body. Though it does not have many customizations, it does add the recipient's name to the mapping for the channel, so it can be used to address the recipient by name in the template for the email body.

package net.democritus.alerting.impl.alerts;

import net.democritus.alerting.AlertDetails;
import net.democritus.alerting.impl.EmailChannelDetails;

import net.democritus.alert.st.DateRenderer;
import net.democritus.alert.st.DynamicST;
import net.democritus.alert.st.StringRenderer;

import net.democritus.sys.Context;
import net.democritus.sys.ParameterContext;

import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.STGroupFile;

import java.net.URL;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

// @feature:alerting
public final class EmailChannelTemplater {

private Map<String, Object> getAlertMapping(final AlertDetails alert, final Context context) {
final Map<String, Object> mapping = new HashMap<>();
mapping.put("title", alert.getTitle());
mapping.put("message", alert.getMessage());
mapping.put("link", alert.getLink() == null || alert.getLink().isBlank() ? null : alert.getLink());
mapping.put("linkTitle", alert.getLinkTitle() == null || alert.getLinkTitle().isBlank() ? null : alert.getLinkTitle());
mapping.put("identifier", alert.getIdentifier());
mapping.put("priority", alert.getPriority().getName());
mapping.put("timestamp", alert.getEnteredAt());

// @anchor:alert-mapping:start
// @anchor:alert-mapping:end
// anchor:custom-alert-mapping:start
// anchor:custom-alert-mapping:end

return mapping;
}

private Map<String, Object> getChannelMapping(final EmailChannelDetails channel, final Context context) {
final Map<String, Object> mapping = new HashMap<>();

// @anchor:channel-mapping:start
// @anchor:channel-mapping:end
// anchor:custom-channel-mapping:start
mapping.put("recipient", channel.getRecipientName());
// anchor:custom-channel-mapping:end

return mapping;
}

private Map<String, Object> getMapping(final Parameters parameters, final Context context) {
final Map<String, Object> mapping = new HashMap<>();
mapping.put("origin", parameters.getOrigin());
mapping.put("alert", getAlertMapping(parameters.getAlert(), context));
mapping.put("channel", getChannelMapping(parameters.getEmailChannel(), context));
return mapping;
}

public String render(final ParameterContext<Parameters> parameters) {
final String templateResourcePath = parameters.getValue().getTemplateResourcePath();

final URL templateResource = EmailChannelTemplater.class.getResource(templateResourcePath);
if (templateResource == null) {
throw new IllegalArgumentException(String.format("Template '%s' could not be found", templateResourcePath));
}

final STGroupFile groupFile = new STGroupFile(templateResource);
groupFile.registerRenderer(Date.class, new DateRenderer());
groupFile.registerRenderer(String.class, new StringRenderer());

final Map<String, Object> mapping = getMapping(parameters.getValue(), parameters.getContext());

final ST stg = new DynamicST(groupFile.getInstanceOf("base"), mapping.keySet());
mapping.forEach(stg::add);

return stg.render();
}

public static final class Parameters {

private EmailChannelDetails emailChannel;
private AlertDetails alert;
private String origin;
private String templateResourcePath;

public EmailChannelDetails getEmailChannel() {
return emailChannel;
}

public Parameters setEmailChannel(EmailChannelDetails emailChannel) {
this.emailChannel = emailChannel;
return this;
}

public AlertDetails getAlert() {
return alert;
}

public Parameters setAlert(AlertDetails alert) {
this.alert = alert;
return this;
}

public String getOrigin() {
return origin;
}

public Parameters setOrigin(String origin) {
this.origin = origin;
return this;
}

public String getTemplateResourcePath() {
return templateResourcePath;
}

public Parameters setTemplateResourcePath(String templateResourcePath) {
this.templateResourcePath = templateResourcePath;
return this;
}

}

}

The Templater is used in the channel implementation bean by calling the render() method on the templater object, which should be passed an instance of the Templater.Parameters class that has all of the needed parameters for the templater, including the resource path of the stg template itself, which should be part of the resources of the application.

Templates

Templates for the templating system are pretty much identical to that of the expanders. The template should be an stg file packaged as a resource with a root template named base:

base() ::= <<
>>
Example of a template

This is an example of a template for an alert sent to a Slack channel.

delimiters "%", "%"

base() ::= <<
[
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":warning: ALERT: %alert.title%",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "%alert.message%"
}%if(alert.link)%,
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": ":link: %if(alert.linkTitle)%%alert.linkTitle%%else%Link%endif%",
"emoji": true
},
"value": "link",
"url": "%alert.link%",
"action_id": "button-action"
}
%endif%
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Priority*: %alert.priority%"
},
{
"type": "mrkdwn",
"text": "*Identifier*: %alert.identifier%"
}
]
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Alert occurred at <!date^%alert.timestamp;format="unix"%^{date_num} {time_secs}|%alert.timestamp%>"
}
]
}
]
>>

Mapping

By default, a mapping is injected as parameters for that template with the following fields:

  • origin: The origin of an alert is the identifier (UUID) of the SubscriptionRoute element. This can be used to create an unsubscribe link.
  • alert: The mapping with all parameters related to the alert message itself. By default the included fields are:
    • title: The title Field of the Alert DataElement.
    • message: The message Field of the Alert DataElement.
    • link: The link Field of the Alert DataElement if it has a value, otherwise null so it can be used in an if-condition in the template to check if it has a value.
    • linkTitle: The linkTitle Field of the Alert DataElement if it has a value, otherwise null so it can be used in an if-condition in the template to check if it has a value.
    • identifier: The identifier Field of the Alert DataElement, with the unique identifier (UUID) of the alert.
    • priority: The name of the AlertPriority that is linked to by the priority Field of the Alert DataElement.
    • timestamp: The enteredAt Field of the Alert DataElement, which has the creation timestamp.
  • channel: The mapping with all parameters related to the alert channel. By default no fields are added to this mapping as the content of the alert should be sufficient in most cases, but sometimes it can be useful to include fields from the channel as well.

Formatting

Formatters are available for several data types in the mappings:

  • String:
    • lower: Convert the string to lowercase.
    • upper: Convert the string to uppercase.
    • no format: The string as given.
  • Date:
    • rfc3339: Convert to RFC3339 compliant timestamp with format yyyy-MM-dd'T'HH:mm:ss.SSSXXX.
    • unix: Convert to numeric Unix timestamp.
    • date: Convert to timestamp containing only the date with format yyyy-MM-dd.
    • time: Convert to timestamp containing only the time (and timezone) with format HH:mm:ss XXX.
    • any other value: If any unlisted value is given, it is used as the format pattern.
    • no format: If no format is given, the date is formatted as EEE, d MMM yyyy HH:mm:ss XXX.
Example

Some examples of formatting:

Date - default:
Template
base() ::= << %alert.timestamp% >>
Output
Sun, 23 Jun 1912 04:15:00 +02:00
Date - unix:
Template
base() ::= << %alert.timestamp;format="unix"% >>
Output
-1815342300
Date - rfc3339:
Template
base() ::= << %alert.timestamp;format="rfc3339"% >>
Output
1912-06-23T04:15:00.000+02:00

Unsubscribing

For some channels it may be useful (or mandatory) to have an unsubscribe feature. The alerting component implements such a feature that can set the active field of a SubscriptionRoute to false, by calling the unsubscribe() method on the agent of SubscriptionRoute with a DataRef for the route to unsubscribe from. The functional key of this element is the identifier (UUID) which is provided to templates as the origin parameter. This parameter can be passed wrapped in a SubscriptionRouteDataRef to unsubscribe.

The idea is that every application that needs an unsubscribe feature will implement this using the technology that is most convenient. This could for example be a REST endpoint that takes the identifier as a parameter, which the endpoint can then use to call the unsubscribe() method on the SubscriptionRoute agent. The template for the alert can use the origin parameter to add a link to that endpoint.