sentry.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * Lightweight Sentry client for CodeGraph — uses the HTTP envelope API directly.
  3. * No @sentry/node dependency, works in any Node.js environment.
  4. */
  5. import { createHash } from 'crypto';
  6. const DSN = 'https://9591f8aca69bcf98e9feb31544200b47@o1181972.ingest.us.sentry.io/4510840133713920';
  7. const DSN_PARTS = DSN.match(/^https:\/\/([^@]+)@([^/]+)\/(.+)$/);
  8. const PUBLIC_KEY = DSN_PARTS![1];
  9. const HOST = DSN_PARTS![2];
  10. const PROJECT_ID = DSN_PARTS![3];
  11. const STORE_URL = `https://${HOST}/api/${PROJECT_ID}/envelope/`;
  12. let _enabled = false;
  13. let _release = 'codegraph@unknown';
  14. let _tags: Record<string, string> = {};
  15. /**
  16. * Initialize Sentry error reporting.
  17. * Safe to call multiple times — subsequent calls update tags/release.
  18. */
  19. export function initSentry({ processName, version }: { processName: string; version?: string }) {
  20. // Skip in development/test environments
  21. if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.VITEST) {
  22. return;
  23. }
  24. _enabled = true;
  25. _release = `codegraph@${version ?? process.env.npm_package_version ?? 'unknown'}`;
  26. _tags = { processName };
  27. }
  28. /**
  29. * Send an error to Sentry with full stack trace and context.
  30. * Fire-and-forget — never throws, never blocks.
  31. */
  32. export function captureException(error: unknown, extra?: Record<string, unknown>) {
  33. if (!_enabled) return;
  34. try {
  35. const err = error instanceof Error ? error : new Error(String(error));
  36. const msg = err.message.toLowerCase();
  37. // Filter non-actionable noise
  38. if (msg.includes('pty') || msg.includes('terminal session')) return;
  39. if ((msg.includes('econnrefused') || msg.includes('econnreset')) && msg.includes('127.0.0.1')) return;
  40. const eventId = createHash('md5').update(`${Date.now()}-${Math.random()}`).digest('hex');
  41. const timestamp = new Date().toISOString();
  42. // Attach CodeGraphError context if available
  43. const errorContext: Record<string, unknown> = { ...extra };
  44. if ('code' in err && typeof (err as any).code === 'string') {
  45. errorContext.errorCode = (err as any).code;
  46. }
  47. if ('context' in err && typeof (err as any).context === 'object') {
  48. Object.assign(errorContext, (err as any).context);
  49. }
  50. const event: Record<string, unknown> = {
  51. event_id: eventId,
  52. timestamp,
  53. platform: 'node',
  54. level: 'error',
  55. release: _release,
  56. tags: _tags,
  57. exception: {
  58. values: [{
  59. type: err.name,
  60. value: err.message,
  61. stacktrace: parseStack(err.stack),
  62. }],
  63. },
  64. };
  65. if (Object.keys(errorContext).length > 0) {
  66. event.extra = errorContext;
  67. }
  68. const payload = JSON.stringify(event);
  69. const envelope = [
  70. JSON.stringify({ event_id: eventId, sent_at: timestamp, dsn: DSN }),
  71. JSON.stringify({ type: 'event', length: payload.length }),
  72. payload,
  73. ].join('\n') + '\n';
  74. fetch(STORE_URL, {
  75. method: 'POST',
  76. headers: {
  77. 'Content-Type': 'application/x-sentry-envelope',
  78. 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${PUBLIC_KEY}`,
  79. },
  80. body: envelope,
  81. }).catch(() => {});
  82. } catch {
  83. // Never throw from error reporting
  84. }
  85. }
  86. /**
  87. * Send a message-level event to Sentry (for logged errors without Error objects).
  88. */
  89. export function captureMessage(message: string, context?: Record<string, unknown>) {
  90. if (!_enabled) return;
  91. try {
  92. const eventId = createHash('md5').update(`${Date.now()}-${Math.random()}`).digest('hex');
  93. const timestamp = new Date().toISOString();
  94. const event: Record<string, unknown> = {
  95. event_id: eventId,
  96. timestamp,
  97. platform: 'node',
  98. level: 'error',
  99. release: _release,
  100. tags: _tags,
  101. message: { formatted: message },
  102. };
  103. if (context && Object.keys(context).length > 0) {
  104. event.extra = context;
  105. }
  106. const payload = JSON.stringify(event);
  107. const envelope = [
  108. JSON.stringify({ event_id: eventId, sent_at: timestamp, dsn: DSN }),
  109. JSON.stringify({ type: 'event', length: payload.length }),
  110. payload,
  111. ].join('\n') + '\n';
  112. fetch(STORE_URL, {
  113. method: 'POST',
  114. headers: {
  115. 'Content-Type': 'application/x-sentry-envelope',
  116. 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${PUBLIC_KEY}`,
  117. },
  118. body: envelope,
  119. }).catch(() => {});
  120. } catch {
  121. // Never throw from error reporting
  122. }
  123. }
  124. /**
  125. * Parse a Node.js Error.stack string into Sentry's stacktrace format.
  126. */
  127. function parseStack(stack?: string): { frames: Array<{ filename: string; function: string; lineno?: number; colno?: number }> } | undefined {
  128. if (!stack) return undefined;
  129. const frames = stack
  130. .split('\n')
  131. .slice(1)
  132. .map((line) => {
  133. const match = line.match(/^\s+at\s+(?:(.+?)\s+\()?(.*?):(\d+):(\d+)\)?$/);
  134. if (!match || !match[2] || !match[3] || !match[4]) return null;
  135. return {
  136. function: match[1] || '<anonymous>',
  137. filename: match[2],
  138. lineno: parseInt(match[3], 10),
  139. colno: parseInt(match[4], 10),
  140. };
  141. })
  142. .filter((f): f is NonNullable<typeof f> => f !== null)
  143. .reverse();
  144. return frames.length > 0 ? { frames } : undefined;
  145. }