openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
420 lines (365 loc) • 14.3 kB
JavaScript
import logger from 'winston'
import moment from 'moment'
import _ from 'lodash'
import * as contact from './contact'
import { config } from './config'
import { EventModel } from './model/events'
import { ContactGroupModel } from './model/contactGroups'
import { AlertModel } from './model/alerts'
import { UserModel } from './model/users'
import * as utils from './utils'
import * as Channels from './model/channels'
config.alerts = config.get('alerts')
const {ChannelModel} = Channels
const trxURL = trx => `${config.alerts.consoleURL}/#/transactions/${trx.transactionID}`
const statusTemplate = (transactions, channel, alert) =>
({
plain () {
return `\
OpenHIM Transactions Alert
The following transaction(s) have completed with status ${alert.status} on the OpenHIM instance running on ${config.alerts.himInstance}:
Channel - ${channel.name}
${(transactions.map(trx => trxURL(trx))).join('\n')}
\
`
},
html () {
let text = `\
<html>
<head></head>
<body>
<h1>OpenHIM Transactions Alert</h1>
<div>
<p>The following transaction(s) have completed with status <b>${alert.status}</b> on the OpenHIM instance running on <b>${config.alerts.himInstance}</b>:</p>
<table>
<tr><td>Channel - <b>${channel.name}</b></td></td>\n\
`
text += (transactions.map(trx => ` <tr><td><a href='${trxURL(trx)}'>${trxURL(trx)}</a></td></tr>`)).join('\n')
text += '\n'
text += `\
</table>
</div>
</body>
</html>\
`
return text
},
sms () {
let text = 'Alert - '
if (transactions.length > 1) {
text += `${transactions.length} transactions have`
} else if (transactions.length === 1) {
text += '1 transaction has'
} else {
text += 'no transactions have'
}
text += ` completed with status ${alert.status} on the OpenHIM running on ${config.alerts.himInstance} (${channel.name})`
return text
}
})
const maxRetriesTemplate = (transactions, channel) =>
({
plain () {
return `\
OpenHIM Transactions Alert - ${config.alerts.himInstance}
The following transaction(s) have been retried ${channel.autoRetryMaxAttempts} times, but are still failing:
Channel - ${channel.name}
${(transactions.map(trx => trxURL(trx))).join('\n')}
Please note that they will not be retried any further by the OpenHIM automatically.\
`
},
html () {
let text = `\
<html>
<head></head>
<body>
<h1>OpenHIM Transactions Alert - ${config.alerts.himInstance}</h1>
<div>
<p>The following transaction(s) have been retried <b>${channel.autoRetryMaxAttempts}</b> times, but are still failing:</p>
<table>
<tr><td>Channel - <b>${channel.name}</b></td></td>\n\
`
text += (transactions.map(trx => ` <tr><td><a href='${trxURL(trx)}'>${trxURL(trx)}</a></td></tr>`)).join('\n')
text += '\n'
text += `\
</table>
<p>Please note that they will not be retried any further by the OpenHIM automatically.</p>
</div>
</body>
</html>\
`
return text
},
sms () {
let text = 'Alert - '
if (transactions.length > 1) {
text += `${transactions.length} transactions have`
} else if (transactions.length === 1) {
text += '1 transaction has'
}
text += ` been retried ${channel.autoRetryMaxAttempts} times but are still failing on the OpenHIM on ${config.alerts.himInstance} (${channel.name})`
return text
}
})
const getAllChannels = callback => ChannelModel.find({}, callback)
const findGroup = (groupID, callback) => ContactGroupModel.findOne({_id: groupID}, callback)
const findTransactions = (channel, dateFrom, status, callback) =>
EventModel
.find({
created: {
$gte: dateFrom
},
channelID: channel._id,
event: 'end',
status,
type: 'channel'
}, {transactionID: 'transactionID'})
.hint({created: 1})
.exec(callback)
const countTotalTransactionsForChannel = (channel, dateFrom, callback) =>
EventModel.count({
created: {
$gte: dateFrom
},
channelID: channel._id,
type: 'channel',
event: 'end'
}, callback)
function findOneAlert (channel, alert, dateFrom, user, alertStatus, callback) {
const criteria = {
timestamp: {$gte: dateFrom},
channelID: channel._id,
condition: alert.condition,
status: alert.condition === 'auto-retry-max-attempted' ? '500' : alert.status,
alertStatus
}
if (user) { criteria.user = user }
return AlertModel
.findOne(criteria)
.exec(callback)
}
function findTransactionsMatchingStatus (channel, alert, dateFrom, callback) {
let statusMatch
const pat = /\dxx/.exec(alert.status)
if (pat) {
statusMatch = {$gte: alert.status[0] * 100, $lt: (alert.status[0] * 100) + 100}
} else {
statusMatch = alert.status
}
let dateToCheck = dateFrom
// check last hour when using failureRate
if (alert.failureRate != null) { dateToCheck = moment().subtract(1, 'hours').toDate() }
return findTransactions(channel, dateToCheck, statusMatch, (err, results) => {
if (!err && (results != null) && (alert.failureRate != null)) {
// Get count of total transactions and work out failure ratio
const _countStart = new Date()
return countTotalTransactionsForChannel(channel, dateToCheck, (err, count) => {
logger.debug(`.countTotalTransactionsForChannel: ${new Date() - _countStart} ms`)
if (err) { return callback(err, null) }
const failureRatio = (results.length / count) * 100.0
if (failureRatio >= alert.failureRate) {
return findOneAlert(channel, alert, dateToCheck, null, 'Completed', (err, userAlert) => {
if (err) { return callback(err, null) }
// Has an alert already been sent this last hour?
if (userAlert != null) {
return callback(err, [])
}
return callback(err, utils.uniqArray(results))
})
}
return callback(err, [])
})
}
return callback(err, results)
})
}
const findTransactionsMaxRetried = (channel, alert, dateFrom, callback) =>
EventModel
.find({
created: {
$gte: dateFrom
},
channelID: channel._id,
event: 'end',
type: 'channel',
status: 500,
autoRetryAttempt: channel.autoRetryMaxAttempts
}, {transactionID: 'transactionID'})
// .hint({created: 1})
.exec((err, transactions) => {
if (err) { return callback(err) }
return callback(null, _.uniqWith(transactions, (a, b) => a.transactionID.equals(b.transactionID)))
})
function findTransactionsMatchingCondition (channel, alert, dateFrom, callback) {
if (!alert.condition || (alert.condition === 'status')) {
return findTransactionsMatchingStatus(channel, alert, dateFrom, callback)
} else if (alert.condition === 'auto-retry-max-attempted') {
return findTransactionsMaxRetried(channel, alert, dateFrom, callback)
}
return callback(new Error(`Unsupported condition '${alert.condition}'`))
}
function calcDateFromForUser (user) {
if (user.maxAlerts === '1 per hour') {
return moment().subtract(1, 'hours').toDate()
} else if (user.maxAlerts === '1 per day') {
return moment().startOf('day').toDate()
}
return null
}
function userAlreadyReceivedAlert (channel, alert, user, callback) {
if (!user.maxAlerts || (user.maxAlerts === 'no max')) {
// user gets all alerts
return callback(null, false)
}
const dateFrom = calcDateFromForUser(user)
if (!dateFrom) { return callback(new Error(`Unsupported option 'maxAlerts=${user.maxAlerts}'`)) }
return findOneAlert(channel, alert, dateFrom, user.user, 'Completed', (err, userAlert) => callback(err != null ? err : null, !!userAlert))
}
// Setup the list of transactions for alerting.
//
// Fetch earlier transactions if a user is setup with maxAlerts.
// If the user has no maxAlerts limit, then the transactions object is returned as is.
function getTransactionsForAlert (channel, alert, user, transactions, callback) {
if (!user.maxAlerts || (user.maxAlerts === 'no max')) {
return callback(null, transactions)
}
const dateFrom = calcDateFromForUser(user)
if (!dateFrom) { return callback(new Error(`Unsupported option 'maxAlerts=${user.maxAlerts}'`)) }
return findTransactionsMatchingCondition(channel, alert, dateFrom, callback)
}
const sendAlert = (channel, alert, user, transactions, contactHandler, done) =>
UserModel.findOne({email: user.user}, (err, dbUser) => {
if (err) { return done(err) }
if (!dbUser) { return done(`Cannot send alert: Unknown user '${user.user}'`) }
return userAlreadyReceivedAlert(channel, alert, user, (err, received) => {
if (err) { return done(err, true) }
if (received) { return done(null, true) }
logger.info(`Sending alert for user '${user.user}' using method '${user.method}'`)
return getTransactionsForAlert(channel, alert, user, transactions, (err, transactionsForAlert) => {
if (err) { done(err) }
let template = statusTemplate(transactionsForAlert, channel, alert)
if (alert.condition === 'auto-retry-max-attempted') {
template = maxRetriesTemplate(transactionsForAlert, channel, alert)
}
if (user.method === 'email') {
const plainMsg = template.plain()
const htmlMsg = template.html()
return contactHandler('email', user.user, 'OpenHIM Alert', plainMsg, htmlMsg, done)
} else if (user.method === 'sms') {
if (!dbUser.msisdn) { return done(`Cannot send alert: MSISDN not specified for user '${user.user}'`) }
const smsMsg = template.sms()
return contactHandler('sms', dbUser.msisdn, 'OpenHIM Alert', smsMsg, null, done)
}
return done(`Unknown method '${user.method}' specified for user '${user.user}'`)
})
})
})
// Actions to take after sending an alert
function afterSendAlert (err, channel, alert, user, transactions, skipSave, done) {
if (err) { logger.error(err) }
if (!skipSave) {
alert = new AlertModel({
user: user.user,
method: user.method,
channelID: channel._id,
condition: alert.condition,
status: alert.condition === 'auto-retry-max-attempted' ? '500' : alert.status,
alertStatus: err ? 'Failed' : 'Completed'
})
return alert.save((err) => {
if (err) { logger.error(err) }
return done()
})
}
return done()
}
function sendAlerts (channel, alert, transactions, contactHandler, done) {
// Each group check creates one promise that needs to be resolved.
// For each group, the promise is only resolved when an alert is sent and stored
// for each user in that group. This resolution is managed by a promise set for that group.
//
// For individual users in the alert object (not part of a group),
// a promise is resolved per user when the alert is both sent and stored.
const promises = []
const _alertStart = new Date()
if (alert.groups) {
for (const group of Array.from(alert.groups)) {
const groupDefer = new Promise((resolve, reject) => {
findGroup(group, (err, result) => {
if (err) {
logger.error(err)
return resolve()
}
const groupUserPromises = Array.from(result.users).map((user) => {
return new Promise((resolve) => {
sendAlert(channel, alert, user, transactions, contactHandler, (err, skipSave) => {
afterSendAlert(err, channel, alert, user, transactions, skipSave, () => resolve())
})
})
})
return Promise.all(groupUserPromises).then(() => resolve())
})
})
promises.push(groupDefer)
}
}
if (alert.users) {
Array.from(alert.users).forEach((user) => {
const userDefer = new Promise((resolve) => {
sendAlert(channel, alert, user, transactions, contactHandler, (err, skipSave) => {
afterSendAlert(err, channel, alert, user, transactions, skipSave, () => resolve())
})
})
promises.push(userDefer)
})
}
return Promise.all(promises).then(() => {
logger.debug(`.sendAlerts: ${new Date() - _alertStart} ms`)
return done()
})
}
function alertingTask (job, contactHandler, done) {
if (!job.attrs.data) { job.attrs.data = {} }
const lastAlertDate = job.attrs.data.lastAlertDate != null ? job.attrs.data.lastAlertDate : new Date()
const _taskStart = new Date()
return getAllChannels((err, results) => {
if (err) { return done(err) }
const promises = []
for (const channel of Array.from(results)) {
if (Channels.isChannelEnabled(channel)) {
for (const alert of Array.from(channel.alerts)) {
(function (channel, alert) {
const deferred = new Promise((resolve) => {
const _findStart = new Date()
findTransactionsMatchingCondition(channel, alert, lastAlertDate, (err, results) => {
logger.debug(`.findTransactionsMatchingStatus: ${new Date() - _findStart} ms`)
if (err) {
logger.error(err)
return resolve()
} else if ((results != null) && (results.length > 0)) {
return sendAlerts(channel, alert, results, contactHandler, () => resolve())
}
return resolve()
})
})
return promises.push(deferred)
}(channel, alert))
}
}
}
return Promise.all(promises).then(() => {
job.attrs.data.lastAlertDate = new Date()
logger.debug(`Alerting task total time: ${new Date() - _taskStart} ms`)
return done()
})
})
}
export function setupAgenda (agenda) {
agenda.define('generate transaction alerts', (job, done) => alertingTask(job, contact.contactUser, done))
return agenda.every(`${config.alerts.pollPeriodMinutes} minutes`, 'generate transaction alerts')
}
if (process.env.NODE_ENV === 'test') {
exports.findTransactionsMatchingStatus = findTransactionsMatchingStatus
exports.findTransactionsMaxRetried = findTransactionsMaxRetried
exports.alertingTask = alertingTask
}