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.
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.
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.
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.
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.
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.
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 theSubscriptionRoute
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
: Thetitle
Field of theAlert
DataElement.message
: Themessage
Field of theAlert
DataElement.link
: Thelink
Field of theAlert
DataElement if it has a value, otherwisenull
so it can be used in an if-condition in the template to check if it has a value.linkTitle
: ThelinkTitle
Field of theAlert
DataElement if it has a value, otherwisenull
so it can be used in an if-condition in the template to check if it has a value.identifier
: Theidentifier
Field of theAlert
DataElement, with the unique identifier (UUID) of the alert.priority
: The name of the AlertPriority that is linked to by thepriority
Field of theAlert
DataElement.timestamp
: TheenteredAt
Field of theAlert
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 formatyyyy-MM-dd'T'HH:mm:ss.SSSXXX
.unix
: Convert to numeric Unix timestamp.date
: Convert to timestamp containing only the date with formatyyyy-MM-dd
.time
: Convert to timestamp containing only the time (and timezone) with formatHH: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
.
Some examples of formatting:
Date - default:
base() ::= << %alert.timestamp% >>
Sun, 23 Jun 1912 04:15:00 +02:00
Date - unix:
base() ::= << %alert.timestamp;format="unix"% >>
-1815342300
Date - rfc3339:
base() ::= << %alert.timestamp;format="rfc3339"% >>
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.