• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 25213279233

01 May 2026 11:50AM UTC coverage: 90.17% (-0.03%) from 90.197%
25213279233

push

github

web-flow
Merge pull request #647 from VolvoxLLC/bill/add-sentry-to-project

9985 of 11705 branches covered (85.31%)

Branch coverage included in aggregate %.

607 of 669 new or added lines in 9 files covered. (90.73%)

1 existing line in 1 file now uncovered.

15737 of 16821 relevant lines covered (93.56%)

168.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.54
/src/logger.js
1
/**
2
 * Structured Logger Module
3
 *
4
 * Provides centralized logging with:
5
 * - Multiple log levels (debug, info, warn, error)
6
 * - Timestamp formatting
7
 * - Structured output
8
 * - Console transport (file transport added in phase 3)
9
 *
10
 * TODO: Logger browser shim — this module uses Winston + Node.js APIs (fs, path) and cannot
11
 * be imported in browser/Next.js client components. If client-side structured logging is
12
 * needed (e.g. for error tracking or debug mode), create a thin `web/src/lib/logger.ts`
13
 * shim that wraps the browser console with the same interface (info/warn/error/debug)
14
 * and optionally forwards to a remote logging endpoint.
15
 */
16

17
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
18
import { dirname, join } from 'node:path';
19
import { fileURLToPath } from 'node:url';
20
import winston from 'winston';
21
import DailyRotateFile from 'winston-daily-rotate-file';
22
import { sentryEnabled } from './sentry.js';
23
import { AmplitudeTransport } from './transports/amplitude.js';
24
import { SentryTransport } from './transports/sentry.js';
25
import { WebSocketTransport } from './transports/websocket.js';
26

27
const __dirname = dirname(fileURLToPath(import.meta.url));
36✔
28
const configPath = join(__dirname, '..', 'config.json');
36✔
29
const logsDir = join(__dirname, '..', 'logs');
36✔
30

31
// Load config to get log level and file output setting
32
let logLevel = 'info';
36✔
33
let fileOutputEnabled = false;
36✔
34

35
try {
36✔
36
  if (existsSync(configPath)) {
36✔
37
    const config = JSON.parse(readFileSync(configPath, 'utf-8'));
33✔
38
    logLevel = process.env.LOG_LEVEL || config.logging?.level || 'info';
33!
39
    fileOutputEnabled = config.logging?.fileOutput || false;
33!
40
  }
41
} catch (_err) {
42
  // Fallback to default if config can't be loaded
43
  logLevel = process.env.LOG_LEVEL || 'info';
1✔
44
}
45

46
// Create logs directory if file output is enabled
47
if (fileOutputEnabled) {
36✔
48
  try {
32✔
49
    if (!existsSync(logsDir)) {
32!
50
      mkdirSync(logsDir, { recursive: true });
×
51
    }
52
  } catch (_err) {
53
    // Log directory creation failed, but continue without file logging
54
    fileOutputEnabled = false;
×
55
  }
56
}
57

58
/**
59
 * Sensitive field names that should be redacted from logs.
60
 * Pattern-based matches (any env var ending in `_API_KEY` or `_AUTH_TOKEN`) are
61
 * handled by `isSensitiveKey()` using `SENSITIVE_PATTERNS` so new providers are
62
 * covered automatically.
63
 */
64
const SENSITIVE_FIELDS = [
36✔
65
  'DISCORD_TOKEN',
66
  'CLAUDE_CODE_OAUTH_TOKEN',
67
  'token',
68
  'authToken',
69
  'password',
70
  'apiKey',
71
  'authorization',
72
  'secret',
73
  'clientSecret',
74
  'DATABASE_URL',
75
  'connectionString',
76
];
77

78
/**
79
 * Case-insensitive suffix patterns that mark a key as sensitive. Covers both
80
 * snake_case (`MINIMAX_API_KEY`, `PROVIDER_AUTH_TOKEN`) and camelCase
81
 * (`classifyApiKey`, `respondApiKey`, `authToken`) conventions so new providers
82
 * and config keys are redacted without per-field maintenance. The generic
83
 * `secret` suffix catches SESSION_SECRET, NEXTAUTH_SECRET, clientSecret, etc.
84
 */
85
const SENSITIVE_PATTERNS = [
36✔
86
  /(?:^|[_-])api[_-]?key$/i,
87
  /apiKey$/i,
88
  /(?:^|[_-])auth[_-]?token$/i,
89
  /authToken$/i,
90
  /secret$/i,
91
];
92

93
/**
94
 * Inline-value patterns that redact substrings inside log messages. Used as a
95
 * last-line defence when a credential appears inside a message string rather
96
 * than as a metadata key (e.g. when an SDK reflects auth headers into its
97
 * error message).
98
 *
99
 * Each pattern replaces the matched substring with `[REDACTED]`. Keep these
100
 * tight — an over-broad pattern corrupts legitimate messages.
101
 */
102
const INLINE_SECRET_PATTERNS = [
36✔
103
  /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
104
  // Covers both generic `sk-…` secrets and Anthropic `sk-ant-…` tokens — the
105
  // broader pattern already matches `ant-` within the character class, so a
106
  // dedicated `sk-ant-…` entry would never fire.
107
  /sk-[A-Za-z0-9_-]{20,}/g,
108
];
109

110
/**
111
 * Scrub inline secrets from a free-form string. Returns the original value
112
 * unchanged if nothing matches or input is non-string.
113
 * @param {unknown} value
114
 * @returns {unknown}
115
 */
116
function scrubInlineSecrets(value) {
117
  if (typeof value !== 'string') return value;
1,807!
118
  let out = value;
1,807✔
119
  for (const pattern of INLINE_SECRET_PATTERNS) {
1,807✔
120
    out = out.replace(pattern, '[REDACTED]');
3,614✔
121
  }
122
  return out;
1,807✔
123
}
124

125
/**
126
 * Determine whether a key name is sensitive.
127
 * Exact (case-insensitive) match against SENSITIVE_FIELDS, or a suffix match
128
 * against SENSITIVE_PATTERNS.
129
 *
130
 * @param {string} key
131
 * @returns {boolean}
132
 */
133
function isSensitiveKey(key) {
134
  if (typeof key !== 'string') return false;
1,220!
135
  const lower = key.toLowerCase();
1,220✔
136
  if (SENSITIVE_FIELDS.some((field) => field.toLowerCase() === lower)) return true;
13,310✔
137
  return SENSITIVE_PATTERNS.some((pattern) => pattern.test(key));
6,020✔
138
}
139

140
/**
141
 * Clone an Error, preserving its subclass and scrubbing secrets from `message`,
142
 * `stack`, and every enumerable own-property (including `cause`). Called from
143
 * `filterSensitiveData` whenever an Error surfaces inside log metadata.
144
 *
145
 * The `seen` WeakMap short-circuits cyclic error graphs — e.g. `err.cause = err`
146
 * or an `AggregateError` whose `errors` array contains itself. The clone is
147
 * registered in `seen` BEFORE recursing, so any back-reference at any depth
148
 * resolves to the already-being-built clone instead of stack-overflowing.
149
 *
150
 * @param {Error} err
151
 * @param {WeakMap<object, unknown>} [seen]
152
 * @returns {Error}
153
 */
154
function cloneAndScrubError(err, seen = new WeakMap()) {
34✔
155
  if (seen.has(err)) return seen.get(err);
34✔
156

157
  const scrubbedMessage = scrubInlineSecrets(err.message);
30✔
158

159
  // Use Object.create + defineProperty rather than `new Ctor(scrubbedMessage)`
160
  // because several built-in Error subclasses (most notably AggregateError)
161
  // interpret their first constructor argument as an iterable of sub-errors
162
  // rather than a message string — `new AggregateError("Bearer …")` throws
163
  // or silently discards the message. Direct prototype instantiation
164
  // sidesteps every subclass's constructor quirks while preserving the
165
  // prototype chain so `instanceof` checks downstream still hold.
166
  const cloned = Object.create(Object.getPrototypeOf(err));
30✔
167

168
  // Register BEFORE recursing so cyclic references resolve to this clone.
169
  seen.set(err, cloned);
30✔
170

171
  Object.defineProperty(cloned, 'message', {
30✔
172
    value: scrubbedMessage,
173
    writable: true,
174
    enumerable: false,
175
    configurable: true,
176
  });
177
  Object.defineProperty(cloned, 'name', {
30✔
178
    value: err.name,
179
    writable: true,
180
    enumerable: false,
181
    configurable: true,
182
  });
183
  if (typeof err.stack === 'string') {
30!
184
    Object.defineProperty(cloned, 'stack', {
30✔
185
      value: scrubInlineSecrets(err.stack),
186
      writable: true,
187
      enumerable: false,
188
      configurable: true,
189
    });
190
  }
191

192
  const scrubValue = (value) => {
30✔
193
    if (typeof value === 'object' && value !== null) return filterSensitiveData(value, seen);
16✔
194
    if (typeof value === 'string') return scrubInlineSecrets(value);
2!
195
    return value;
×
196
  };
197

198
  // `cause` set via `new Error(msg, { cause })` is a non-enumerable own-property,
199
  // so Object.entries misses it. Preserve its non-enumerable shape on the clone.
200
  if (Object.hasOwn(err, 'cause')) {
30✔
201
    Object.defineProperty(cloned, 'cause', {
4✔
202
      value: scrubValue(err.cause),
203
      writable: true,
204
      enumerable: false,
205
      configurable: true,
206
    });
207
  }
208

209
  // AggregateError carries its sub-errors on the non-enumerable own-property
210
  // `errors`. Recurse into each so a leaked Bearer token in a child error's
211
  // message gets scrubbed the same way a top-level one would.
212
  //
213
  // A non-array `errors` (e.g. `{ field: 'invalid' }` on a custom ValidationError)
214
  // would otherwise be dropped entirely — the enumerable-copy loop below
215
  // skips it because `errors` is non-enumerable. Preserve whatever shape the
216
  // caller set by scrubbing it through `scrubValue`.
217
  if (Object.hasOwn(err, 'errors') && Array.isArray(err.errors)) {
30✔
218
    cloned.errors = err.errors.map((sub) => scrubValue(sub));
8✔
219
  } else if (Object.hasOwn(err, 'errors')) {
24✔
220
    cloned.errors = scrubValue(err.errors);
2✔
221
  }
222

223
  // Copy remaining enumerable own-properties (code, custom fields), scrubbing
224
  // each the same way filterSensitiveData would for a plain object.
225
  for (const [key, value] of Object.entries(err)) {
30✔
226
    if (
9✔
227
      key === 'message' ||
45✔
228
      key === 'stack' ||
229
      key === 'name' ||
230
      key === 'cause' ||
231
      key === 'errors'
232
    )
233
      continue;
5✔
234
    if (isSensitiveKey(key)) {
4✔
235
      cloned[key] = '[REDACTED]';
2✔
236
    } else {
237
      cloned[key] = scrubValue(value);
2✔
238
    }
239
  }
240
  return cloned;
30✔
241
}
242

243
/**
244
 * Recursively filter sensitive data from objects.
245
 * - Keys matching the sensitive list/patterns → `[REDACTED]`.
246
 * - String values get scrubbed for inline secrets (e.g. `Bearer <token>`).
247
 * - Nested objects/arrays recurse.
248
 *
249
 * `Error` instances are cloned (preserving subclass so `instanceof TypeError`
250
 * still holds downstream) with `message`, `stack`, and every enumerable own
251
 * property scrubbed. This closes the nested-leak path where
252
 * `{ cause: new Error('Bearer sk-…') }` would otherwise land in log output
253
 * unredacted — the top-level format only scrubs `info.message` / `info.stack`.
254
 *
255
 * The `seen` WeakMap threads through every recursive call (arrays, plain
256
 * objects, and Error clones) so cyclic graphs — e.g. an object that refers
257
 * back to an ancestor — short-circuit to the previously-built clone instead
258
 * of stack-overflowing.
259
 *
260
 * @param {unknown} obj
261
 * @param {WeakMap<object, unknown>} [seen]
262
 */
263
function filterSensitiveData(obj, seen = new WeakMap()) {
44✔
264
  if (obj === null || obj === undefined) {
44!
265
    return obj;
×
266
  }
267

268
  if (typeof obj === 'string') {
44!
269
    return scrubInlineSecrets(obj);
×
270
  }
271

272
  if (typeof obj !== 'object') {
44!
273
    return obj;
×
274
  }
275

276
  if (obj instanceof Error) {
44✔
277
    return cloneAndScrubError(obj, seen);
34✔
278
  }
279

280
  if (seen.has(obj)) return seen.get(obj);
10!
281

282
  if (Array.isArray(obj)) {
10✔
283
    const out = [];
2✔
284
    seen.set(obj, out);
2✔
285
    for (const item of obj) {
2✔
286
      out.push(filterSensitiveData(item, seen));
4✔
287
    }
288
    return out;
2✔
289
  }
290

291
  const filtered = {};
8✔
292
  seen.set(obj, filtered);
8✔
293
  for (const [key, value] of Object.entries(obj)) {
8✔
294
    if (isSensitiveKey(key)) {
16✔
295
      filtered[key] = '[REDACTED]';
6✔
296
    } else if (typeof value === 'object' && value !== null) {
10!
297
      filtered[key] = filterSensitiveData(value, seen);
×
298
    } else if (typeof value === 'string') {
10!
299
      filtered[key] = scrubInlineSecrets(value);
10✔
300
    } else {
301
      filtered[key] = value;
×
302
    }
303
  }
304

305
  return filtered;
8✔
306
}
307

308
/**
309
 * Winston format that redacts sensitive data
310
 */
311
const redactSensitiveData = winston.format((info) => {
36✔
312
  // Reserved winston properties that should not be recursively filtered as
313
  // metadata — but `message` and `stack` are still scanned for inline secrets
314
  // (SDK errors sometimes reflect `Bearer <token>` into their message text).
315
  const reserved = ['level', 'message', 'timestamp', 'stack'];
956✔
316

317
  // Filter each property in the info object
318
  for (const key in info) {
956✔
319
    if (Object.hasOwn(info, key) && !reserved.includes(key)) {
3,112✔
320
      if (isSensitiveKey(key)) {
1,200✔
321
        info[key] = '[REDACTED]';
8✔
322
      } else if (typeof info[key] === 'object' && info[key] !== null) {
1,192✔
323
        // Recursively filter nested objects
324
        info[key] = filterSensitiveData(info[key]);
26✔
325
      } else if (typeof info[key] === 'string') {
1,166✔
326
        info[key] = scrubInlineSecrets(info[key]);
779✔
327
      }
328
    }
329
  }
330

331
  // Scrub the message string and stack trace for inline credentials. This is
332
  // the last line of defence against an SDK leaking a token into its error
333
  // message before Sentry/Postgres/file transports persist it.
334
  if (typeof info.message === 'string') {
956!
335
    info.message = scrubInlineSecrets(info.message);
956✔
336
  }
337
  if (typeof info.stack === 'string') {
956!
338
    info.stack = scrubInlineSecrets(info.stack);
×
339
  }
340

341
  return info;
956✔
342
})();
343

344
/**
345
 * Emoji mapping for log levels
346
 */
347
const EMOJI_MAP = {
36✔
348
  error: '❌',
349
  warn: '⚠️',
350
  info: '✅',
351
  debug: '🔍',
352
};
353

354
/**
355
 * Format that stores the original level before colorization
356
 */
357
const preserveOriginalLevel = winston.format((info) => {
36✔
358
  info.originalLevel = info.level;
325✔
359
  return info;
325✔
360
})();
361

362
/**
363
 * Circular-reference-safe JSON.stringify replacer. Logged errors may carry
364
 * cyclic graphs (e.g. `err.cause = err`, or `AggregateError.errors` containing
365
 * the aggregate itself) — `cloneAndScrubError` intentionally preserves those
366
 * back-references rather than re-cloning into infinity, so the formatter must
367
 * also tolerate them instead of throwing "Converting circular structure to JSON".
368
 *
369
 * @returns {(key: string, value: unknown) => unknown}
370
 */
371
function circularSafeReplacer() {
372
  const seen = new WeakSet();
264✔
373
  return (_key, value) => {
264✔
374
    if (typeof value === 'object' && value !== null) {
691✔
375
      if (seen.has(value)) return '[Circular]';
287✔
376
      seen.add(value);
286✔
377
    }
378
    return value;
690✔
379
  };
380
}
381

382
/**
383
 * Custom format for console output with emoji prefixes
384
 */
385
const consoleFormat = winston.format.printf(
36✔
386
  ({ level, message, timestamp, originalLevel, ...meta }) => {
387
    // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes
388
    const prefix = EMOJI_MAP[originalLevel] || '📝';
325!
389
    const metaStr =
390
      Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, circularSafeReplacer())}` : '';
325✔
391

392
    const lvl = typeof originalLevel === 'string' ? originalLevel : (level ?? 'info');
325!
393
    return `${prefix} [${timestamp}] ${lvl.toUpperCase()}: ${message}${metaStr}`;
325✔
394
  },
395
);
396

397
/**
398
 * Create winston logger instance
399
 */
400
const transports = [
36✔
401
  new winston.transports.Console({
402
    format: winston.format.combine(
403
      redactSensitiveData,
404
      preserveOriginalLevel,
405
      winston.format.colorize(),
406
      winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
407
      consoleFormat,
408
    ),
409
  }),
410
];
411

412
// Add file transport if enabled in config
413
if (fileOutputEnabled) {
36✔
414
  transports.push(
32✔
415
    new DailyRotateFile({
416
      filename: join(logsDir, 'combined-%DATE%.log'),
417
      datePattern: 'YYYY-MM-DD',
418
      maxSize: '20m',
419
      maxFiles: '14d',
420
      format: winston.format.combine(
421
        redactSensitiveData,
422
        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
423
        winston.format.json(),
424
      ),
425
    }),
426
  );
427

428
  // Separate transport for error-level logs only
429
  transports.push(
32✔
430
    new DailyRotateFile({
431
      level: 'error',
432
      filename: join(logsDir, 'error-%DATE%.log'),
433
      datePattern: 'YYYY-MM-DD',
434
      maxSize: '20m',
435
      maxFiles: '14d',
436
      format: winston.format.combine(
437
        redactSensitiveData,
438
        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
439
        winston.format.json(),
440
      ),
441
    }),
442
  );
443
}
444

445
// Add Sentry transport if enabled — error logs go to Sentry for alerting/grouping
446
if (sentryEnabled) {
36!
NEW
447
  transports.push(new SentryTransport({ level: 'error' }));
×
448
}
449

450
// Always install the Amplitude transport so dotenv-loaded API keys that become
451
// available after this module is imported can still enable info/warn telemetry.
452
// trackAnalyticsEvent() reads process.env at call time and no-ops while disabled.
453
transports.push(new AmplitudeTransport({ level: 'info' }));
36✔
454

455
const logger = winston.createLogger({
36✔
456
  level: logLevel,
457
  format: winston.format.combine(
458
    winston.format.errors({ stack: true }),
459
    winston.format.splat(),
460
    redactSensitiveData,
461
  ),
462
  transports,
463
});
464

465
/**
466
 * Log at debug level
467
 */
468
export function debug(message, meta = {}) {
2✔
469
  logger.debug(message, meta);
2✔
470
}
471

472
/**
473
 * Log at info level
474
 */
475
export function info(message, meta = {}) {
271✔
476
  logger.info(message, meta);
271✔
477
}
478

479
/**
480
 * Log at warn level
481
 */
482
export function warn(message, meta = {}) {
51✔
483
  logger.warn(message, meta);
51✔
484
}
485

486
/**
487
 * Log a message at the error level.
488
 * @param {string|Error|any} message - The message or error to log.
489
 * @param {Object} [meta={}] - Additional metadata to include with the log.
490
 */
491
export function error(message, meta = {}) {
3✔
492
  logger.error(message, meta);
3✔
493
}
494

495
/**
496
 * Create and add a WebSocket transport to the logger.
497
 * Returns the transport instance so it can be passed to the WS server setup.
498
 *
499
 * @returns {WebSocketTransport} The transport instance
500
 */
501
export function addWebSocketTransport() {
502
  const transport = new WebSocketTransport({
×
503
    level: logLevel,
504
    format: winston.format.combine(
505
      redactSensitiveData,
506
      winston.format.timestamp(),
507
      winston.format.json(),
508
    ),
509
  });
510

511
  logger.add(transport);
×
512
  return transport;
×
513
}
514

515
/**
516
 * Remove a WebSocket transport from the logger.
517
 *
518
 * @param {WebSocketTransport} transport - The transport to remove
519
 */
520
export function removeWebSocketTransport(transport) {
521
  if (transport) {
×
522
    transport.close();
×
523
    logger.remove(transport);
×
524
  }
525
}
526

527
// Default export for convenience
528
export default {
529
  debug,
530
  info,
531
  warn,
532
  error,
533
  logger, // Export winston logger instance for advanced usage
534
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc