From 0cc53a42b559198db937be0a0a6f23c888cd3b61 Mon Sep 17 00:00:00 2001 From: Dmytro Kulakov Date: Wed, 26 Oct 2022 15:17:31 +0300 Subject: [PATCH] initial commit --- .env.example | 22 +++++ .gitignore | 4 + LICENSE | 21 +++++ README.md | 102 +++++++++++++++++++++- index.js | 181 ++++++++++++++++++++++++++++++++++++++ message-queue.js | 51 +++++++++++ package.json | 32 +++++++ pm2-helper.js | 86 ++++++++++++++++++ scheduler.js | 63 ++++++++++++++ slack-receiver.js | 216 ++++++++++++++++++++++++++++++++++++++++++++++ slack-sender.js | 180 ++++++++++++++++++++++++++++++++++++++ test.js | 0 utils.js | 73 ++++++++++++++++ 13 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 index.js create mode 100644 message-queue.js create mode 100644 package.json create mode 100644 pm2-helper.js create mode 100644 scheduler.js create mode 100644 slack-receiver.js create mode 100644 slack-sender.js delete mode 100644 test.js create mode 100644 utils.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8752f75 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +slack_url=https://hooks.slack.com/services/aaa/aaa/aaa +log=true +error=true +kill=true +exception=false +restart=false +reload=true +delete=false +stop=false +exit=true +start=true +online=true +buffer=true +buffer_seconds=2 +queue_max=100 +buffer_max_seconds=20 +SLACK_SIGNING_SECRET=change +SLACK_BOT_TOKEN=xoxb-aaa-aaa-aaa +SLACK_PORT=6666 +SLACK_ADMINS='aaa aaa' +SLACK_APP_TOKEN=xapp-a-aaa-aaa-aaa +LOGS_PATH='/aaa/aaa/aaa/aaa/aaa/aaa/aaa-out.log' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13cbc09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.DS_Store +.jshintrc +.env \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9a948d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 DMYTRO KULAKOV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index f6ae88d..0ab11f2 100644 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ -# binary-slack \ No newline at end of file +# binary-slack + +This is a PM2 Module for sending events & logs from your PM2 processes to Slack. + +## Install + +To install and setup pm2-slack, run the following commands: + +``` +pm2 install pm2-slack +pm2 set pm2-slack:slack_url https://slack_url +``` + +To get the Slack URL, you need to setup an Incoming Webhook. More details on how to set this up can be found here: https://api.slack.com/incoming-webhooks + +## Events subscription configuration + +The following events can be subscribed to: + +- `log` - All standard out logs from your processes. Default: false +- `error` - All error logs from your processes. Default: true +- `kill` - Event fired when PM2 is killed. Default: true +- `exception` - Any exceptions from your processes. Default: true +- `restart` - Event fired when a process is restarted. Default: false +- `reload` - Event fired when a cluster is reloaded. Default: false +- `delete` - Event fired when a process is removed from PM2. Default: false +- `stop` - Event fired when a process is stopped. Default: false +- `restart overlimit` - Event fired when a process is reaches the max amount of times it can restart. Default: true +- `exit` - Event fired when a process is exited. Default: false +- `start` - Event fired when a process is started. Default: false +- `online` - Event fired when a process is online. Default: false + +You can simply turn these on and off by setting them to true or false using the PM2 set command. + +``` +pm2 set pm2-slack:log true +pm2 set pm2-slack:error false +``` + +## Options + +The following options are available: + +- `slack_url` (string) - Slack Incomming Webhook URL. +- `servername` / `username` (string) - Set the custom username for Slack messages (visible in message headers). Default: server hostname +- `buffer` (bool) - Enable/Disable buffering of messages. Messages that occur in short time will be concatenated together and posted as a single slack message. Default: true +- `buffer_seconds` (int) - If buffering is enables, all messages are stored for this interval. If no new messages comes in this interval, buffered message(s) are sended to Slack. If new message comes in this interval, the "timer" will be reseted and buffer starts waiting for the new interval for a new next message. *Note: Puspose is reduction of push notifications on Slack clients.* Default: 2 +- `buffer_max_seconds` (int) - If time exceed this time, the buffered messages are always sent to Slack, even if new messages are still comming in interval (property `buffer_seconds`). Default: 20 +- `queue_max` (int) - Maximum number of messages, that can be send in one Slack message (in one bufferring round). When the queue exceeds this maximum, next messages are suppresesed and replaced with message "*Next XX messages have been suppressed.*". Default: 100 + +Set these options in the same way as subscribing to events. + + +###### Example + +The following configuration options will enable message buffering, and set the buffer duration to 5 seconds. All messages that occur within maximum 5 seconds delay between two neighboring messages will be concatenated into a single slack message. + +``` +pm2 set pm2-slack:slack_url https://hooks.slack.com/services/123456789/123456789/aaaaaaa +pm2 set pm2-slack:buffer_seconds 5 +``` + +Note: In this example, the maximum total delay for messages is still 20 seconds (default value for `buffer_max_seconds`). After this time, the buffer will be flushed +everytime and all messages will be sent. + +### Process based custom options + +By default, all options are defined for all processes globally. +But you can separately define custom options to each PM2 process. +Use format `pm2-slack:optionName-processName` to process based custom options. + +If no custom options is defined, the global `pm2-slack:propertyName` will be used. + +Note: By this way, all custom options can be used for specific process, but events subsciptions configuration is always global only. + +###### Example + +We have many processes, includes process `foo` and process `bar`. +For this two processes will have to define separate Slack URL channel and separate server name. +Same buffer options will be used for all processed. + +``` +# Define global options for all processes. +pm2 set pm2-slack:buffer_seconds 5 + +# Define global options for all processes. +# (for process `foo` and `bar` the values will be overridden below). +pm2 set pm2-slack:slack_url https://hooks.slack.com/services/123456789/123456789/aaaaaaa +pm2 set pm2-slack:servername Orion + +# Define custom Slack Incomming Webhoook for `foo` process. +pm2 set pm2-slack:slack_url-foo https://hooks.slack.com/services/123456789/123456789/bbbbbbb +pm2 set pm2-slack:servername-foo Foo-server +# Note: The `pm2-slack:buffer_seconds`=5 will be used from global options for this process. + +# Define custom Slack Incomming Webhoook for `bar` process +pm2 set pm2-slack:slack_url-bar https://hooks.slack.com/services/123456789/123456789/ccccccc +pm2 set pm2-slack:servername-foo Bar-server +# Note: The `pm2-slack:buffer_seconds`=5 will be used from global options for this process. +``` + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..929ebb6 --- /dev/null +++ b/index.js @@ -0,0 +1,181 @@ +'use strict'; + +// Dependency +// for local tests: +require('dotenv').config(); +const pm2 = require('pm2'); +const pmx = require('pmx').initModule(); +const app = require('./slack-receiver'); +const MessageQueue = require('./message-queue'); +const { parseIncommingLog, parseProcessName } = require('./utils'); + +/** + * Get the configuration from PM2 + * + * @type {Object} + * @property {boolean} exception + */ + + + +// const process.env = pmx.initModule(); + +const slackUrlRouter = { + /** + * Keys are slackUrls, values are instances of MessageQueue + * + * @typedef {Object.} + */ + messageQueues: {}, + + + /** + * Add the message to appropriate message queue (each Slack URL has own independent message enqueing). + * + * @param {Message} message + */ + addMessage: function (message) { + const processName = message.name; + const slackUrl = process.env['slack_url-' + processName] || process.env['slack_url']; + + if (!slackUrl) { + return; + } + + if (!this.messageQueues[slackUrl]) { + // Init new messageQueue to different Slack URL. + + // Resolve configuration parameters. + const configProperties = ['username', 'servername', 'buffer', 'slack_url', 'buffer_seconds', 'buffer_max_seconds', 'queue_max']; + const config = {}; + configProperties.map((configPropertyName) => { + // Use process based custom configuration values if exist, else use the global configuration values. + config[configPropertyName] = process.env[configPropertyName + '-' + processName] || process.env[configPropertyName]; + }); + + this.messageQueues[slackUrl] = new MessageQueue(config); + } + + this.messageQueues[slackUrl].addMessageToQueue(message); + + } +}; + + +// ----- APP INITIALIZATION ----- + +// Start listening on the PM2 BUS +pm2.launchBus(function (err, bus) { + + // Listen for process logs + if (process.env.log) { + bus.on('log:out', function (data) { + // if (data.process.name === 'pm2-slack-plus') { return; } // Ignore messages of own module. + const parsedLog = parseIncommingLog(data.data); + slackUrlRouter.addMessage({ + name: parseProcessName(data.process), + event: 'log', + description: parsedLog.description, + timestamp: parsedLog.timestamp, + }); + }); + } + + // Listen for process errors + if (process.env.error) { + bus.on('log:err', function (data) { + + + if (data.data.includes('DeprecationWarning')) return; + // if (data.process.name === 'pm2-slack-plus') { return; } // Ignore messages of own module. + const parsedLog = parseIncommingLog(data.data); + slackUrlRouter.addMessage({ + name: parseProcessName(data.process), + event: 'error', + description: parsedLog.description, + timestamp: parsedLog.timestamp, + }); + }); + } + + // Listen for PM2 kill + if (process.env.kill) { + bus.on('pm2:kill', function (data) { + slackUrlRouter.addMessage({ + name: 'PM2', + event: 'kill', + description: data.msg, + timestamp: Math.floor(Date.now() / 1000), + }); + }); + } + + // Listen for process exceptions + if (process.env.exception) { + bus.on('process:exception', function (data) { + if (data.process.name === 'pm2-slack-plus') { return; } // Ignore messages of own module. + + // If it is instance of Error, use it. If type is unknown, stringify it. + const description = (data.data && data.data.message) ? (data.data.code || '') + data.data.message : JSON.stringify(data.data); + slackUrlRouter.addMessage({ + name: parseProcessName(data.process), + event: 'exception', + description: description, + timestamp: Math.floor(Date.now() / 1000), + }); + }); + } + + // Listen for PM2 events + bus.on('process:event', function (data) { + if (!process.env[data.event] && data.event !== 'restart overlimit') { return; } // This event type is disabled by configuration. + if (data.process.name === 'pm2-slack-plus') { return; } // Ignore messages of own module. + + let description = null; + let interactive = []; + switch (data.event) { + case 'start': + case 'online': + description = `${data.process.name} started`; + break; + case 'stop': + description = 'App stopped.'; + break; + case 'restart': + description = 'App restarted.'; + break; + case 'exit': + description = 'App closed.'; + break; + case 'restart overlimit': + description = 'Process has been stopped. Check and fix the issue.'; + break; + + } + slackUrlRouter.addMessage({ + name: parseProcessName(data.process), + event: data.event, + description: description, + interactive: interactive, + timestamp: Math.floor(Date.now() / 1000), + }); + }); +}); + +if (process.env["SLACK_SIGNING_SECRET"] && process.env["SLACK_BOT_TOKEN"] && process.env["SLACK_PORT"]) { + (async () => { + + await app.start(); + + console.log(`Slack bot is ready. Listening on ${process.env["SLACK_PORT"]} port.`); + })(); +} + +/** + * @typedef {Object} Message + * + * @property {string} name - Process name + * @property {string} event - `start`|`stop`|`restart`|`error`|`exception`|`restart overlimit`| ... + * @property {string} description + * @property {number} timestamp - Linux timestamp format + */ \ No newline at end of file diff --git a/message-queue.js b/message-queue.js new file mode 100644 index 0000000..fdcdba5 --- /dev/null +++ b/message-queue.js @@ -0,0 +1,51 @@ +"use strict"; + +// Dependency +const Scheduler = require('./scheduler'); +const slackSender = require('./slack-sender'); + +/** + * + * @param {Object} config + * @param {boolean} config.buffer + * @param {number} config.buffer_seconds + * @param {number} config.buffer_max_seconds + * @param {number} config.queue_max + * @param {number} config.slack_url + * @constructor + */ +function MessageQueue(config) { + this.config = config; + this.messageQueue = []; + this.scheduler = new Scheduler(config); +} + + +/** + * Sends the message to Slack's Incoming Webhook. + * If buffer is enabled, the message is added to queue and sending is postponed for couple of seconds. + * + * @param {Message} message + */ +MessageQueue.prototype.addMessageToQueue = function(message) { + const self = this; + + if (!this.config.buffer || !(this.config.buffer_seconds > 0)) { + // No sending buffer defined. Send directly to Slack. + slackSender.sendToSlack([message], self.config); + } else { + // Add message to buffer + this.messageQueue.push(message); + // Plan send the enqueued messages + this.scheduler.schedule(async function() { + // Remove waiting messages from global queue + const messagesToSend = self.messageQueue.splice(0, self.messageQueue.length); + + slackSender.sendToSlack(messagesToSend, self.config); + }); + } + +} + + +module.exports = MessageQueue; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b762c2 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "binary-slack", + "version": "1.0.0", + "description": "A PM2 module to emit events to Slack and more", + "apps": [ + { + "script": "index.js", + "merge_logs": true, + "max_memory_restart": "200M" + } + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/kulak91/binary-slack" + }, + "keywords": [ + "pm2", + "slack", + "binary" + ], + "license": "MIT", + "dependencies": { + "@slack/bolt": "^3.12.1", + "dotenv": "^16.0.3", + "node-fetch": "2.6.7", + "pm2": "^0.15.10", + "pmx": "^0.5.5" + } +} diff --git a/pm2-helper.js b/pm2-helper.js new file mode 100644 index 0000000..1400c46 --- /dev/null +++ b/pm2-helper.js @@ -0,0 +1,86 @@ +const pm2 = require('pm2'); + +const connectAsync = () => { + return new Promise((resolve, reject) => { + pm2.connect((err) => { + if (err) reject(err); + resolve(); + }); + }); +}; + +const listAsync = () => { + return new Promise((resolve, reject) => { + pm2.list((err, processDescriptionList) => { + if (err) reject(err); + resolve(processDescriptionList); + }); + }); +}; + +const restartAsync = (process) => { + return new Promise((resolve, reject) => { + pm2.restart(process, (err, proc) => { + if (err) reject(err); + resolve(proc); + }); + }); +}; + +const describeAsync = (process) => { + return new Promise((resolve, reject) => { + pm2.describe(process, (err, proc) => { + if (err) reject(err); + resolve(proc[0]); + }); + }); +}; + +async function list() { + try { + await connectAsync(); + + let response = await listAsync(); + + return { err: undefined, response }; + } catch (err) { + return { err, response: undefined }; + } finally { + pm2.disconnect(); + } +} + +async function restart(process) { + try { + await connectAsync(); + let _test = await describeAsync(process); + console.log('test: ', _test); + let response = await restartAsync(process); + console.log('response restart: ', response); + return { err: undefined, response }; + } catch (err) { + return { err, response: undefined }; + } finally { + pm2.disconnect(); + } +} + +/** + * Returns various information about a process: eg what stdout/stderr and pid files are used. + * + * @export + * @param {(string|number)} process + * @returns {Promise} + */ +async function describe(process) { + try { + await connectAsync(); + let response = await describeAsync(process); + return { err: undefined, response }; + } catch (err) { + return { err, response: undefined }; + } finally { + pm2.disconnect(); + } +} +module.exports = { list, restart, describe }; \ No newline at end of file diff --git a/scheduler.js b/scheduler.js new file mode 100644 index 0000000..d82ff65 --- /dev/null +++ b/scheduler.js @@ -0,0 +1,63 @@ +'use strict'; + + +/** + * Adds ability to postpone the execution of some function. + * If new postpone is requested, old schedule will be cancelling. So max. one schedule can exists in one time. + * + * Configuration: + * + * - Postponing time is defined in `this.config.buffer_seconds`. + * - + */ +function Scheduler(config) { + const self = this; + + /** @private */ + self._timeoutId = null; + + /** @private */ + self._totalPostponingSeconds = 0; + + + /** + * @property {number} buffer_seconds - Postponing time. If it is zero, the callback is always executed immediately. + * + * @property {number} buffer_max_seconds - If is defined, postponning is limited to this total time. + * So when the new postponings are request and it will exceed this value, it will be ignored. + */ + self.config = config; +}; + + +/** + * Plan the postponed execution of callback function. + * If some plan exists, it will be cancelled and replaced by the new one. + * + * @param {function} callback + */ +Scheduler.prototype.schedule = function(callback) { + const self = this; + + if (self.config.buffer_max_seconds && (self.config.buffer_max_seconds <= self._totalPostponingSeconds + self.config.buffer_seconds)) { + // Max buffer time reached. Do not replan sending. + return; + } + + // If previous sending is planned, cancel it. + if (self._timeoutId) { + clearTimeout(this._timeoutId); + } + // Plan the message sending after timeout + self._timeoutId = setTimeout(function() { + self._timeoutId = null; + self._totalPostponingSeconds = 0; + + callback(); + }, self.config.buffer_seconds * 1000); + self._totalPostponingSeconds += this.config.buffer_seconds; + +}; + + +module.exports = Scheduler; \ No newline at end of file diff --git a/slack-receiver.js b/slack-receiver.js new file mode 100644 index 0000000..a9c0c3e --- /dev/null +++ b/slack-receiver.js @@ -0,0 +1,216 @@ +const { App } = require('@slack/bolt'); +const fs = require('fs/promises'); +require('dotenv').config(); +const { list, restart, describe } = require('./pm2-helper'); +const { timeSince } = require('./utils'); +const path = require('path'); +const exec = require('shelljs').exec; + +// const pmx = require('pmx'); +// const process.env = pmx.initModule(); + +const adminUsers = process.env?.SLACK_ADMINS.split(' '); + + +const app = new App({ + token: process.env["SLACK_BOT_TOKEN"], + signingSecret: process.env["SLACK_SIGNING_SECRET"], + port: process.env["SLACK_PORT"] || 6666, + appToken: process.env["SLACK_APP_TOKEN"], + socketMode: true +}); + + + +app.message('hi', async ({ message, say }) => { + + await say(`Hey there <@${message.user}>!\nIf you want to see the list of available commands type in chat: "help"`); +}); + +app.message('help', async ({ message, say }) => { + await say(`*List of available commands:*\ntype 'list' - to see the list of PM2 processes\ntype 'emergency_stop' - to stop PM2 processes\ntype 'info_app' - to upload the lastest log file`); +}); + +app.message('emergency_stop', async ({ message, say }) => { + await say({ + "text": "Are you sure you want to stop ecosystem.config file?", + "attachments": [ + { + "text": "Please confirm", + "fallback": "Confirm stop", + "callback_id": "stop_ecosystem", + "color": "#3AA3E3", + "attachment_type": "default", + "actions": [ + { + "name": "game", + "text": "Thermonuclear War", + "style": "danger", + "type": "button", + "value": "stop_ecosystem_confirm", + "confirm": { + "title": "Are you sure?", + "text": "This will stop server process.", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + } + ] + }) +}); + +app.message('list', async ({ message, say }) => { + const status = { + online: "\u{1F7E2}", + stopping: "\u{1F6AB}", + stopped: "\u{1F6AB}", + launching: "\u{267B}", + errored: "\u{1F198}", + }; + + let { err, response } = await list(); + if (err) return await say(err); + + const answer = { + "text": "List of Processes", + "blocks": [] + } + for (const proc of response) { + answer.blocks.push({ + "type": "header", + "text": { + "type": "plain_text", + "text": `${proc.name === 'pm2-slack-plus' ? proc.name + ' - MODULE' : proc.name}`, + "emoji": true + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": `*Status:* ${proc.pm2_env.status} ${status[proc.pm2_env.status] || ""}` + }, + { + "type": "mrkdwn", + "text": `*Uptime:* ${proc.pm2_env.status === "online" ? timeSince(proc.pm2_env.pm_uptime) : '0'}` + }, + { + "type": "mrkdwn", + "text": `*MEM:* ${Math.round(proc.monit.memory / 1024 / 1024)}Mb` + }, + { + "type": "mrkdwn", + "text": `*Restarts Count:* ${proc.pm2_env.restart_time}`, + }, + { + "type": "mrkdwn", + "text": `*CPU:* ${proc.monit.cpu} %` + }, + { + "type": "mrkdwn", + "text": `*ID:* ${proc.pm_id}` + }, + ] + } + ) + // if (proc.pm2_env.status === "errored") { + // answer.blocks.push() + // } + } + answer.blocks.push( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": `Force reload *ecosystem.config* : ` + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Reload", + "emoji": true + }, + "style": "primary", + "value": `reload`, + "action_id": `button-reload` + } + }) + await say(answer) +} +); + +app.action('button-reload', async ({ body, ack, say }) => { + await ack(); + + await say(`<@${body.user.id}> wants to restart ecosystem`); + + if (!adminUsers.find(user => user === body.user.id)) { + await say(`But <@${body.user.name}> has no permissions to reload.`); + return; + } + + // const response = await restart('app'); + + const { err, response } = await describe('app'); + + if (!response) { + await say('App is not running. Please start ecosystem manually.') + return; + } + + const serverPath = path.resolve(response?.pm2_env?.pm_cwd); + const child = exec(`cd ${serverPath}; pm2 reload ecosystem.config.js`, { async: true }); + + child.stdout.on('end', async function () { + await say('Reloaded.'); + }); +}); + +app.message('thx', async ({ message, say }) => { + await say(`You're welcome <@${message.user}>!`); +}); + + +app.action({ callback_id: 'stop_ecosystem' }, async ({ body, ack, say }) => { + await ack(); + + if (!adminUsers.find(user => user === body.user.id)) { + await say(`<@${body.user.name}> has no permissions to reload.`); + return; + } + + const { err, response } = await describe('app'); + + if (!response) { + await say('App is not running. Please start ecosystem manually.') + return; + } + + const serverPath = path.resolve(response?.pm2_env?.pm_cwd); + const child = exec(`cd ${serverPath}; pm2 stop ecosystem.config.js`, { async: true }); + + + await say('Process stopped.'); +}) + +app.message('info_app', async ({ message, client, say, payload }) => { + + try { + const date = new Date(); + // const filePath = '/root/app/logs/strapi-out.log'; + const filePath = process.env.LOGS_PATH; + const data = await fs.readFile(filePath); + const fileName = 'strapi-out.log'; + await client.files.upload({ file: data, channels: message.channel, title: date.toLocaleString(), filename: fileName, token: process.env["SLACK_BOT_TOKEN"] }); + } catch (err) { + console.log(err); + } + +}); + + +module.exports = app; \ No newline at end of file diff --git a/slack-sender.js b/slack-sender.js new file mode 100644 index 0000000..e612004 --- /dev/null +++ b/slack-sender.js @@ -0,0 +1,180 @@ +"use strict"; + + +// Exports +module.exports = { sendToSlack }; + + +// Dependency +// const request = require('request'); +const os = require('os'); +const fetch = require('node-fetch'); + +// Constants +// The events that will trigger the color red +const redEvents = ['stop', 'exit', 'delete', 'error', 'kill', 'exception', 'restart overlimit', 'suppressed']; +const redColor = '#F44336'; +const commonColor = '#2196F3'; + + +/** + * Sends immediately the message(s) to Slack's Incoming Webhook. + * + * @param {Message[]} messages - List of messages, ready to send. + * This list can be trimmed and concated base on module configuration. + */ +async function sendToSlack(messages, config) { + + // If a Slack URL is not set, we do not want to continue and nofify the user that it needs to be set + if (!config.slack_url) { + return console.error("There is no Slack URL set, please set the Slack URL: 'pm2 set pm2-slack-plus:slack_url https://slack_url'"); + } + + let limitedCountOfMessages; + if (config.queue_max > 0) { + // Limit count of messages for sending + limitedCountOfMessages = messages.splice(0, Math.min(config.queue_max, messages.length)); + } else { + // Select all messages for sending + limitedCountOfMessages = messages; + } + + // The JSON payload to send to the Webhook + let payload = { + username: config.username || config.servername || os.hostname(), + attachments: [] + }; + + + // Merge together all messages from same process and with same event + // Convert messages to Slack message's attachments + payload.attachments = convertMessagesToSlackAttachments(mergeSimilarMessages(limitedCountOfMessages)); + + // Because Slack`s notification text displays the fallback text of first attachment only, + // add list of message types to better overview about complex message in mobile notifications. + + if (payload.attachments.length > 1) { + payload.text = payload.attachments + .map(function (/*SlackAttachment*/ attachment) { return attachment.title; }) + .join(", "); + } + + // Group together all messages with same title. + // payload.attachments = groupSameSlackAttachmentTypes(payload.attachments); + + // Add warning, if some messages has been suppresed + if (messages.length > 0) { + let text = 'Next ' + messages.length + ' message' + (messages.length > 1 ? 's have ' : ' has ') + 'been suppressed.'; + payload.attachments.push({ + fallback: text, + // color: redColor, + title: 'message rate limitation', + text: text, + ts: Math.floor(Date.now() / 1000), + }); + } + + // Options for the post request + const requestOptions = { + method: 'post', + body: payload, + json: true, + url: config.slack_url, + }; + + // Finally, make the post request to the Slack Incoming Webhook + + // request(requestOptions, function(err, res, body) { + // if (err) return console.error(err); + // if (body !== 'ok') { + // console.error('Error sending notification to Slack, verify that the Slack URL for incoming webhooks is correct. ' + messages.length + ' unsended message(s) lost.'); + // } + // }); + + const response = await fetch(requestOptions.url, { + method: requestOptions.method, + body: JSON.stringify(requestOptions.body), + headers: { 'Content-Type': 'application/json' } + }); +} + + +/** + * Merge together all messages from same process and with same event + * + * @param {Messages[]} messages + * @returns {Messages[]} + */ +function mergeSimilarMessages(messages) { + return messages.reduce(function (/*Message[]*/ finalMessages, /*Message*/ currentMessage) { + if (finalMessages.length > 0 + && finalMessages[finalMessages.length - 1].name === currentMessage.name + && finalMessages[finalMessages.length - 1].event === currentMessage.event + ) { + // Current message has same title as previous one. Concate it. + finalMessages[finalMessages.length - 1].description += "\n" + currentMessage.description; + } else { + // Current message is different than previous one. + finalMessages.push(currentMessage); + } + return finalMessages; + }, []); +} + + +/** + * Converts messages to json format, that can be sent as Slack message's attachments. + * + * @param {Message[]} messages + * @returns {SlackAttachment[]} + */ +function convertMessagesToSlackAttachments(messages) { + return messages.reduce(function (slackAttachments, message) { + + // The default color for events should be green + let color = commonColor; + // If the event is listed in redEvents, set the color to red + if (redEvents.indexOf(message.event) > -1) { + color = redColor; + } + + const title = `${message.name} ${message.event}`; + const description = (message.description || '').trim(); + const interactive = message?.interactive || ['']; + const fallbackText = title + (description ? ': ' + description.replace(/[\r\n]+/g, ', ') : ''); + slackAttachments.push({ + fallback: escapeSlackText(fallbackText), + color: color, + title: escapeSlackText(title), + text: escapeSlackText(description), + actions: interactive, + ts: message.timestamp, + // footer: message.name, + }); + + return slackAttachments; + }, []); +} + + +/** + * Escapes the plain text before sending to Slack's Incoming webhook. + * @see https://api.slack.com/docs/message-formatting#how_to_escape_characters + * + * @param {string} text + * @returns {string} + */ +function escapeSlackText(text) { + return (text || '').replace('&', '&').replace('<', '<').replace('>', '>'); +} + + +/** + * @typedef {Object} SlackAttachment + * + * @property {string} fallback + * @property {string} title + * @property {string} [color] + * @property {string} [text] + * @property {number} ts - Linux timestamp format + */ \ No newline at end of file diff --git a/test.js b/test.js deleted file mode 100644 index e69de29..0000000 diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..c844cd1 --- /dev/null +++ b/utils.js @@ -0,0 +1,73 @@ +function timeSince(timestamp) { + let diff = (new Date().getTime() - parseInt(timestamp)) / 1000; + let seconds = diff; + let minutes = 0; + let hours = 0; + let days = 0; + let str = `${Math.abs(Math.round(seconds))}s`; + if (seconds > 60) { + seconds = Math.abs(Math.round(diff % 60)); + minutes = Math.abs(Math.round((diff /= 60))); + str = `${minutes}m ${seconds}s`; + } + if (minutes > 60) { + minutes = Math.abs(Math.round(diff % 60)); + hours = Math.abs(Math.round(diff / 60)); + str = `${hours}h ${minutes}m`; + } + if (hours > 24) { + days = Math.abs(Math.round(hours / 24)); + hours = Math.abs(Math.round(hours % 24)); + str = `${days}d ${hours}h`; + } + return str; +} + +/** + * New PM2 is storing log messages with date in format "YYYY-MM-DD hh:mm:ss +-zz:zz" + * Parses this date from begin of message + * + * @param {string} logMessage + * @returns {{description:string|null, timestamp:number|null}} + */ + function parseIncommingLog(logMessage) { + let description = null; + let timestamp = null; + + if (typeof logMessage === "string") { + // Parse date on begin (if exists) + const dateRegex = /([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{1,2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})? [+\-]?[0-9]{1,2}:[0-9]{2}(\.[0-9]{3})?)[:\-\s]+/; + const parsedDescription = dateRegex.exec(logMessage); + // Note: The `parsedDescription[0]` is datetime with separator(s) on the end. + // The `parsedDescription[1]` is datetime only (without separators). + // The `parsedDescription[2]` are ".microseconds" + if (parsedDescription && parsedDescription.length >= 2) { + // Use timestamp from message + timestamp = Math.floor(Date.parse(parsedDescription[1]) / 1000); + // Use message without date + description = logMessage.replace(parsedDescription[0], ""); + } else { + // Use whole original message + description = logMessage; + } + } + + return { + description: description, + timestamp: timestamp + } +} + +/** + * Get pm2 app display name. + * If the app is running in cluster mode, id will append [pm_id] as the suffix. + * + * @param {object} process + * @returns {string} name + */ + function parseProcessName(process) { + return process.name + (process.exec_mode === 'cluster_mode' && process.instances > 1 ? `[${process.pm_id}]` : ''); +} + + +module.exports = { timeSince, parseIncommingLog, parseProcessName }; \ No newline at end of file