UNPKG

@nivinjoseph/n-log

Version:
435 lines (383 loc) 14.8 kB
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(); // } // }