Skip to content

Commit a548350

Browse files
authored
feat: Sentry error monitoring integration (#38) (#91)
* feat(sentry): add @sentry/node dependency * feat(sentry): add Sentry initialization module with environment config * feat(sentry): wire up error capture for Discord client, commands, shard disconnect, and shutdown flush * feat(sentry): add Express error handler middleware for API error capture * feat(sentry): capture database pool errors * docs: add Sentry environment variables to .env.example * test(sentry): add tests for Sentry module initialization * refactor(sentry): use Winston transport instead of manual captureException calls - Created SentryTransport (src/transports/sentry.js) that forwards error/warn logs to Sentry - Added transport to logger.js — single integration point - Removed all manual if(sentryEnabled) checks from index.js, server.js, db.js - Metadata 'source', 'command', 'module' auto-promoted to Sentry tags - Stack traces reconstructed for proper Sentry grouping * fix(sentry): use Number.isFinite for trace rate parsing, update SDK version comment, add edge case tests
1 parent d1a4d45 commit a548350

10 files changed

Lines changed: 964 additions & 89 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ MEM0_API_KEY=your_mem0_api_key
100100
# URL to POST when guild config changes (optional — notifies dashboard, 5s timeout)
101101
# DASHBOARD_WEBHOOK_URL=https://example.com/hooks/dashboard-config-changed
102102

103+
# ── Sentry (Error Monitoring) ────────────────
104+
105+
# Sentry DSN — enables error tracking and alerting (optional)
106+
# Get yours at https://sentry.io → Create Project → Node.js
107+
# SENTRY_DSN=https://[email protected]/0
108+
109+
# Sentry environment name (optional — default: NODE_ENV or 'production')
110+
# SENTRY_ENVIRONMENT=production
111+
112+
# Sentry performance sampling rate 0-1 (optional — default: 0.1 = 10%)
113+
# SENTRY_TRACES_RATE=0.1
114+
103115
# ── Logging ──────────────────────────────────
104116

105117
# Logging level (optional: debug, info, warn, error — default: info)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
},
2323
"dependencies": {
2424
"@anthropic-ai/claude-code": "^2.1.44",
25+
"@sentry/node": "^10.40.0",
2526
"discord.js": "^14.25.1",
2627
"dotenv": "^17.3.1",
2728
"express": "^5.2.1",

pnpm-lock.yaml

Lines changed: 732 additions & 85 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/db.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export async function initDb() {
104104

105105
// Prevent unhandled pool errors from crashing the process
106106
pool.on('error', (err) => {
107-
logError('Unexpected database pool error', { error: err.message });
107+
logError('Unexpected database pool error', { error: err.message, source: 'database_pool' });
108108
});
109109

110110
try {

src/index.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
* - Structured logging
1212
*/
1313

14+
// Sentry must be imported before all other modules to instrument them
15+
import './sentry.js';
16+
1417
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1518
import { dirname, join } from 'node:path';
1619
import { fileURLToPath } from 'node:url';
@@ -218,7 +221,7 @@ client.on('interactionCreate', async (interaction) => {
218221
await command.execute(interaction);
219222
info('Command executed', { command: commandName, user: interaction.user.tag });
220223
} catch (err) {
221-
error('Command error', { command: commandName, error: err.message, stack: err.stack });
224+
error('Command error', { command: commandName, error: err.message, stack: err.stack, source: 'slash_command' });
222225

223226
const errorMessage = {
224227
content: '❌ An error occurred while executing this command.',
@@ -283,11 +286,14 @@ async function gracefulShutdown(signal) {
283286
error('Failed to close database pool', { error: err.message });
284287
}
285288

286-
// 5. Destroy Discord client
289+
// 5. Flush Sentry events before exit (no-op if Sentry disabled)
290+
await import('./sentry.js').then(({ Sentry }) => Sentry.flush(2000)).catch(() => {});
291+
292+
// 6. Destroy Discord client
287293
info('Disconnecting from Discord');
288294
client.destroy();
289295

290-
// 6. Log clean exit
296+
// 7. Log clean exit
291297
info('Shutdown complete');
292298
process.exit(0);
293299
}
@@ -302,9 +308,16 @@ client.on('error', (err) => {
302308
error: err.message,
303309
stack: err.stack,
304310
code: err.code,
311+
source: 'discord_client',
305312
});
306313
});
307314

315+
client.on('shardDisconnect', (event, shardId) => {
316+
if (event.code !== 1000) {
317+
warn('Shard disconnected unexpectedly', { shardId, code: event.code, source: 'discord_shard' });
318+
}
319+
});
320+
308321
// Start bot
309322
const token = process.env.DISCORD_TOKEN;
310323
if (!token) {
@@ -421,6 +434,15 @@ async function startup() {
421434
await loadCommands();
422435
await client.login(token);
423436

437+
// Set Sentry context now that we know the bot identity (no-op if disabled)
438+
import('./sentry.js').then(({ Sentry, sentryEnabled }) => {
439+
if (sentryEnabled) {
440+
Sentry.setTag('bot.username', client.user?.tag || 'unknown');
441+
Sentry.setTag('bot.version', BOT_VERSION);
442+
info('Sentry error monitoring enabled', { environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production' });
443+
}
444+
}).catch(() => {});
445+
424446
// Start REST API server with WebSocket log streaming (non-fatal — bot continues without it)
425447
{
426448
let wsTransport = null;

src/logger.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { fileURLToPath } from 'node:url';
1414
import winston from 'winston';
1515
import DailyRotateFile from 'winston-daily-rotate-file';
1616
import { PostgresTransport } from './transports/postgres.js';
17+
import { sentryEnabled } from './sentry.js';
18+
import { SentryTransport } from './transports/sentry.js';
1719
import { WebSocketTransport } from './transports/websocket.js';
1820

1921
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -204,6 +206,11 @@ if (fileOutputEnabled) {
204206
);
205207
}
206208

209+
// Add Sentry transport if enabled — all error/warn logs automatically go to Sentry
210+
if (sentryEnabled) {
211+
transports.push(new SentryTransport({ level: 'warn' }));
212+
}
213+
207214
const logger = winston.createLogger({
208215
level: logLevel,
209216
format: winston.format.combine(winston.format.errors({ stack: true }), winston.format.splat()),

src/sentry.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Sentry Error Monitoring
3+
*
4+
* Initializes Sentry for error tracking, performance monitoring,
5+
* and alerting. Must be imported before any other application code.
6+
*
7+
* Configure via environment variables:
8+
* SENTRY_DSN - Sentry project DSN (required to enable)
9+
* SENTRY_ENVIRONMENT - Environment name (default: 'production')
10+
* SENTRY_TRACES_RATE - Performance sampling rate 0-1 (default: 0.1)
11+
* NODE_ENV - Used as fallback for environment name
12+
*/
13+
14+
import * as Sentry from '@sentry/node';
15+
16+
const dsn = process.env.SENTRY_DSN;
17+
18+
/**
19+
* Whether Sentry is actively initialized.
20+
* Use this to guard optional Sentry calls in hot paths.
21+
*/
22+
export const sentryEnabled = Boolean(dsn);
23+
24+
if (dsn) {
25+
Sentry.init({
26+
dsn,
27+
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production',
28+
29+
// Performance monitoring — sample 10% of transactions by default
30+
// Use ?? so SENTRY_TRACES_RATE=0 explicitly disables tracing
31+
tracesSampleRate: (() => {
32+
const parsed = parseFloat(process.env.SENTRY_TRACES_RATE);
33+
return Number.isFinite(parsed) ? parsed : 0.1;
34+
})(),
35+
36+
// Automatically capture unhandled rejections and uncaught exceptions
37+
autoSessionTracking: true,
38+
39+
// Filter out noisy/expected errors
40+
beforeSend(event) {
41+
// Skip AbortError from intentional request cancellations
42+
const message = event.exception?.values?.[0]?.value || '';
43+
if (message.includes('AbortError') || message.includes('The operation was aborted')) {
44+
return null;
45+
}
46+
return event;
47+
},
48+
49+
// Add useful default tags
50+
initialScope: {
51+
tags: {
52+
service: 'volvox-bot',
53+
},
54+
},
55+
});
56+
}
57+
58+
export { Sentry };

src/transports/sentry.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Sentry Winston Transport
3+
*
4+
* Forwards error and warn level logs to Sentry automatically.
5+
* This is the single integration point — no need to call
6+
* Sentry.captureException() manually throughout the codebase.
7+
*/
8+
9+
import Transport from 'winston-transport';
10+
import { Sentry } from '../sentry.js';
11+
12+
/**
13+
* Winston transport that sends error/warn logs to Sentry.
14+
*
15+
* - 'error' level → Sentry.captureException (if Error) or captureMessage (if string)
16+
* - 'warn' level → Sentry.captureMessage with 'warning' severity
17+
*
18+
* Metadata from the log entry is attached as Sentry extra context,
19+
* and recognized tags (source, command, module) are promoted to Sentry tags.
20+
*/
21+
export class SentryTransport extends Transport {
22+
/**
23+
* @param {Object} [opts]
24+
* @param {string} [opts.level='error'] - Minimum log level to forward
25+
*/
26+
constructor(opts = {}) {
27+
super({ level: opts.level || 'warn', ...opts });
28+
}
29+
30+
/**
31+
* Known metadata keys to promote to Sentry tags.
32+
* Everything else goes into Sentry 'extra' context.
33+
*/
34+
static TAG_KEYS = new Set(['source', 'command', 'module', 'code', 'shardId']);
35+
36+
/**
37+
* @param {Object} info - Winston log info object
38+
* @param {Function} callback
39+
*/
40+
log(info, callback) {
41+
const { level, message, timestamp, stack, ...meta } = info;
42+
43+
// Separate tags from extra context
44+
const tags = {};
45+
const extra = {};
46+
for (const [key, value] of Object.entries(meta)) {
47+
if (SentryTransport.TAG_KEYS.has(key) && (typeof value === 'string' || typeof value === 'number')) {
48+
tags[key] = String(value);
49+
} else if (key !== 'originalLevel' && key !== 'splat') {
50+
extra[key] = value;
51+
}
52+
}
53+
54+
const context = {
55+
tags,
56+
extra,
57+
};
58+
59+
if (level === 'error') {
60+
// If we have a stack trace, reconstruct an Error for better Sentry grouping
61+
if (stack) {
62+
const err = new Error(message);
63+
err.stack = stack;
64+
Sentry.captureException(err, context);
65+
} else if (meta.error && typeof meta.error === 'string') {
66+
// Common pattern: error('Something failed', { error: err.message })
67+
Sentry.captureMessage(`${message}: ${meta.error}`, { ...context, level: 'error' });
68+
} else {
69+
Sentry.captureMessage(message, { ...context, level: 'error' });
70+
}
71+
} else if (level === 'warn') {
72+
Sentry.captureMessage(message, { ...context, level: 'warning' });
73+
}
74+
75+
callback();
76+
}
77+
}

tests/index.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ describe('index.js', () => {
698698
error: 'discord broke',
699699
stack: 'stack',
700700
code: 500,
701+
source: 'discord_client',
701702
});
702703
});
703704

tests/sentry.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Tests for Sentry integration module
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6+
7+
describe('sentry module', () => {
8+
beforeEach(() => {
9+
vi.resetModules();
10+
});
11+
12+
afterEach(() => {
13+
vi.unstubAllEnvs();
14+
});
15+
16+
it('should export sentryEnabled as false when SENTRY_DSN is not set', async () => {
17+
vi.stubEnv('SENTRY_DSN', '');
18+
const mod = await import('../src/sentry.js');
19+
expect(mod.sentryEnabled).toBe(false);
20+
});
21+
22+
it('should export Sentry namespace', async () => {
23+
vi.stubEnv('SENTRY_DSN', '');
24+
const mod = await import('../src/sentry.js');
25+
expect(mod.Sentry).toBeDefined();
26+
expect(typeof mod.Sentry.captureException).toBe('function');
27+
expect(typeof mod.Sentry.captureMessage).toBe('function');
28+
});
29+
30+
it('should export sentryEnabled as true when SENTRY_DSN is set', async () => {
31+
vi.stubEnv('SENTRY_DSN', 'https://[email protected]/0');
32+
const mod = await import('../src/sentry.js');
33+
expect(mod.sentryEnabled).toBe(true);
34+
});
35+
36+
it('should allow SENTRY_TRACES_RATE=0 to disable tracing', async () => {
37+
vi.stubEnv('SENTRY_DSN', '');
38+
vi.stubEnv('SENTRY_TRACES_RATE', '0');
39+
// Module parses rate independently of DSN — just verify it doesn't throw
40+
const mod = await import('../src/sentry.js');
41+
expect(mod.Sentry).toBeDefined();
42+
});
43+
44+
it('should fall back to default trace rate for non-numeric values', async () => {
45+
vi.stubEnv('SENTRY_DSN', '');
46+
vi.stubEnv('SENTRY_TRACES_RATE', 'not-a-number');
47+
const mod = await import('../src/sentry.js');
48+
expect(mod.Sentry).toBeDefined();
49+
});
50+
});

0 commit comments

Comments
 (0)