From 50cc41bd961f9e4bf43e057cc4025ed741f12ab3 Mon Sep 17 00:00:00 2001 From: Chamath Wijesekera Date: Sun, 11 Feb 2024 15:35:30 -0500 Subject: [PATCH] Implement Better Logging (#66) --- .github/workflows/deploy_development.yml | 6 +- ecosystem.config.js | 2 +- src/classes/CalendarEvent.ts | 33 +++-- src/classes/SlackLogger.ts | 130 +++++++++++++++++++ src/common/constants.ts | 6 + src/index.ts | 12 +- src/listeners/commands/helpCommand.ts | 11 +- src/utils/calendarDescription.ts | 4 +- src/utils/channels.ts | 13 +- src/utils/env.ts | 1 + src/utils/eventReminders.ts | 21 ++- src/utils/googleCalendar.ts | 9 +- src/utils/logging.ts | 12 ++ src/utils/slack.ts | 110 ++++++++++++---- src/utils/users.ts | 11 +- tests/unit/utils/calendarDescription.test.ts | 2 +- tsconfig.json | 2 +- 17 files changed, 320 insertions(+), 65 deletions(-) create mode 100644 src/classes/SlackLogger.ts create mode 100644 src/utils/logging.ts diff --git a/.github/workflows/deploy_development.yml b/.github/workflows/deploy_development.yml index 09fab5b..028bcbb 100644 --- a/.github/workflows/deploy_development.yml +++ b/.github/workflows/deploy_development.yml @@ -29,10 +29,11 @@ jobs: echo GOOGLE_ACCOUNT_CLIENT=${{ secrets.GOOGLE_ACCOUNT_CLIENT }} >> .env echo GOOGLE_ACCOUNT_SECRET=${{ secrets.GOOGLE_ACCOUNT_SECRET }} >> .env echo GOOGLE_ACCOUNT_TOKEN=${{ secrets.GOOGLE_ACCOUNT_TOKEN }} >> .env - echo GOOGLE_ACCOUNT_OAUTH_REDIRECT=${{ secrets.GOOGLE_ACCOUNT_OAUTH_REDIRECT }} >> .env + echo GOOGLE_ACCOUNT_OAUTH_REDIRECT=${{ vars.GOOGLE_ACCOUNT_OAUTH_REDIRECT }} >> .env echo SLACK_APP_TOKEN=${{ secrets.SLACK_APP_TOKEN }} >> .env echo SLACK_OAUTH_TOKEN=${{ secrets.SLACK_OAUTH_TOKEN }} >> .env echo SLACK_SIGNING_SECRET=${{ secrets.SLACK_SIGNING_SECRET }} >> .env + echo DEPLOY_COMMIT_SHA=${{ github.sha }} >> .env - name: Move files to dist run: | mv .env ./dist/ @@ -54,7 +55,7 @@ jobs: key: ${{ secrets.REMOTE_KEY }} script: | cd ~/minerva-dev - + echo "Starting the application with PM2." pm2 start ecosystem.config.js @@ -70,4 +71,3 @@ jobs: echo "PM2 application has failed to start." exit 1 fi - \ No newline at end of file diff --git a/ecosystem.config.js b/ecosystem.config.js index f8c27c7..0666a67 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -2,7 +2,7 @@ module.exports = { apps: [ { name: "minerva-dev", - script: "node ./bundle.js 2>&1 | rtail --id minerva-dev", + script: "node ./bundle.js", time: true, instances: 1, autorestart: true, diff --git a/src/classes/CalendarEvent.ts b/src/classes/CalendarEvent.ts index 844a0c8..6bb82d3 100644 --- a/src/classes/CalendarEvent.ts +++ b/src/classes/CalendarEvent.ts @@ -41,15 +41,24 @@ export default class CalendarEvent { * @param event The google calendar event to parse * @param workspaceChannels The list of slack channels in the workspace. Used to create SlackChannel objects from the channel names in the event description * @returns The parsed CalendarEvent object + * @throws Error if the event summary, start time, end time, or URL is undefined */ static fromGoogleCalendarEvent(event: calendar_v3.Schema$Event, workspaceChannels: SlackChannel[]): CalendarEvent { - if (event.summary == undefined) throw new Error("Event summary is undefined"); + if (event.summary == undefined) { + throw new Error("Event summary is undefined"); + } const title = event.summary; - if (event.start?.dateTime == undefined) throw new Error("Event start is undefined"); + if (event.start?.dateTime == undefined) { + throw new Error(`Event start is undefined for event "${event.summary}"`); + } const start = new Date(event.start.dateTime); - if (event.end?.dateTime == undefined) throw new Error("Event end is undefined"); + if (event.end?.dateTime == undefined) { + throw new Error(`Event end is undefined for event "${event.summary}"`); + } const end = new Date(event.end.dateTime); - if (event.htmlLink == undefined) throw new Error("Event URL is undefined"); + if (event.htmlLink == undefined) { + throw new Error(`Event URL is undefined for event "${event.summary}"`); + } const url = event.htmlLink; const parsedEvent = new CalendarEvent(title, start, end, url); @@ -58,10 +67,18 @@ export default class CalendarEvent { parsedEvent.url = event.htmlLink ?? undefined; if (event?.description != undefined) { - const { description, minervaEventMetadata } = parseDescription(event.description, workspaceChannels); - - parsedEvent.description = description; - parsedEvent.minervaEventMetadata = minervaEventMetadata; + try { + const { description, minervaEventMetadata } = parseDescription(event.description, workspaceChannels); + parsedEvent.minervaEventMetadata = minervaEventMetadata; + parsedEvent.description = description; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to parse event description for event "${event.summary}" with error: ${error.message}`, + ); + } + throw new Error(`Failed to parse event description for event ${event.summary}: ${error}`); + } } return parsedEvent; diff --git a/src/classes/SlackLogger.ts b/src/classes/SlackLogger.ts new file mode 100644 index 0000000..d03453d --- /dev/null +++ b/src/classes/SlackLogger.ts @@ -0,0 +1,130 @@ +import { WebClient } from "@slack/web-api"; +import { postMessage } from "../utils/slack"; +import * as environment from "../utils/env"; +import { loggingChannel } from "../common/constants"; +import { App } from "@slack/bolt"; + +/** + * The levels of logging that can be used + */ +export enum LogLevel { + INFO = "INFO", + WARNING = "WARN", + ERROR = "ERROR", +} + +/** + * A class to handle logging to Slack + */ +export class SlackLogger { + private static instance: SlackLogger; + slackClient: WebClient; + + /** + * Singleton constructor for SlackLogger + * @param slackClient The Slack Web API client to use for logging + */ + private constructor(slackClient: WebClient) { + this.slackClient = slackClient; + } + + /** + * Get the singleton instance of SlackLogger + * @returns The singleton instance of SlackLogger + */ + static getInstance(): SlackLogger { + if (!SlackLogger.instance) { + const app = new App({ + token: environment.slackBotToken, + signingSecret: environment.slackSigningSecret, + socketMode: true, + appToken: environment.slackAppToken, + }); + SlackLogger.instance = new SlackLogger(app.client); + } + return SlackLogger.instance; + } + + /** + * Log a message to the minerva logging channel in Slack + * @param level The level of the log + * @param message The message to log + * @param codeBlockContent The content of the code block to log after the message, if any. + * @param logToConsole Whether to log the message to the console in addition to Slack + */ + private async log( + level: LogLevel, + message: string, + codeBlockContent: unknown = "", + logToConsole = true, + ): Promise { + const formattedMessage = this.formatMessage(level, message); + + if (logToConsole) { + if (level === LogLevel.ERROR) { + console.error(formattedMessage); + console.error(codeBlockContent); + } else { + console.log(formattedMessage); + console.log(codeBlockContent); + } + } + + let formattedSlackMessage = formattedMessage; + + if (codeBlockContent) { + formattedSlackMessage += "\n" + SlackLogger.formatCodeBlockContent(codeBlockContent); + } + + await postMessage(this.slackClient, loggingChannel, formattedSlackMessage); + } + + /** + * Log an info message to Slack + * @param message The message to log + * @param codeBlockContent The content of the code block to log after the message, if any. + * @param logToConsole Whether to log the message to the console in addition to Slack + */ + async info(message: string, codeBlockContent: unknown = "", logToConsole = true): Promise { + await this.log(LogLevel.INFO, message, codeBlockContent, logToConsole); + } + /** + * Log a warning message to Slack + * @param message The message to log + * @param codeBlockContent The content of the code block to log after the message, if any. + * @param logToConsole Whether to log the message to the console in addition to Slack + */ + async warning(message: string, codeBlockContent: unknown = "", logToConsole = true): Promise { + await this.log(LogLevel.WARNING, message, codeBlockContent, logToConsole); + } + + /** + * Log an error message to Slack + * @param message The message to log + * @param codeBlockContent The content of the code block to log after the message, if any. Can be used to log the error content + * @param logToConsole Whether to log the message to the console in addition to Slack + */ + async error(message: string, codeBlockContent: unknown = "", logToConsole = true): Promise { + await this.log(LogLevel.ERROR, message, codeBlockContent, logToConsole); + } + + /** + * Formats the message into a log line + * @param level The message level + * @param message The message to log + * @returns The formatted log line + */ + private formatMessage(level: LogLevel, message: string): string { + const timeStamp = new Date().toISOString(); + return `[${timeStamp}] [${level}] ${message}`; + } + + /** + * Formats code block content for Slack + * @param codeBlockContent The content of the code block + * @returns The formatted code block content + */ + private static formatCodeBlockContent(codeBlockContent: unknown): string { + return `\`\`\`${codeBlockContent}\`\`\``; + } +} diff --git a/src/common/constants.ts b/src/common/constants.ts index 9e61826..acedbb9 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,4 +1,5 @@ import { environment } from "../utils/env"; +import SlackChannel from "../classes/SlackChannel"; /** * The "default" slack channels for use in sending DM reminders to single-channel guests @@ -19,3 +20,8 @@ export const defaultSlackChannels = "software", ] : ["general", "random", "software", "test", "propulsion"]; // default channels for development + +export const loggingChannel = + environment == "production" + ? new SlackChannel("minerva-log", "C016AAGP83F") + : new SlackChannel("minerva-log", "C015FSK7FQE"); diff --git a/src/index.ts b/src/index.ts index e117665..c57097f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import registerListeners from "./listeners"; import { OAuth2Client } from "google-auth-library"; import scheduleTasks from "./scheduled"; +import { SlackLogger } from "./classes/SlackLogger"; + // Set up Google OAuth2 client const auth = new OAuth2Client({ clientId: environment.googleAccountClient, @@ -34,9 +36,13 @@ scheduleTasks(app.client, auth); (async (): Promise => { try { await app.start(); - console.log("⚡️ Bolt app is running!"); + SlackLogger.getInstance().info( + `Minerva has started. Deployment commit hash: \`${environment.deploymentCommitHash}\``, + ); } catch (error) { - console.error("Failed to start the Bolt app", error); - throw error; + SlackLogger.getInstance().error( + `Minerva has failed to start. Deployment commit hash: \`${environment.deploymentCommitHash}\``, + error, + ); } })(); diff --git a/src/listeners/commands/helpCommand.ts b/src/listeners/commands/helpCommand.ts index 8b45cf6..ee9854a 100644 --- a/src/listeners/commands/helpCommand.ts +++ b/src/listeners/commands/helpCommand.ts @@ -1,15 +1,12 @@ -import { slackBotToken } from "../../utils/env"; import { Middleware, SlackCommandMiddlewareArgs } from "@slack/bolt"; +import { logCommandUsed } from "../../utils/logging"; +import { postEphemeralMessage } from "../../utils/slack"; const message = `For more information, check out .`; export const helpCommandHandler: Middleware = async ({ command, ack, client }) => { await ack(); + await logCommandUsed(command); - await client.chat.postEphemeral({ - token: slackBotToken, - channel: command.channel_id, - user: command.user_id, - text: message, - }); + await postEphemeralMessage(client, command.channel_id, command.user_id, message); }; diff --git a/src/utils/calendarDescription.ts b/src/utils/calendarDescription.ts index 8d7de68..bc1c2aa 100644 --- a/src/utils/calendarDescription.ts +++ b/src/utils/calendarDescription.ts @@ -55,6 +55,7 @@ export function parseDescriptionFromHtml(description: string): string { // TODO parse description as markdown potentially - would allow for slack to have more formatting const plainDescription = convert(description, { wordwrap: false, + preserveNewlines: true, selectors: [{ selector: "a", options: { ignoreHref: true } }], }); @@ -66,6 +67,7 @@ export function parseDescriptionFromHtml(description: string): string { * @param description The description of the event to parse * @param workspaceChannels The Slack channels in the workspace * @returns The parsed description and the metadata of the event that minerva uses + * @throws Error if the channel name is not specified or if the channel is not found */ export function parseDescription( description: string, @@ -84,7 +86,7 @@ export function parseDescription( } const channel = filterSlackChannelFromName(channelName, workspaceChannels); - if (channel == undefined) throw new Error(`channel ${channelName} not found`); + if (channel == undefined) throw new Error(`channel "${channelName}" not found`); const minervaEventMetadata = { channel, diff --git a/src/utils/channels.ts b/src/utils/channels.ts index 869b9f2..2c8cc21 100644 --- a/src/utils/channels.ts +++ b/src/utils/channels.ts @@ -2,6 +2,7 @@ import { WebClient } from "@slack/web-api"; import SlackChannel from "../classes/SlackChannel"; import ObjectSet from "../classes/ObjectSet"; import { defaultSlackChannels } from "../common/constants"; +import { SlackLogger } from "../classes/SlackLogger"; /** * Filters a Slack channel from an array of channels based on its name. @@ -13,7 +14,7 @@ import { defaultSlackChannels } from "../common/constants"; export function filterSlackChannelFromName(name: string, channels: SlackChannel[]): SlackChannel | undefined { if (name == "default") throw new Error("`default` is not a valid channel name, as it is a group of channels"); const channel = channels?.find((channel) => channel.name === name); - if (channel == undefined) throw new Error(`could not find channel with name ${name}`); + if (channel == undefined) throw new Error(`could not find channel with name "${name}"`); return channel; } @@ -35,13 +36,15 @@ export function filterSlackChannelsFromNames(names: string[], channels: SlackCha const channel = channels.find((channel) => channel.name === name); // If a channel with a given name is not found, it logs an error and continues to the next name. if (channel == undefined) { - console.error(`could not find channel with name ${name}`); + SlackLogger.getInstance().warning(`could not find channel with name \`${name}\` when filtering channels`); continue; } // If a channel has an undefined name or id, it logs an error and continues to the next channel. if (channel.name == undefined || channel.id == undefined) { - console.error(`channel with name ${name} has undefined name or id. This should not happen.`); + SlackLogger.getInstance().error( + `channel with name \`${name}\` has undefined name or id. This should not happen.`, + ); continue; } @@ -78,7 +81,9 @@ export async function getAllSlackChannels(client: WebClient): Promise { - const calendarEvent = CalendarEvent.fromGoogleCalendarEvent(event, channels); - eventsList.push(calendarEvent); + try { + const calendarEvent = CalendarEvent.fromGoogleCalendarEvent(event, channels); + eventsList.push(calendarEvent); + } catch (error) { + SlackLogger.getInstance().error(`Failed to parse Google Calendar event:`, error); + } }); return eventsList; diff --git a/src/utils/logging.ts b/src/utils/logging.ts new file mode 100644 index 0000000..88f1fbc --- /dev/null +++ b/src/utils/logging.ts @@ -0,0 +1,12 @@ +import { SlashCommand } from "@slack/bolt"; +import { SlackLogger } from "../classes/SlackLogger"; + +/** + * Logs the use of a slash command. + * @param command - The slash command to log. + */ +export async function logCommandUsed(command: SlashCommand): Promise { + await SlackLogger.getInstance().info( + `\`${command.user_name}\` used the \`${command.command}\` command in \`${command.channel_name}\``, + ); +} diff --git a/src/utils/slack.ts b/src/utils/slack.ts index 1baa552..6a97538 100644 --- a/src/utils/slack.ts +++ b/src/utils/slack.ts @@ -4,6 +4,7 @@ import SlackUser, { UserType } from "../classes/SlackUser"; import SlackChannel from "../classes/SlackChannel"; import { determineUserType } from "./users"; import { ReactionsAddResponse } from "@slack/web-api"; +import { SlackLogger } from "../classes/SlackLogger"; export type SlackUserID = string; @@ -44,12 +45,48 @@ export async function postMessage( ...options, }); } catch (error) { - throw `Failed to post message to channel ${channel.name} with error ${error}`; + SlackLogger.getInstance().error(`Failed to post message to channel \`${channel.name}\` with error:`, error); + throw error; } return res; } +/** + * Posts an ephemeral message to a Slack channel + * @param client Slack Web API client + * @param channel The Slack channel to post the message to + * @param user The Slack user to post the message to + * @param text The text of the message to post + * @param options Optional arguments for the message as per https://api.slack.com/methods/chat.postEphemeral#args + * @returns A promise that resolves to the response from the Slack API + */ +export async function postEphemeralMessage( + client: WebClient, + channel: string, + user: string, + text: string, + options?: ChatPostMessageOptionalArgs, +): Promise { + let res: ChatPostMessageResponse | undefined = undefined; + try { + res = await client.chat.postEphemeral({ + channel: channel, + user: user, + text: text, + ...options, + }); + + return res; + } catch (error) { + SlackLogger.getInstance().error( + `Failed to post ephemeral message to user \`${user}\` in channel \`${channel}\` with error:`, + error, + ); + throw error; + } +} + // https://api.slack.com/methods/emoji.list /** * Retrieves all custom emoji from a Slack workspace. @@ -64,7 +101,7 @@ export async function getAllEmoji(client: WebClient): Promise { } return Object.keys(result.emoji); } catch (error) { - console.error("Failed to get emoji:", error); + SlackLogger.getInstance().error(`Failed to get emojis for workspace:`, error); throw error; } } @@ -83,24 +120,29 @@ export async function getAllSlackUsers( let cursor: string | undefined = undefined; while (true) { - const response = await client.users.list({ - limit: 900, - cursor: cursor, - }); - - if (response.members) { - response.members.forEach((user) => { - if (user.deleted == includeDeactivatedMembers) { - const userType: UserType = determineUserType(user); - const newGuest = new SlackUser(user.real_name as string, user.id as string, userType); - usersList.push(newGuest); - } + try { + const response = await client.users.list({ + limit: 900, + cursor: cursor, }); - } - cursor = response.response_metadata?.next_cursor; - if (!cursor) { - break; + if (response.members) { + response.members.forEach((user) => { + if (user.deleted == includeDeactivatedMembers) { + const userType: UserType = determineUserType(user); + const newGuest = new SlackUser(user.real_name as string, user.id as string, userType); + usersList.push(newGuest); + } + }); + } + + cursor = response.response_metadata?.next_cursor; + if (!cursor) { + break; + } + } catch (error) { + SlackLogger.getInstance().error(`Failed to get users from workspace:`, error); + throw error; } } return usersList; @@ -112,7 +154,7 @@ export async function getAllSlackUsers( * @param channelId The id of the Slack channel. * @returns A promise that resolves to an array of SlackUserIDs in the channel, or undefined if the channel is not found. */ -export async function getChannelMembers(client: WebClient, channelId: string): Promise { +export async function getChannelMembers(client: WebClient, channelId: string): Promise { try { let cursor: string | undefined = undefined; const members: SlackUserID[] = []; @@ -133,10 +175,10 @@ export async function getChannelMembers(client: WebClient, channelId: string): P break; } } - return members.length > 0 ? members : undefined; + return members.length > 0 ? members : []; } catch (error) { - console.error("Error fetching channel members:", error); - return undefined; + SlackLogger.getInstance().error(`Failed to get members for channel with id \`${channelId}\`:`, error); + throw error; } } @@ -158,11 +200,19 @@ export function addReactionToMessage( // Convert timestamp to string if it's a number const timestampStr = typeof timestamp === "number" ? timestamp.toString() : timestamp; - return client.reactions.add({ - channel, - name: emoji, - timestamp: timestampStr, - }); + try { + return client.reactions.add({ + channel, + name: emoji, + timestamp: timestampStr, + }); + } catch (error) { + SlackLogger.getInstance().error( + `Failed to add reaction \`${emoji}\` to message \`${timestampStr}\` in \`${channel}\`:`, + error, + ); + throw error; + } } /** @@ -184,7 +234,11 @@ export async function getMessagePermalink( message_ts: timestamp, }); } catch (error) { - console.error(`Error fetching message permalink for message with timestamp ${timestamp} in ${channel}`, error); + SlackLogger.getInstance().error( + `Error fetching message permalink for message with timestamp \`${timestamp}\` in \`${channel}\`:`, + error, + ); + throw error; } if (res?.ok) { diff --git a/src/utils/users.ts b/src/utils/users.ts index 9de4ee6..9cfcc37 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -6,6 +6,7 @@ import SlackUser, { UserType } from "../classes/SlackUser"; import { Member } from "@slack/web-api/dist/response/UsersListResponse"; import { filterSlackChannelFromName, getAllSlackChannels } from "./channels"; import { SlackUserID, getAllSlackUsers, getChannelMembers, postMessage } from "./slack"; +import { SlackLogger } from "../classes/SlackLogger"; /** * Determine the type of a Slack user based on the provided Member object. @@ -58,7 +59,7 @@ export async function getAllSingleChannelGuests(slackUsers: SlackUser[]): Promis * @returns A promise that resolves to an array of user IDs in the channel, * or undefined if the channel is not found. */ -export async function getAllUsersInChannel(client: WebClient, channel: string): Promise { +export async function getAllUsersInChannel(client: WebClient, channel: string): Promise { const allSlackChannels = await getAllSlackChannels(client); const channelId = await filterSlackChannelFromName(channel, allSlackChannels); if (!channelId) { @@ -108,7 +109,13 @@ export async function getAllSingleChannelGuestsInOneChannel( channel: SlackChannel, allUsersInWorkspace?: SlackUser[], ): Promise { - const allUsersInChannel = await getAllUsersInChannel(client, channel.name); + let allUsersInChannel: SlackUserID[] = []; + try { + allUsersInChannel = await getAllUsersInChannel(client, channel.name); + } catch (error) { + SlackLogger.getInstance().error(`Failed to get users in channel \`${channel.name}\` with error:`, error); + throw error; + } if (allUsersInWorkspace == undefined) { allUsersInWorkspace = await getAllSlackUsers(client); diff --git a/tests/unit/utils/calendarDescription.test.ts b/tests/unit/utils/calendarDescription.test.ts index ebb9e23..6542b09 100644 --- a/tests/unit/utils/calendarDescription.test.ts +++ b/tests/unit/utils/calendarDescription.test.ts @@ -160,7 +160,7 @@ describe("utils/calendarDescription", () => { it("should throw an error if the the channel specified does not exist", () => { const description = `#foodstuffs
This is a description
Yep it is.`; - expect(() => parseDescription(description, [])).toThrow("could not find channel with name foodstuffs"); + expect(() => parseDescription(description, [])).toThrow(`could not find channel with name "foodstuffs"`); }); it("should throw an error if no channel is specified", () => { diff --git a/tsconfig.json b/tsconfig.json index c85ddc7..afcdeb0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */