• 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

85.59
/src/sentry.js
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_SEND_DEFAULT_PII - Opt in to Sentry default PII capture after local scrubbing
11
 *                             (default: false; only true when set to 'true')
12
 *   SENTRY_TRACES_RATE   - Performance sampling rate 0-1 (default: 0.1)
13
 *   NODE_ENV             - Used as fallback for environment name
14
 */
15

16
import 'dotenv/config';
17
import * as Sentry from '@sentry/node';
18

19
const dsn = process.env.SENTRY_DSN;
82✔
20
// Keep Sentry default PII capture opt-in only; any value other than the explicit string "true" stays disabled.
21
// dotenv/config is imported above before env reads so .env Sentry settings are available
22
// even when this module is imported before other startup code.
23
let sendDefaultPii = false;
82✔
24
const CIRCULAR_REFERENCE_SENTINEL = '[Circular]';
82✔
25
const SENSITIVE_KEY_PARTS = [
82✔
26
  'authorization',
27
  'cookie',
28
  'csrf',
29
  'email',
30
  'secret',
31
  'password',
32
  'token',
33
  'session',
34
  'stack',
35
  'xforwardedfor',
36
  'ipaddress',
37
  'xapikey',
38
  'apikey',
39
  'botapisecret',
40
  'accesstoken',
41
  'refreshtoken',
42
];
43
const SENSITIVE_KEY_EXACT_MATCHES = new Set(['ip']);
82✔
44
const SENSITIVE_IP_KEY_SUFFIXES = [
82✔
45
  'actorip',
46
  'clientip',
47
  'destinationip',
48
  'externalip',
49
  'forwardedip',
50
  'hostip',
51
  'internalip',
52
  'lastloginip',
53
  'localip',
54
  'originip',
55
  'peerip',
56
  'privateip',
57
  'publicip',
58
  'realip',
59
  'remoteip',
60
  'requestip',
61
  'responseip',
62
  'serverip',
63
  'socketip',
64
  'sourceip',
65
  'userip',
66
  'visitorip',
67
];
68
const URL_METADATA_KEY_PATTERN = /url/i;
82✔
69
const URL_HEADER_KEY_PATTERN = /^(?:referer|referrer|origin)$/i;
82✔
70
const ABSOLUTE_URL_IN_TEXT_PATTERN = /\b[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi;
82✔
71
const RELATIVE_URL_IN_TEXT_PATTERN = /(^|[\s(["'])((?:\/|\.\.?\/)[^\s"'<>?#]*(?:[?#][^\s"'<>]*)+)/g;
82✔
72
const INLINE_SECRET_REPLACEMENTS = [
82✔
73
  { pattern: /\bBearer\s+[\w.~+/=-]+/gi, replacement: '[REDACTED]' },
74
  { pattern: /\bsk-\w[\w-]{10,}/g, replacement: '[REDACTED]' },
75
  {
76
    pattern: /\b(?:xox[baprs]|gh[pousr])_[\w/-]{10,}/g,
77
    replacement: '[REDACTED]',
78
  },
79
  { pattern: /\bgithub_pat_\w{10,}/g, replacement: '[REDACTED]' },
80
  {
81
    pattern:
82
      /([?&#]\s*(?:access[-_]?token|refresh[-_]?token|api[-_]?key|token|secret|password)\s*=)\s*[^\s&#]+/gi,
83
    replacement: '$1[REDACTED]',
84
  },
85
  {
86
    pattern:
87
      /(^|[\s,;])((?:access[-_]?token|refresh[-_]?token|api[-_]?key|token|secret|password)\s*=)\s*[^\s,;&#]+/gi,
88
    replacement: '$1$2[REDACTED]',
89
  },
90
];
91

92
/**
93
 * Redact secret-looking substrings from free-form strings before they reach Sentry.
94
 * @param {string} value - The string to scrub.
95
 * @returns {string} The string with inline secrets redacted.
96
 */
97
function scrubInlineSecrets(value) {
98
  return INLINE_SECRET_REPLACEMENTS.reduce(
91✔
99
    (scrubbedValue, { pattern, replacement }) => scrubbedValue.replaceAll(pattern, replacement),
546✔
100
    value,
101
  );
102
}
103

104
/**
105
 * Normalize object keys so equivalent sensitive forms (api_key, api-key, api key) match.
106
 * @param {string} key - Object key to normalize.
107
 * @returns {string} Lowercase alphanumeric key.
108
 */
109
function normalizeSensitiveKey(key) {
110
  return key.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
106✔
111
}
112

113
/**
114
 * Check whether an object key is sensitive and should be removed from Sentry metadata.
115
 * @param {string} key - Object key to evaluate.
116
 * @returns {boolean} True when the key represents secret or PII metadata.
117
 */
118
function isIpMetadataKey(key, normalizedKey) {
119
  return (
106✔
120
    SENSITIVE_KEY_EXACT_MATCHES.has(normalizedKey) ||
417✔
121
    /(?:^|[._\-\s])ip$/i.test(key) ||
122
    /[a-z0-9]I[Pp]$/.test(key) ||
123
    SENSITIVE_IP_KEY_SUFFIXES.some((sensitiveSuffix) => normalizedKey.endsWith(sensitiveSuffix))
2,222✔
124
  );
125
}
126

127
function isSensitiveKey(key) {
128
  const normalizedKey = normalizeSensitiveKey(key);
106✔
129

130
  return (
106✔
131
    isIpMetadataKey(key, normalizedKey) ||
207✔
132
    SENSITIVE_KEY_PARTS.some((sensitivePart) => normalizedKey.includes(sensitivePart))
1,368✔
133
  );
134
}
135

136
/**
137
 * Recursively removes sensitive keys from arbitrary Sentry metadata.
138
 *
139
 * @param {unknown} value - Metadata value to scrub.
140
 * @param {WeakSet<object>} seen - Objects on the current recursion path.
141
 * @returns {unknown} A copy with sensitive object keys removed.
142
 */
143
function scrubUnknown(value, seen = new WeakSet()) {
84✔
144
  if (typeof value === 'string') {
84✔
145
    return scrubInlineSecrets(value);
43✔
146
  }
147

148
  if (!value || typeof value !== 'object') {
41✔
149
    return value;
2✔
150
  }
151

152
  if (value instanceof Date) {
39✔
153
    return value.toISOString();
1✔
154
  }
155

156
  if (value instanceof Error) {
38✔
157
    return {
2✔
158
      name: value.name,
159
      message: scrubInlineSecrets(value.message),
160
    };
161
  }
162

163
  if (seen.has(value)) {
36✔
164
    return CIRCULAR_REFERENCE_SENTINEL;
1✔
165
  }
166

167
  seen.add(value);
35✔
168

169
  if (Array.isArray(value)) {
35✔
170
    const scrubbedArray = value.map((childValue) => scrubUnknown(childValue, seen));
1✔
171
    seen.delete(value);
1✔
172
    return scrubbedArray;
1✔
173
  }
174

175
  const scrubbed = {};
34✔
176

177
  for (const [key, childValue] of Object.entries(value)) {
34✔
178
    if (isSensitiveKey(key)) {
85✔
179
      continue;
26✔
180
    }
181

182
    scrubbed[key] = scrubUnknown(childValue, seen);
59✔
183
  }
184

185
  seen.delete(value);
34✔
186
  return scrubbed;
34✔
187
}
188

189
/**
190
 * Strip query parameters and fragments from a Sentry request URL without dropping the path.
191
 *
192
 * @param {unknown} value - Request URL value from a Sentry event.
193
 * @returns {unknown} The URL without its query or fragment component, or the original value when not a string.
194
 */
195
function stripRequestUrlQuery(value) {
196
  if (typeof value !== 'string') {
24!
NEW
197
    return value;
×
198
  }
199

200
  try {
24✔
201
    const isAbsoluteUrl = /^[a-z][a-z\d+.-]*:/i.test(value);
24✔
202
    const url = new URL(value, 'https://volvox.local');
24✔
203
    url.username = '';
24✔
204
    url.password = '';
24✔
205
    url.search = '';
24✔
206
    url.hash = '';
24✔
207

208
    return isAbsoluteUrl ? url.toString() : url.pathname;
24✔
209
  } catch {
NEW
210
    const queryIndex = value.indexOf('?');
×
NEW
211
    const fragmentIndex = value.indexOf('#');
×
NEW
212
    const stripIndex = [queryIndex, fragmentIndex]
×
NEW
213
      .filter((index) => index !== -1)
×
NEW
214
      .reduce((earliestIndex, index) => Math.min(earliestIndex, index), value.length);
×
215

NEW
216
    return stripIndex === value.length ? value : value.slice(0, stripIndex);
×
217
  }
218
}
219

220
/**
221
 * Strip query strings, fragments, credentials, and inline secrets from URL-like free-form text.
222
 *
223
 * @param {string} value - String that may contain one or more URLs.
224
 * @returns {string} The string with URL secrets removed.
225
 */
226
function scrubUrlLikeString(value) {
227
  const scrubbedValue = scrubInlineSecrets(value).replaceAll(ABSOLUTE_URL_IN_TEXT_PATTERN, (url) =>
42✔
228
    stripRequestUrlQuery(url),
12✔
229
  );
230

231
  return scrubbedValue.replaceAll(
42✔
232
    RELATIVE_URL_IN_TEXT_PATTERN,
233
    (_match, prefix, url) => `${prefix}${stripRequestUrlQuery(url)}`,
7✔
234
  );
235
}
236

237
/**
238
 * Recursively scrub breadcrumb metadata and strip query strings from URL-like values.
239
 *
240
 * @param {unknown} value - Breadcrumb data value to scrub.
241
 * @param {WeakSet<object>} seen - Objects on the current recursion path.
242
 * @returns {unknown} A scrubbed copy of the breadcrumb data value.
243
 */
244
function scrubBreadcrumbData(value, seen = new WeakSet()) {
8✔
245
  if (typeof value === 'string') {
8✔
246
    return scrubUrlLikeString(value);
6✔
247
  }
248

249
  if (!value || typeof value !== 'object') {
2!
NEW
250
    return value;
×
251
  }
252

253
  if (seen.has(value)) {
2!
NEW
254
    return CIRCULAR_REFERENCE_SENTINEL;
×
255
  }
256

257
  seen.add(value);
2✔
258

259
  if (Array.isArray(value)) {
2!
NEW
260
    const scrubbedArray = value.map((childValue) => scrubBreadcrumbData(childValue, seen));
×
NEW
261
    seen.delete(value);
×
NEW
262
    return scrubbedArray;
×
263
  }
264

265
  const scrubbed = {};
2✔
266

267
  for (const [key, childValue] of Object.entries(value)) {
2✔
268
    if (isSensitiveKey(key)) {
8✔
269
      continue;
1✔
270
    }
271

272
    const scrubbedValue = scrubBreadcrumbData(childValue, seen);
7✔
273
    scrubbed[key] = URL_METADATA_KEY_PATTERN.test(key)
7✔
274
      ? stripRequestUrlQuery(scrubbedValue)
275
      : scrubbedValue;
276
  }
277

278
  seen.delete(value);
2✔
279
  return scrubbed;
2✔
280
}
281

282
/**
283
 * Scrubs Sentry breadcrumb payloads so URL query strings and nested secrets cannot bypass scrubbing.
284
 *
285
 * @param {unknown} breadcrumbs - Event breadcrumb list.
286
 * @returns {unknown} Scrubbed breadcrumbs, or the original value if it is not an array.
287
 */
288
function scrubBreadcrumbs(breadcrumbs) {
289
  // Handle Sentry v10 shape: { values?: Breadcrumb[] }
290
  let crumbs = breadcrumbs;
1✔
291
  const isV10Shape =
292
    breadcrumbs &&
1!
293
    typeof breadcrumbs === 'object' &&
294
    !Array.isArray(breadcrumbs) &&
295
    'values' in breadcrumbs;
296
  if (isV10Shape) {
1!
NEW
297
    crumbs = breadcrumbs.values;
×
298
  }
299

300
  if (!Array.isArray(crumbs)) {
1!
NEW
301
    return breadcrumbs;
×
302
  }
303

304
  const scrubbedCrumbs = crumbs.map((breadcrumb) => {
1✔
305
    if (!breadcrumb || typeof breadcrumb !== 'object') {
1!
NEW
306
      return breadcrumb;
×
307
    }
308

309
    const scrubbedBreadcrumb = { ...breadcrumb };
1✔
310
    if (typeof scrubbedBreadcrumb.message === 'string') {
1!
311
      scrubbedBreadcrumb.message = scrubUrlLikeString(scrubbedBreadcrumb.message);
1✔
312
    }
313

314
    if ('data' in scrubbedBreadcrumb) {
1!
315
      scrubbedBreadcrumb.data = scrubBreadcrumbData(scrubbedBreadcrumb.data);
1✔
316
    }
317

318
    return scrubbedBreadcrumb;
1✔
319
  });
320

321
  if (isV10Shape) {
1!
NEW
322
    breadcrumbs.values = scrubbedCrumbs;
×
NEW
323
    return breadcrumbs;
×
324
  }
325

326
  return scrubbedCrumbs;
1✔
327
}
328

329
/**
330
 * Remove Sentry user fields that may contain PII.
331
 * @param {object} event - Sentry event-like payload to mutate.
332
 */
333
function scrubSentryUser(event) {
334
  if (!event.user) {
26✔
335
    return;
23✔
336
  }
337

338
  delete event.user.email;
3✔
339
  delete event.user.ip_address;
3✔
340
}
341

342
/**
343
 * Determine whether scrubbed request data can safely remain on the event.
344
 * @param {unknown} scrubbedData - Scrubbed request data value.
345
 * @param {unknown} rawData - Original request data value.
346
 * @returns {boolean} True when the request data should be retained.
347
 */
348
function shouldKeepRequestData(scrubbedData, rawData) {
349
  return (
4✔
350
    (Boolean(scrubbedData) && typeof scrubbedData === 'object') ||
12✔
351
    (typeof scrubbedData === 'string' && scrubbedData !== rawData)
352
  );
353
}
354

355
/**
356
 * Scrub request metadata that can carry secrets or PII.
357
 * @param {object} request - Sentry request-like payload to mutate.
358
 */
359
function scrubRequestHeaderValue(value) {
360
  if (typeof value === 'string') {
2!
361
    return scrubUrlLikeString(value);
2✔
362
  }
363

NEW
364
  if (Array.isArray(value)) {
×
NEW
365
    return value.map((headerValue) => scrubRequestHeaderValue(headerValue));
×
366
  }
367

NEW
368
  return value;
×
369
}
370

371
function scrubSentryRequest(request) {
372
  delete request.cookies;
8✔
373
  delete request.query_string;
8✔
374

375
  if (request.url) {
8✔
376
    request.url = stripRequestUrlQuery(request.url);
3✔
377
  }
378

379
  if (request.headers) {
8✔
380
    const scrubbedHeaders = scrubUnknown(request.headers);
6✔
381

382
    if (!scrubbedHeaders || typeof scrubbedHeaders !== 'object' || Array.isArray(scrubbedHeaders)) {
6✔
383
      delete request.headers;
2✔
384
    } else {
385
      for (const [key, value] of Object.entries(scrubbedHeaders)) {
4✔
386
        if (URL_HEADER_KEY_PATTERN.test(key) || URL_METADATA_KEY_PATTERN.test(key)) {
6✔
387
          scrubbedHeaders[key] = scrubRequestHeaderValue(value);
2✔
388
        }
389
      }
390

391
      request.headers = scrubbedHeaders;
4✔
392
    }
393
  }
394

395
  if (!request.data) {
8✔
396
    return;
4✔
397
  }
398

399
  const rawData = request.data;
4✔
400
  const scrubbedData = scrubUnknown(rawData);
4✔
401

402
  if (shouldKeepRequestData(scrubbedData, rawData)) {
4✔
403
    request.data = scrubbedData;
3✔
404
    return;
3✔
405
  }
406

407
  delete request.data;
1✔
408
}
409

410
/**
411
 * Scrub optional top-level metadata collections on a Sentry event.
412
 * @param {object} event - Sentry event-like payload to mutate.
413
 */
414
function scrubSentryMetadata(event) {
415
  for (const key of ['extra', 'contexts', 'data']) {
26✔
416
    if (event[key]) {
78✔
417
      event[key] = scrubUnknown(event[key]);
14✔
418
    }
419
  }
420
}
421

422
/**
423
 * Scrub primary Sentry message fields that are not covered by request/user/metadata scrubbers.
424
 * @param {object} event - Sentry event-like payload to mutate.
425
 */
426
function scrubSentryMessageFields(event) {
427
  if (typeof event.message === 'string') {
26✔
428
    event.message = scrubUrlLikeString(event.message);
1✔
429
  }
430

431
  if (typeof event.transaction === 'string') {
26✔
432
    event.transaction = scrubUrlLikeString(event.transaction);
1✔
433
  }
434

435
  if (event.logentry && typeof event.logentry === 'object') {
26✔
436
    if (typeof event.logentry.formatted === 'string') {
1!
437
      event.logentry.formatted = scrubUrlLikeString(event.logentry.formatted);
1✔
438
    }
439

440
    if (typeof event.logentry.message === 'string') {
1!
441
      event.logentry.message = scrubUrlLikeString(event.logentry.message);
1✔
442
    }
443
  }
444

445
  const exceptionValues = event.exception?.values;
26✔
446
  if (!Array.isArray(exceptionValues)) {
26✔
447
    return;
2✔
448
  }
449

450
  for (const exception of exceptionValues) {
24✔
451
    if (exception && typeof exception.value === 'string') {
24!
452
      exception.value = scrubUrlLikeString(exception.value);
24✔
453
    }
454
  }
455
}
456

457
/**
458
 * Removes sensitive fields and identifiers from a Sentry event or performance payload.
459
 *
460
 * Mutates the provided event in place: deletes user email and IP address, removes request cookies,
461
 * replaces request headers and nested data with scrubbed copies (or deletes request.data if it cannot
462
 * be represented as an object), and replaces `extra`, `contexts`, and `data` with scrubbed copies.
463
 *
464
 * @param {object} event - Sentry event-like payload to be sanitized; this object is modified in place.
465
 * @returns {object} The same event object after in-place scrubbing.
466
 */
467
function scrubSentryEvent(event) {
468
  scrubSentryUser(event);
26✔
469

470
  if (event.request) {
26✔
471
    scrubSentryRequest(event.request);
8✔
472
  }
473

474
  scrubSentryMetadata(event);
26✔
475
  scrubSentryMessageFields(event);
26✔
476

477
  if (event.breadcrumbs) {
26✔
478
    event.breadcrumbs = scrubBreadcrumbs(event.breadcrumbs);
1✔
479
  }
480

481
  return event;
26✔
482
}
483

484
/**
485
 * Check whether a span key can contain a URL that needs query/fragment/credential stripping.
486
 * @param {string} key - Span field or nested data key.
487
 * @returns {boolean} True when the field should be treated as URL-bearing.
488
 */
489
function isSpanUrlBearingKey(key) {
490
  return key === 'description' || key === 'name' || URL_METADATA_KEY_PATTERN.test(key);
9✔
491
}
492

493
/**
494
 * Recursively scrub span payloads while preserving non-sensitive span identifiers and attributes.
495
 * @param {unknown} value - Span or nested span value.
496
 * @param {string} key - The object key that contained value.
497
 * @param {WeakSet<object>} seen - Objects on the current recursion path.
498
 * @returns {unknown} A scrubbed span value.
499
 */
500
function scrubSpanValue(value, key = '', seen = new WeakSet()) {
26✔
501
  if (typeof value === 'string') {
13✔
502
    return isSpanUrlBearingKey(key) ? scrubUrlLikeString(value) : scrubInlineSecrets(value);
9✔
503
  }
504

505
  if (!value || typeof value !== 'object') {
4!
NEW
506
    return value;
×
507
  }
508

509
  if (seen.has(value)) {
4!
NEW
510
    return CIRCULAR_REFERENCE_SENTINEL;
×
511
  }
512

513
  seen.add(value);
4✔
514

515
  if (Array.isArray(value)) {
4!
NEW
516
    const scrubbedArray = value.map((childValue) => scrubSpanValue(childValue, key, seen));
×
NEW
517
    seen.delete(value);
×
NEW
518
    return scrubbedArray;
×
519
  }
520

521
  const scrubbed = {};
4✔
522

523
  for (const [childKey, childValue] of Object.entries(value)) {
4✔
524
    if (isSensitiveKey(childKey)) {
13✔
525
      continue;
2✔
526
    }
527

528
    scrubbed[childKey] = scrubSpanValue(childValue, childKey, seen);
11✔
529
  }
530

531
  seen.delete(value);
4✔
532
  return scrubbed;
4✔
533
}
534

535
/**
536
 * Scrub Sentry span payloads that do not use the same shape as error/transaction events.
537
 *
538
 * @param {object} span - Sentry serialized span; this object is modified in place.
539
 * @returns {object} The same span object after in-place scrubbing.
540
 */
541
function scrubSentrySpan(span) {
542
  if (!span || typeof span !== 'object') {
2!
NEW
543
    return span;
×
544
  }
545

546
  const scrubbedSpan = scrubSpanValue(span);
2✔
547

548
  for (const key of Object.keys(span)) {
2✔
549
    delete span[key];
6✔
550
  }
551
  Object.assign(span, scrubbedSpan);
2✔
552

553
  return span;
2✔
554
}
555

556
/**
557
 * Whether Sentry is actively initialized.
558
 * Use this to guard optional Sentry calls in hot paths.
559
 */
560
export const sentryEnabled = Boolean(dsn);
82✔
561

562
if (dsn) {
82✔
563
  sendDefaultPii = process.env.SENTRY_SEND_DEFAULT_PII === 'true';
40✔
564
  Sentry.init({
40✔
565
    dsn,
566
    environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production',
109✔
567
    sendDefaultPii,
568

569
    // Performance monitoring — sample 10% of transactions by default
570
    // Use ?? so SENTRY_TRACES_RATE=0 explicitly disables tracing
571
    tracesSampleRate: (() => {
572
      const parsed = parseFloat(process.env.SENTRY_TRACES_RATE);
40✔
573
      return Number.isFinite(parsed) ? parsed : 0.1;
40✔
574
    })(),
575

576
    // Automatically capture unhandled rejections and uncaught exceptions
577
    autoSessionTracking: true,
578

579
    // Filter out noisy/expected errors and scrub sensitive metadata
580
    beforeSend(event) {
581
      // Skip AbortError from intentional request cancellations
582
      const message = event.exception?.values?.[0]?.value || '';
27✔
583
      if (message.includes('AbortError') || message.includes('The operation was aborted')) {
27✔
584
        return null;
2✔
585
      }
586
      return scrubSentryEvent(event);
25✔
587
    },
588
    beforeSendTransaction: scrubSentryEvent,
589
    beforeSendSpan: scrubSentrySpan,
590

591
    // Add useful default tags
592
    initialScope: {
593
      tags: {
594
        service: 'volvox-bot',
595
      },
596
    },
597
  });
598
}
599

600
export { Sentry };
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