Skip to content

Commit

Permalink
Implement Better Logging (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
QuantumManiac authored Feb 11, 2024
1 parent 04dc191 commit 50cc41b
Show file tree
Hide file tree
Showing 17 changed files with 320 additions and 65 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/deploy_development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -54,7 +55,7 @@ jobs:
key: ${{ secrets.REMOTE_KEY }}
script: |
cd ~/minerva-dev
echo "Starting the application with PM2."
pm2 start ecosystem.config.js
Expand All @@ -70,4 +71,3 @@ jobs:
echo "PM2 application has failed to start."
exit 1
fi

2 changes: 1 addition & 1 deletion ecosystem.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 25 additions & 8 deletions src/classes/CalendarEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions src/classes/SlackLogger.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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}\`\`\``;
}
}
6 changes: 6 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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");
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,9 +36,13 @@ scheduleTasks(app.client, auth);
(async (): Promise<void> => {
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,
);
}
})();
11 changes: 4 additions & 7 deletions src/listeners/commands/helpCommand.ts
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/waterloo-rocketry/minerva-rewrite/blob/main/README.md|minerva's README>.`;

export const helpCommandHandler: Middleware<SlackCommandMiddlewareArgs> = 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);
};
4 changes: 3 additions & 1 deletion src/utils/calendarDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }],
});

Expand All @@ -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,
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions src/utils/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -78,7 +81,9 @@ export async function getAllSlackChannels(client: WebClient): Promise<SlackChann

for (const channel of channels.channels ?? []) {
if (channel.name == undefined || channel.id == undefined) {
console.error(`channel with name ${channel.name} has undefined name or id. This should not happen.`);
SlackLogger.getInstance().error(
`channel with name \`${channel.name}\` has undefined name or id. This should not happen.`,
);
continue;
}

Expand Down
1 change: 1 addition & 0 deletions src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export const googleAccountClient = process.env.GOOGLE_ACCOUNT_CLIENT;
export const googleAccountSecret = process.env.GOOGLE_ACCOUNT_SECRET;
export const googleAccountToken = process.env.GOOGLE_ACCOUNT_TOKEN;
export const googleAccountOauthRedirect = process.env.GOOGLE_ACCOUNT_OAUTH_REDIRECT;
export const deploymentCommitHash = process.env.DEPLOY_COMMIT_SHA;
Loading

0 comments on commit 50cc41b

Please sign in to comment.