@nivinjoseph/n-log
Version:
Logging framework
332 lines • 12.6 kB
JavaScript
import { given } from "@nivinjoseph/n-defensive";
import { Delay, Duration, Make, Mutex } from "@nivinjoseph/n-util";
import SlackWebApi from "@slack/web-api";
import { BaseLogger } from "./base-logger.js";
/**
* Logger implementation that posts logs to a Slack channel.
* Features:
* - Posts logs as formatted Slack messages with colors
* - Configurable log level filtering
* - Customizable bot appearance
* - Batches messages and sends them every 30 seconds
* - Fallback logger support for error handling
* - Debug logs only posted in development environment
*/
export class SlackLogger extends BaseLogger {
_includeInfo;
_includeWarn;
_includeError;
_logFilter;
_fallbackLogger;
_slackWebClient;
_channel;
_userName;
_userImage = ":robot_face:";
_userImageIsEmoji;
_flushMutex = new Mutex();
_messages = new Array();
_timer;
_isDisposed = false;
_disposePromise = null;
_warnedAfterDispose = false;
/**
* Creates a new instance of SlackLogger
* @param config - Configuration for the Slack logger
*/
constructor(config) {
super(config);
// eslint-disable-next-line @typescript-eslint/unbound-method
const { slackBotToken, slackBotChannel, slackUserName, slackUserImage, logFilter } = config;
given(slackBotToken, "slackBotToken").ensureHasValue().ensureIsString();
this._slackWebClient = new SlackWebApi.WebClient(slackBotToken);
given(slackBotChannel, "slackBotChannel").ensureHasValue().ensureIsString();
this._channel = slackBotChannel;
given(slackUserName, "slackUserName").ensureIsString();
if (slackUserName != null && slackUserName.isNotEmptyOrWhiteSpace())
this._userName = slackUserName;
else
this._userName = this.service;
given(slackUserImage, "slackUserImage").ensureIsString();
if (slackUserImage != null && slackUserImage.isNotEmptyOrWhiteSpace())
this._userImage = slackUserImage.trim();
this._userImageIsEmoji = this._userImage.startsWith(":") && this._userImage.endsWith(":");
const allFilters = ["Info", "Warn", "Error"];
const filter = config.filter ?? allFilters;
given(filter, "filter").ensureIsArray().ensure(t => t.every(u => allFilters.contains(u)));
this._includeInfo = filter.contains("Info");
this._includeWarn = filter.contains("Warn");
this._includeError = filter.contains("Error");
given(logFilter, "logFilter").ensureIsFunction();
this._logFilter = logFilter ?? ((_) => true);
this._fallbackLogger = config.fallback ?? null;
this._timer = this._createLogFlushTimeout();
}
/**
* Logs a debug message to Slack.
* Only posts in development environment.
* @param debug - The debug message to log
* @returns A promise that resolves when the log is queued
*/
async logDebug(debug) {
if (this._isDisposedDrop())
return;
if (this.env === "dev") {
let log = {
source: this.source,
service: this.service,
env: this.env,
level: "Debug",
message: debug,
...this.getDateTime(),
color: "#F8F8F8"
};
if (this.logInjector)
log = this.logInjector(log);
this._messages.push(log);
}
}
/**
* Logs an informational message to Slack in green.
* @param info - The informational message to log
* @returns A promise that resolves when the log is queued
*/
async logInfo(info) {
if (this._isDisposedDrop())
return;
if (!this._includeInfo)
return;
let log = {
source: this.source,
service: this.service,
env: this.env,
level: "Info",
message: info,
...this.getDateTime(),
color: "#259D2F"
};
if (!this._logFilter(log))
return;
if (this.logInjector)
log = this.logInjector(log);
this._messages.push(log);
}
/**
* Logs a warning message or exception to Slack in yellow.
* @param warning - The warning message or exception to log
* @returns A promise that resolves when the log is queued
*/
async logWarning(warning) {
if (this._isDisposedDrop())
return;
if (!this._includeWarn)
return;
let log = {
source: this.source,
service: this.service,
env: this.env,
level: "Warn",
message: this.getErrorMessage(warning),
...this.getDateTime(),
color: "#F1AB2A"
};
if (!this._logFilter(log))
return;
if (this.logInjector)
log = this.logInjector(log);
this._messages.push(log);
}
/**
* Logs an error message or exception to Slack in red.
* @param error - The error message or exception to log
* @returns A promise that resolves when the log is queued
*/
async logError(error) {
if (this._isDisposedDrop())
return;
if (!this._includeError)
return;
let log = {
source: this.source,
service: this.service,
env: this.env,
level: "Error",
message: this.getErrorMessage(error),
...this.getDateTime(),
color: "#EF401D"
};
if (!this._logFilter(log))
return;
if (this.logInjector)
log = this.logInjector(log);
this._messages.push(log);
}
/**
* Disposes the logger, flushing any remaining messages.
* @returns A promise that resolves when disposal is complete
*/
dispose() {
if (!this._isDisposed) {
this._isDisposed = true;
clearTimeout(this._timer);
this._disposePromise = this._flushMessages();
}
return this._disposePromise;
}
_createLogFlushTimeout() {
return setTimeout(() => {
this._flushMessages()
.catch(e => this._fallbackLogger?.logError(e).catch(e => console.error(e)) ?? console.error(e));
}, Duration.fromSeconds(15).toMilliSeconds());
}
/**
* Returns true if the logger has been disposed and the caller should drop
* the message. Emits a one-shot warning to stderr the first time a log
* call is seen after dispose so the misuse is visible without spamming.
*/
_isDisposedDrop() {
if (!this._isDisposed)
return false;
if (!this._warnedAfterDispose) {
this._warnedAfterDispose = true;
console.warn("SlackLogger: log call after dispose; message dropped. Further warnings suppressed.");
}
return true;
}
/**
* Flushes queued messages to Slack.
* Serialized via a mutex so concurrent invocations (timer + dispose,
* overlapping timer ticks) cannot interleave or post out of order.
* Drains the queue fully, posting in batches of 20 with a 1s gap
* between batches to stay under Slack's rate limit.
* @returns A promise that resolves when messages are flushed
*/
async _flushMessages() {
await this._flushMutex.lock();
try {
while (!this._messages.isEmpty) {
const messagesToFlush = this._messages.take(20);
this._messages = this._messages.skip(20);
await this._postMessages(messagesToFlush);
if (!this._messages.isEmpty)
await Delay.seconds(1);
}
}
finally {
this._flushMutex.release();
if (!this._isDisposed)
this._timer = this._createLogFlushTimeout();
}
}
/**
* Posts messages to Slack
* @param messages - The messages to post
* @returns A promise that resolves when messages are posted
*/
async _postMessages(messages) {
try {
// slackMsg: SlackWebApi.ChatPostMessageArguments = {
// username: this._userName,
// icon_emoji: this._userImageIsEmoji ? this._userImage : undefined,
// icon_url: !this._userImageIsEmoji ? this._userImage : undefined,
// };
await Make.retryWithExponentialBackoff(() => {
return this._slackWebClient.chat.postMessage({
username: this._userName,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
icon_emoji: this._userImageIsEmoji ? this._userImage : undefined,
icon_url: !this._userImageIsEmoji ? this._userImage : undefined,
channel: this._channel,
text: `${this.service} [${this.env}]`,
attachments: messages.map(log => {
return {
color: log.color,
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: log.message
}
},
{
type: "context",
elements: [{
type: "plain_text",
text: log.dateTime
}]
}
]
};
}),
});
}, 10)();
}
catch (error) {
if (this._fallbackLogger != null) {
await this._fallbackLogger.logWarning("Error while posting to slack.");
await this._fallbackLogger.logError(error);
await this._fallbackLogger.logWarning("Original messages below");
await messages.forEachAsync(async (log) => {
switch (log.level) {
case "Debug":
await this._fallbackLogger.logDebug(log.message);
break;
case "Info":
await this._fallbackLogger.logInfo(log.message);
break;
case "Warn":
await this._fallbackLogger.logWarning(log.message);
break;
case "Error":
await this._fallbackLogger.logError(log.message);
break;
default:
await this._fallbackLogger.logError(log.message);
}
}, 1);
}
else {
console.warn("Error while posting to slack.");
console.error(error);
console.warn("Original messages below");
messages.forEach(log => {
switch (log.level) {
case "Debug":
console.info(log.message);
break;
case "Info":
console.info(log.message);
break;
case "Warn":
console.warn(log.message);
break;
case "Error":
console.error(log.message);
break;
default:
console.error(log.message);
}
});
}
}
}
}
// class DummyReceiver implements Slack.Receiver
// {
// // @ts-expect-error: not used atm
// public init(app: App<StringIndexed>): void
// {
// // no-op
// }
// // @ts-expect-error: not used atm
// public start(...args: Array<any>): Promise<unknown>
// {
// return Promise.resolve();
// }
// // @ts-expect-error: not used atm
// public stop(...args: Array<any>): Promise<unknown>
// {
// return Promise.resolve();
// }
// }
//# sourceMappingURL=slack-logger.js.map