@nivinjoseph/n-log
Version:
Logging framework
435 lines (383 loc) • 14.8 kB
text/typescript
import { given } from "@nivinjoseph/n-defensive";
import { Exception } from "@nivinjoseph/n-exception";
import { Delay, Disposable, Duration, Make, Mutex } from "@nivinjoseph/n-util";
import SlackWebApi from "@slack/web-api";
import { BaseLogger } from "./base-logger.js";
import { LogRecord } from "./log-record.js";
import { Logger } from "./logger.js";
import { LoggerConfig } from "./logger-config.js";
/**
* Configuration options for the Slack logger
*/
export type SlackLoggerConfig = Pick<LoggerConfig, "logDateTimeZone" | "logInjector"> & {
/** Slack bot token for authentication */
slackBotToken: string;
/** Slack channel to post logs to */
slackBotChannel: string;
/** Custom username for the bot (default: service name) */
slackUserName?: string;
/** Custom user image for the bot (default: robot_face emoji) */
slackUserImage?: string;
/** Filter which log levels to post (default: all) */
filter?: ReadonlyArray<"Info" | "Warn" | "Error">;
/** Custom filter function for log records */
logFilter?(record: LogRecord): boolean;
/** Fallback logger to use if Slack posting fails */
fallback?: Logger;
};
/**
* 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 implements Disposable
{
private readonly _includeInfo: boolean;
private readonly _includeWarn: boolean;
private readonly _includeError: boolean;
private readonly _logFilter: (record: LogRecord) => boolean;
private readonly _fallbackLogger: Logger | null;
private readonly _slackWebClient: SlackWebApi.WebClient;
private readonly _channel: string;
private readonly _userName: string;
private readonly _userImage: string = ":robot_face:";
private readonly _userImageIsEmoji: boolean;
private readonly _flushMutex = new Mutex();
private _messages = new Array<SlackMessage>();
private _timer: NodeJS.Timeout;
private _isDisposed = false;
private _disposePromise: Promise<void> | null = null;
private _warnedAfterDispose = false;
/**
* Creates a new instance of SlackLogger
* @param config - Configuration for the Slack logger
*/
public constructor(config: SlackLoggerConfig)
{
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 ?? ((_: LogRecord): boolean => 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
*/
public async logDebug(debug: string): Promise<void>
{
if (this._isDisposedDrop())
return;
if (this.env === "dev")
{
let log: SlackMessage = {
source: this.source,
service: this.service,
env: this.env,
level: "Debug",
message: debug,
...this.getDateTime(),
color: "#F8F8F8"
};
if (this.logInjector)
log = this.logInjector(log) as SlackMessage;
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
*/
public async logInfo(info: string): Promise<void>
{
if (this._isDisposedDrop())
return;
if (!this._includeInfo)
return;
let log: SlackMessage = {
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) as SlackMessage;
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
*/
public async logWarning(warning: string | Exception): Promise<void>
{
if (this._isDisposedDrop())
return;
if (!this._includeWarn)
return;
let log: SlackMessage = {
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) as SlackMessage;
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
*/
public async logError(error: string | Exception): Promise<void>
{
if (this._isDisposedDrop())
return;
if (!this._includeError)
return;
let log: SlackMessage = {
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) as SlackMessage;
this._messages.push(log);
}
/**
* Disposes the logger, flushing any remaining messages.
* @returns A promise that resolves when disposal is complete
*/
public dispose(): Promise<void>
{
if (!this._isDisposed)
{
this._isDisposed = true;
clearTimeout(this._timer);
this._disposePromise = this._flushMessages();
}
return this._disposePromise!;
}
private _createLogFlushTimeout(): NodeJS.Timeout
{
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.
*/
private _isDisposedDrop(): boolean
{
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
*/
private async _flushMessages(): Promise<void>
{
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
*/
private async _postMessages(messages: ReadonlyArray<SlackMessage>): Promise<void>
{
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 as any,
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 as any);
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 as any);
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);
}
});
}
}
}
}
type SlackMessage = LogRecord & { color: string; };
// 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();
// }
// }