a fun bot for the hc slack

feat: add sentry logging

dunkirk.sh 6e2d8cdb fc4ecfd5

verified
Changed files
+523 -318
src
+21 -10
src/features/api/index.ts
···
import { recentTakes, takesPerUser } from "./routes/recentTakes";
import video from "./routes/video";
+
import { handleApiError } from "../../libs/apiError";
export { default as video } from "./routes/video";
export async function apiRouter(url: URL) {
-
const path = url.pathname.split("/")[2];
+
try {
+
const path = url.pathname.split("/")[2];
-
switch (path) {
-
case "video":
-
return video(url);
-
case "recentTakes":
-
return recentTakes();
-
case "takesPerUser":
-
return takesPerUser(url.pathname.split("/")[3] as string);
-
default:
-
return new Response("404 Not Found", { status: 404 });
+
switch (path) {
+
case "video":
+
return await video(url);
+
case "recentTakes":
+
return await recentTakes();
+
case "takesPerUser":
+
return await takesPerUser(url.pathname.split("/")[3] as string);
+
default:
+
return new Response(
+
JSON.stringify({ error: "Route not found" }),
+
{
+
status: 404,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}
+
} catch (error) {
+
return handleApiError(error, "apiRouter");
}
}
+94 -85
src/features/api/routes/recentTakes.ts
···
import { eq, desc, and } from "drizzle-orm";
import { db } from "../../../libs/db";
import { takes as takesTable } from "../../../libs/schema";
+
import { handleApiError } from "../../../libs/apiError";
export async function recentTakes(): Promise<Response> {
-
const recentTakes = await db
-
.select()
-
.from(takesTable)
-
.where(eq(takesTable.status, "approved"))
-
.orderBy(desc(takesTable.completedAt))
-
.limit(40);
+
try {
+
const recentTakes = await db
+
.select()
+
.from(takesTable)
+
.where(eq(takesTable.status, "approved"))
+
.orderBy(desc(takesTable.completedAt))
+
.limit(40);
+
+
if (recentTakes.length === 0) {
+
return new Response(
+
JSON.stringify({
+
takes: [],
+
}),
+
{
+
headers: {
+
"Content-Type": "application/json",
+
},
+
},
+
);
+
}
+
+
const takes = recentTakes.map((take) => ({
+
id: take.id,
+
userId: take.userId,
+
description: take.description,
+
completedAt: take.completedAt,
+
status: take.status,
+
mp4Url: take.takeUrl,
+
elapsedTime: take.elapsedTimeMs,
+
}));
-
if (recentTakes.length === 0) {
return new Response(
JSON.stringify({
-
takes: [],
+
takes,
}),
{
headers: {
···
},
},
);
+
} catch (error) {
+
return handleApiError(error, "recentTakes");
}
-
-
const takes = recentTakes.map((take) => ({
-
id: take.id,
-
userId: take.userId,
-
description: take.description,
-
completedAt: take.completedAt,
-
status: take.status,
-
mp4Url: take.takeUrl,
-
elapsedTime: take.elapsedTimeMs,
-
}));
-
-
return new Response(
-
JSON.stringify({
-
takes,
-
}),
-
{
-
headers: {
-
"Content-Type": "application/json",
-
},
-
},
-
);
}
export async function takesPerUser(userId: string): Promise<Response> {
-
const rawTakes = await db
-
.select()
-
.from(takesTable)
-
.where(and(eq(takesTable.userId, userId)))
-
.orderBy(desc(takesTable.completedAt));
+
try {
+
const rawTakes = await db
+
.select()
+
.from(takesTable)
+
.where(and(eq(takesTable.userId, userId)))
+
.orderBy(desc(takesTable.completedAt));
-
const takes = rawTakes.map((take) => ({
-
id: take.id,
-
description: take.description,
-
completedAt: take.completedAt,
-
status: take.status,
-
mp4Url: take.takeUrl,
-
elapsedTime: take.elapsedTimeMs,
-
}));
+
const takes = rawTakes.map((take) => ({
+
id: take.id,
+
description: take.description,
+
completedAt: take.completedAt,
+
status: take.status,
+
mp4Url: take.takeUrl,
+
elapsedTime: take.elapsedTimeMs,
+
}));
-
const approvedTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "approved") return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
+
const approvedTakes = rawTakes.reduce((acc, take) => {
+
if (take.status !== "approved") return acc;
+
const multiplier = Number.parseFloat(take.multiplier || "1.0");
+
return Number(
+
(
+
acc +
+
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
+
).toFixed(1),
+
);
+
}, 0);
-
const waitingTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "waitingUpload" && take.status !== "uploaded")
-
return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
+
const waitingTakes = rawTakes.reduce((acc, take) => {
+
if (take.status !== "waitingUpload" && take.status !== "uploaded")
+
return acc;
+
const multiplier = Number.parseFloat(take.multiplier || "1.0");
+
return Number(
+
(
+
acc +
+
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
+
).toFixed(1),
+
);
+
}, 0);
-
const rejectedTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "rejected") return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
+
const rejectedTakes = rawTakes.reduce((acc, take) => {
+
if (take.status !== "rejected") return acc;
+
const multiplier = Number.parseFloat(take.multiplier || "1.0");
+
return Number(
+
(
+
acc +
+
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
+
).toFixed(1),
+
);
+
}, 0);
-
return new Response(
-
JSON.stringify({
-
approvedTakes,
-
waitingTakes,
-
rejectedTakes,
-
takes,
-
}),
-
{
-
headers: {
-
"Content-Type": "application/json",
+
return new Response(
+
JSON.stringify({
+
approvedTakes,
+
waitingTakes,
+
rejectedTakes,
+
takes,
+
}),
+
{
+
headers: {
+
"Content-Type": "application/json",
+
},
},
-
},
-
);
+
);
+
} catch (error) {
+
return handleApiError(error, "takesPerUser");
+
}
}
+36 -25
src/features/api/routes/video.ts
···
+
import { handleApiError } from "../../../libs/apiError";
import { db } from "../../../libs/db";
import { takes as takesTable } from "../../../libs/schema";
import { eq, and } from "drizzle-orm";
export default async function getVideo(url: URL): Promise<Response> {
-
const videoId = url.pathname.split("/")[2];
-
const thumbnail = url.pathname.split("/")[3] === "thumbnail";
+
try {
+
const videoId = url.pathname.split("/")[2];
+
const thumbnail = url.pathname.split("/")[3] === "thumbnail";
-
if (!videoId) {
-
return new Response("Invalid video id", { status: 400 });
-
}
+
if (!videoId) {
+
return new Response(JSON.stringify({ error: "Invalid video id" }), {
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
});
+
}
-
const video = await db
-
.select()
-
.from(takesTable)
-
.where(eq(takesTable.id, videoId));
+
const video = await db
+
.select()
+
.from(takesTable)
+
.where(eq(takesTable.id, videoId));
-
if (video.length === 0) {
-
return new Response("Video not found", { status: 404 });
-
}
+
if (video.length === 0) {
+
return new Response(JSON.stringify({ error: "Video not found" }), {
+
status: 404,
+
headers: { "Content-Type": "application/json" },
+
});
+
}
-
const videoData = video[0];
+
const videoData = video[0];
-
if (thumbnail) {
-
return Response.redirect(
-
`https://cachet.dunkirk.sh/users/${videoData?.userId}/r`,
-
);
-
}
+
if (thumbnail) {
+
return Response.redirect(
+
`https://cachet.dunkirk.sh/users/${videoData?.userId}/r`,
+
);
+
}
-
return new Response(
-
`<!DOCTYPE html>
+
return new Response(
+
`<!DOCTYPE html>
<html>
<head>
<title>Video Player</title>
···
</div>
</body>
</html>`,
-
{
-
headers: {
-
"Content-Type": "text/html",
+
{
+
headers: {
+
"Content-Type": "text/html",
+
},
},
-
},
-
);
+
);
+
} catch (error) {
+
return handleApiError(error, "getVideo");
+
}
}
-2
src/features/takes/services/notifications.ts
···
.set({ notifiedLowTime: true })
.where(eq(takesTable.id, take.id));
-
console.log("Sending low time warning to user");
-
try {
await slackApp.client.chat.postMessage({
channel: take.userId,
+110 -74
src/features/takes/setup/actions.ts
···
import { slackApp } from "../../../index";
import { db } from "../../../libs/db";
+
import { blog } from "../../../libs/Logger";
import { takes as takesTable } from "../../../libs/schema";
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
···
import upload from "../services/upload";
import type { MessageResponse } from "../types";
import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks";
+
import * as Sentry from "@sentry/bun";
export default function setupActions() {
// Handle button actions
slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
-
const userId = payload.user.id;
-
const channelId = context.channelId || "";
-
const actionId = payload.actions[0]?.action_id as string;
-
const command = actionId.replace("takes_", "");
-
const descriptionInput = payload.state.values.note_block?.note_input;
+
try {
+
const userId = payload.user.id;
+
const channelId = context.channelId || "";
+
const actionId = payload.actions[0]?.action_id as string;
+
const command = actionId.replace("takes_", "");
+
const descriptionInput =
+
payload.state.values.note_block?.note_input;
-
let response: MessageResponse | undefined;
+
let response: MessageResponse | undefined;
-
const activeTake = await getActiveTake(userId);
+
const activeTake = await getActiveTake(userId);
-
// Route to the appropriate handler function
-
switch (command) {
-
case "start": {
-
if (activeTake.length > 0) {
-
if (context.respond) {
-
response = await handleStatus(userId);
+
// Route to the appropriate handler function
+
switch (command) {
+
case "start": {
+
if (activeTake.length > 0) {
+
if (context.respond) {
+
response = await handleStatus(userId);
+
}
+
} else {
+
if (!descriptionInput?.value?.trim()) {
+
response = getDescriptionBlocks(
+
"Please enter a note for your session.",
+
);
+
} else {
+
response = await handleStart(
+
userId,
+
channelId,
+
descriptionInput?.value?.trim(),
+
);
+
}
}
-
} else {
-
if (!descriptionInput?.value?.trim()) {
-
response = getDescriptionBlocks(
-
"Please enter a note for your session.",
+
break;
+
}
+
case "pause":
+
response = await handlePause(userId);
+
break;
+
case "resume":
+
response = await handleResume(userId);
+
break;
+
case "stop":
+
response = await handleStop(userId);
+
break;
+
case "edit": {
+
if (!activeTake.length && context.respond) {
+
await context.respond({
+
text: "You don't have an active takes session to edit!",
+
response_type: "ephemeral",
+
});
+
return;
+
}
+
+
if (!descriptionInput) {
+
response = getEditDescriptionBlocks(
+
activeTake[0]?.description || "",
);
+
} else if (descriptionInput.value?.trim()) {
+
const takeToUpdate = activeTake[0];
+
if (!takeToUpdate) return;
+
+
// Update the note for the active session
+
await db.update(takesTable).set({
+
description: descriptionInput.value.trim(),
+
});
+
+
response = await handleStatus(userId);
} else {
-
response = await handleStart(
-
userId,
-
channelId,
-
descriptionInput?.value?.trim(),
+
response = getEditDescriptionBlocks(
+
"",
+
"Please enter a note for your session.",
);
}
-
}
-
break;
-
}
-
case "pause":
-
response = await handlePause(userId);
-
break;
-
case "resume":
-
response = await handleResume(userId);
-
break;
-
case "stop":
-
response = await handleStop(userId);
-
break;
-
case "edit": {
-
if (!activeTake.length && context.respond) {
-
await context.respond({
-
text: "You don't have an active takes session to edit!",
-
response_type: "ephemeral",
-
});
-
return;
+
break;
}
-
if (!descriptionInput) {
-
response = getEditDescriptionBlocks(
-
activeTake[0]?.description || "",
-
);
-
} else if (descriptionInput.value?.trim()) {
-
const takeToUpdate = activeTake[0];
-
if (!takeToUpdate) return;
-
-
// Update the note for the active session
-
await db.update(takesTable).set({
-
description: descriptionInput.value.trim(),
-
});
-
+
case "status":
response = await handleStatus(userId);
-
} else {
-
response = getEditDescriptionBlocks(
-
"",
-
"Please enter a note for your session.",
-
);
-
}
-
break;
+
break;
+
case "history":
+
response = await handleHistory(userId);
+
break;
+
default:
+
response = await handleHelp();
+
break;
}
-
case "status":
-
response = await handleStatus(userId);
-
break;
-
case "history":
-
response = await handleHistory(userId);
-
break;
-
default:
-
response = await handleHelp();
-
break;
-
}
+
// Send the response
+
if (response && context.respond) {
+
await context.respond(response);
+
}
+
} catch (error) {
+
if (error instanceof Error)
+
blog(
+
`Error in \`${payload.actions[0]?.action_id}\` action: ${error.message}`,
+
"error",
+
);
-
// Send the response
-
if (response && context.respond) {
-
await context.respond(response);
+
// Capture the error in Sentry
+
Sentry.captureException(error, {
+
extra: {
+
actionId: payload.actions[0]?.action_id,
+
userId: payload.user.id,
+
channelId: context.channelId,
+
},
+
});
+
+
// Respond with error message to user
+
if (context.respond) {
+
await context.respond({
+
text: "An error occurred while processing your request. Please stand by while we try to put out the fire.",
+
response_type: "ephemeral",
+
});
+
}
}
});
// setup the upload actions
-
upload();
+
try {
+
upload();
+
} catch (error) {
+
Sentry.captureException(error, {
+
extra: {
+
context: "upload setup",
+
},
+
});
+
}
}
+104 -76
src/features/takes/setup/commands.ts
···
} from "../services/notifications";
import type { MessageResponse } from "../types";
import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks";
+
import * as Sentry from "@sentry/bun";
+
import { blog } from "../../../libs/Logger";
export default function setupCommands() {
// Main command handler
slackApp.command(
environment === "dev" ? "/takes-dev" : "/takes",
async ({ payload, context }): Promise<void> => {
-
const userId = payload.user_id;
-
const channelId = payload.channel_id;
-
const text = payload.text || "";
-
const args = text.trim().split(/\s+/);
-
let subcommand = args[0]?.toLowerCase() || "";
+
try {
+
const userId = payload.user_id;
+
const channelId = payload.channel_id;
+
const text = payload.text || "";
+
const args = text.trim().split(/\s+/);
+
let subcommand = args[0]?.toLowerCase() || "";
-
// Check for active takes session
-
const activeTake = await getActiveTake(userId);
+
// Check for active takes session
+
const activeTake = await getActiveTake(userId);
-
// Check for paused session if no active one
-
const pausedTakeCheck =
-
activeTake.length === 0 ? await getPausedTake(userId) : [];
+
// Check for paused session if no active one
+
const pausedTakeCheck =
+
activeTake.length === 0 ? await getPausedTake(userId) : [];
-
// Run checks for expired or about-to-expire sessions
-
await expirePausedSessions();
-
await checkActiveSessions();
+
// Run checks for expired or about-to-expire sessions
+
await expirePausedSessions();
+
await checkActiveSessions();
-
// Default to status if we have an active or paused session and no command specified
-
if (
-
subcommand === "" &&
-
(activeTake.length > 0 || pausedTakeCheck.length > 0)
-
) {
-
subcommand = "status";
-
} else if (subcommand === "") {
-
subcommand = "help";
-
}
+
// Default to status if we have an active or paused session and no command specified
+
if (
+
subcommand === "" &&
+
(activeTake.length > 0 || pausedTakeCheck.length > 0)
+
) {
+
subcommand = "status";
+
} else if (subcommand === "") {
+
subcommand = "help";
+
}
-
let response: MessageResponse | undefined;
+
let response: MessageResponse | undefined;
-
// Special handling for start command to show modal
-
if (subcommand === "start" && !activeTake.length) {
-
response = getDescriptionBlocks();
-
}
+
// Special handling for start command to show modal
+
if (subcommand === "start" && !activeTake.length) {
+
response = getDescriptionBlocks();
+
}
+
+
// Route to the appropriate handler function
+
switch (subcommand) {
+
case "start": {
+
if (args.length < 2) {
+
response = getDescriptionBlocks();
+
break;
+
}
-
// Route to the appropriate handler function
-
switch (subcommand) {
-
case "start": {
-
if (args.length < 2) {
-
response = getDescriptionBlocks();
-
break;
-
}
+
const descriptionInput = args.slice(1).join(" ");
-
const descriptionInput = args.slice(1).join(" ");
+
if (!descriptionInput.trim()) {
+
response = getDescriptionBlocks(
+
"Please enter a note for your session.",
+
);
+
break;
+
}
-
if (!descriptionInput.trim()) {
-
response = getDescriptionBlocks(
-
"Please enter a note for your session.",
+
response = await handleStart(
+
userId,
+
channelId,
+
descriptionInput,
);
break;
}
+
case "pause":
+
response = await handlePause(userId);
+
break;
+
case "resume":
+
response = await handleResume(userId);
+
break;
+
case "stop":
+
response = await handleStop(userId, args);
+
break;
+
case "edit":
+
response = getEditDescriptionBlocks(
+
activeTake[0]?.description || "",
+
);
+
break;
+
case "status":
+
response = await handleStatus(userId);
+
break;
+
case "history":
+
response = await handleHistory(userId);
+
break;
+
case "help":
+
response = await handleHelp();
+
break;
+
default:
+
response = await handleHelp();
+
break;
+
}
-
response = await handleStart(
-
userId,
-
channelId,
-
descriptionInput,
-
);
-
break;
+
if (!response) {
+
throw new Error("No response received from handler");
}
-
case "pause":
-
response = await handlePause(userId);
-
break;
-
case "resume":
-
response = await handleResume(userId);
-
break;
-
case "stop":
-
response = await handleStop(userId, args);
-
break;
-
case "edit":
-
response = getEditDescriptionBlocks(
-
activeTake[0]?.description || "",
+
+
if (context.respond) {
+
await context.respond(response);
+
}
+
} catch (error) {
+
if (error instanceof Error)
+
blog(
+
`Error in \`${payload.command}\` command: ${error.message}`,
+
"error",
);
-
break;
-
case "status":
-
response = await handleStatus(userId);
-
break;
-
case "history":
-
response = await handleHistory(userId);
-
break;
-
case "help":
-
response = await handleHelp();
-
break;
-
default:
-
response = await handleHelp();
-
break;
-
}
+
+
// Capture the error in Sentry
+
Sentry.captureException(error, {
+
extra: {
+
command: payload.command,
+
userId: payload.user_id,
+
channelId: payload.channel_id,
+
text: payload.text,
+
},
+
});
-
if (context.respond)
-
await context.respond(
-
response || {
-
text: "An error occurred while processing your request.",
+
// Respond with error message to user
+
if (context.respond) {
+
await context.respond({
+
text: "An error occurred while processing your request. Please be patent while we try to put out the fire.",
response_type: "ephemeral",
-
},
-
);
+
});
+
}
+
}
},
);
}
+39 -5
src/features/takes/setup/notifications.ts
···
+
import * as Sentry from "@sentry/bun";
import TakesConfig from "../../../libs/config";
+
import { blog } from "../../../libs/Logger";
import {
checkActiveSessions,
expirePausedSessions,
} from "../services/notifications";
export default function setupNotifications() {
-
const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL;
-
setInterval(async () => {
-
await checkActiveSessions();
-
await expirePausedSessions();
-
}, notificationInterval);
+
try {
+
const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL;
+
+
setInterval(async () => {
+
try {
+
await checkActiveSessions();
+
await expirePausedSessions();
+
} catch (error) {
+
if (error instanceof Error)
+
blog(
+
`Error in notifications check: ${error.message}`,
+
"error",
+
);
+
Sentry.captureException(error, {
+
extra: {
+
context: "notifications check",
+
checkInterval: notificationInterval,
+
},
+
tags: {
+
type: "notification_check",
+
},
+
});
+
}
+
}, notificationInterval);
+
} catch (error) {
+
if (error instanceof Error)
+
blog(`Error setting up notifications: ${error.message}`, "error");
+
Sentry.captureException(error, {
+
extra: {
+
context: "notifications setup",
+
},
+
tags: {
+
type: "notification_setup",
+
},
+
});
+
throw error; // Re-throw to prevent the app from starting with broken notifications
+
}
}
+2
src/index.ts
···
"SLACK_BOT_TOKEN",
"SLACK_SIGNING_SECRET",
"SLACK_REVIEW_CHANNEL",
+
"SLACK_LOG_CHANNEL",
+
"SLACK_SPAM_CHANNEL",
"SLACK_USER_TOKEN",
"API_URL",
"SENTRY_DSN",
+89 -41
src/libs/Logger.ts
···
import { slackClient } from "../index";
-
import Bottleneck from "bottleneck";
import Queue from "./queue";
-
import colors from "colors";
+
import * as Sentry from "@sentry/bun";
import type {
ChatPostMessageRequest,
ChatPostMessageResponse,
···
const messageQueue = new Queue();
-
function sendMessage(
+
async function sendMessage(
message: ChatPostMessageRequest,
): Promise<ChatPostMessageResponse> {
-
return limiter.schedule(() => slackClient.chat.postMessage(message));
+
try {
+
return await limiter.schedule(() =>
+
slackClient.chat.postMessage(message),
+
);
+
} catch (error) {
+
Sentry.captureException(error, {
+
extra: { channel: message.channel, text: message.text },
+
tags: { type: "slack_message_error" },
+
});
+
console.error("Failed to send Slack message:", error);
+
throw error;
+
}
}
async function slog(
···
channel: string;
},
): Promise<void> {
-
const message: ChatPostMessageRequest = {
-
channel: location?.channel || process.env.SLACK_LOG_CHANNEL || "",
-
thread_ts: location?.thread_ts,
-
text: logMessage.substring(0, 2500),
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: logMessage
-
.split("\n")
-
.map((a) => `> ${a}`)
-
.join("\n"),
-
},
-
},
-
{
-
type: "context",
-
elements: [
-
{
+
try {
+
const channel = location?.channel || process.env.SLACK_LOG_CHANNEL;
+
+
if (!channel) {
+
throw new Error("No Slack channel specified for logging");
+
}
+
+
const message: ChatPostMessageRequest = {
+
channel,
+
thread_ts: location?.thread_ts,
+
text: logMessage.substring(0, 2500),
+
blocks: [
+
{
+
type: "section",
+
text: {
type: "mrkdwn",
-
text: `${new Date().toString()}`,
+
text: logMessage
+
.split("\n")
+
.map((a) => `> ${a}`)
+
.join("\n"),
},
-
],
-
},
-
],
-
};
+
},
+
{
+
type: "context",
+
elements: [
+
{
+
type: "mrkdwn",
+
text: `${new Date().toString()}`,
+
},
+
],
+
},
+
],
+
};
-
messageQueue.enqueue(() => sendMessage(message));
+
messageQueue.enqueue(() => sendMessage(message));
+
} catch (error) {
+
Sentry.captureException(error, {
+
extra: { logMessage, location, channel: location?.channel },
+
tags: { type: "slog_error" },
+
});
+
console.error("Failed to queue Slack log message:", error);
+
}
}
type LogType = "info" | "start" | "cron" | "error";
-
export async function clog(logMessage: string, type: LogType): Promise<void> {
+
type LogMetadata = {
+
error?: Error;
+
context?: string;
+
additional?: Record<string, unknown>;
+
};
+
+
export async function clog(
+
logMessage: string,
+
type: LogType,
+
metadata?: LogMetadata,
+
): Promise<void> {
+
const timestamp = new Date().toISOString();
+
const formattedMessage = `[${timestamp}] ${logMessage}`;
+
switch (type) {
case "info":
-
console.log(colors.blue(logMessage));
+
console.log(colors.blue(formattedMessage));
break;
case "start":
-
console.log(colors.green(logMessage));
+
console.log(colors.green(formattedMessage));
break;
case "cron":
-
console.log(colors.magenta(`[CRON]: ${logMessage}`));
+
console.log(colors.magenta(`[CRON]: ${formattedMessage}`));
break;
-
case "error":
-
console.error(
-
colors.red.bold(
-
`Yo <@S0790GPRA48> deres an error \n\n [ERROR]: ${logMessage}`,
-
),
+
case "error": {
+
const errorMessage = colors.red.bold(
+
`Yo <@S0790GPRA48> deres an error \n\n [ERROR]: ${formattedMessage}`,
);
+
console.error(errorMessage);
break;
+
}
default:
-
console.log(logMessage);
+
console.log(formattedMessage);
}
}
···
thread_ts?: string;
channel: string;
},
+
metadata?: LogMetadata,
): Promise<void> {
-
slog(logMessage, location);
-
clog(logMessage, type);
+
try {
+
await Promise.all([
+
slog(logMessage, location),
+
clog(logMessage, type, metadata),
+
]);
+
} catch (error) {
+
console.error("Failed to log message:", error);
+
Sentry.captureException(error, {
+
extra: { logMessage, type, location, metadata },
+
tags: { type: "blog_error" },
+
});
+
}
}
export { clog as default, slog };
+28
src/libs/apiError.ts
···
+
import * as Sentry from "@sentry/bun";
+
+
export interface ApiError {
+
message: string;
+
status: number;
+
}
+
+
export function handleApiError(error: unknown, context: string): Response {
+
if (error instanceof Error) {
+
Sentry.captureException(error, {
+
extra: { context },
+
tags: { type: "api_error" },
+
});
+
+
return new Response(JSON.stringify({ error: error.message }), {
+
status: 500,
+
headers: { "Content-Type": "application/json" },
+
});
+
}
+
+
return new Response(
+
JSON.stringify({ error: "An unexpected error occurred" }),
+
{
+
status: 500,
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
}