import * as Sentry from '@sentry/node' import type { AutocompleteInteraction } from 'discord.js' import env from 'env-var' import { type ApplicationInteraction, type SleetClient, SleetModule, type SleetModuleMiddleware, } from 'sleetcord' import { interactionToString } from '../utils/stringify.ts' import { censorPath } from './logging.ts' export { Sentry } const NODE_ENV: string = env.get('NODE_ENV').required().asString() const SENTRY_DSN: string = env.get('SENTRY_DSN').asString() ?? '' /** * Init Sentry, enabling logging * * This comes with default settings for Sentry, but you can override any of them by providing options * * Requires the Sentry DSN to be set as an env var `SENTRY_DSN` * @param options Sentry options */ export function initSentry(options?: Sentry.NodeOptions) { if (!SENTRY_DSN) { console.warn('SENTRY_DSN not set, not initializing Sentry. Set it to enable Sentry.') return } Sentry.init({ dsn: SENTRY_DSN, environment: NODE_ENV, integrations: [ Sentry.dedupeIntegration(), Sentry.localVariablesIntegration({ captureAllExceptions: true, maxExceptionsPerSecond: 5, }), ], // Performance Monitoring tracesSampleRate: 0.1, // Set sampling rate for profiling - this is relative to tracesSampleRate profilesSampleRate: 0.5, includeLocalVariables: true, // Censor out tokens from url breadcrumbs, they're not very useful beforeBreadcrumb(breadcrumb) { if (typeof breadcrumb.data?.url === 'string') { breadcrumb.data.url = censorPath(breadcrumb.data.url) } return breadcrumb }, // Apply any provided options ...options, }) } /** * A module runner designed to run every module+event under a Sentry span * for error tracing * @param module The module to run * @param callback The callback that runs the module * @param event The event that will be handled by the module * @returns The result of running that module */ export const sentryMiddleware: SleetModuleMiddleware = async (module, event, next) => { await Sentry.startSpan( { name: `${module.name}:${event.name}`, op: 'module', }, async (span) => { const scope = Sentry.getCurrentScope() scope.setTag('module', module.name) try { await next() } catch (e) { Sentry.captureException(e instanceof Error ? e : new Error(String(e)), { tags: { module: module.name, type: 'unhandled-module-error', }, }) span.setAttribute('error', e instanceof Error ? e.message : String(e)) } finally { scope.clearBreadcrumbs() } }, ) } export const sentryLogger: SleetModule = new SleetModule( { name: 'sentryLogger', }, { // discord.js error error(error) { Sentry.captureException(error, { tags: { ...moduleName(this.sleet), type: 'discord.js-error', }, }) }, sleetError(message, error) { Sentry.captureException(error, { tags: { ...moduleName(this.sleet), type: 'sleet-error', }, extra: { message, }, }) }, // discord.js warning warn(message) { Sentry.captureMessage(message, { level: 'warning', tags: { ...moduleName(this.sleet), type: 'discord.js-warning', }, }) }, sleetWarn(message, data) { Sentry.captureMessage(message, { level: 'warning', tags: { ...moduleName(this.sleet), type: 'sleet-warn', }, extra: { data, }, }) }, autocompleteInteractionError: interactionErrorHandler, applicationInteractionError: interactionErrorHandler, }, { middleware: SENTRY_DSN ? [sentryMiddleware] : [], }, ) function interactionErrorHandler( module: SleetModule, interaction: ApplicationInteraction | AutocompleteInteraction, error: unknown, ) { Sentry.captureException(error, { extra: { interaction: interactionToString(interaction), }, tags: { moduleName: module?.name, type: 'interaction-error', commandName: interaction.commandName, }, }) } function moduleName(sleet: SleetClient): { moduleName: string } | undefined { const module = sleet.runningModuleStore.getStore() if (module) { return { moduleName: module.name } } return undefined }