UNPKG

@bugspotter/sdk

Version:

Professional bug reporting SDK with screenshots, session replay, and automatic error capture for web applications

206 lines (205 loc) 9.4 kB
import { ScreenshotCapture } from './capture/screenshot'; import { ConsoleCapture } from './capture/console'; import { NetworkCapture } from './capture/network'; import { MetadataCapture } from './capture/metadata'; import { compressData, estimateSize, getCompressionRatio } from './core/compress'; import { FloatingButton } from './widget/button'; import { BugReportModal } from './widget/modal'; import { DOMCollector } from './collectors'; import { createSanitizer } from './utils/sanitize'; import { getLogger } from './utils/logger'; import { submitWithAuth } from './core/transport'; const logger = getLogger(); export class BugSpotter { constructor(config) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; this.config = config; // Initialize sanitizer if enabled if (((_a = config.sanitize) === null || _a === void 0 ? void 0 : _a.enabled) !== false) { this.sanitizer = createSanitizer({ enabled: (_c = (_b = config.sanitize) === null || _b === void 0 ? void 0 : _b.enabled) !== null && _c !== void 0 ? _c : true, patterns: (_d = config.sanitize) === null || _d === void 0 ? void 0 : _d.patterns, customPatterns: (_e = config.sanitize) === null || _e === void 0 ? void 0 : _e.customPatterns, excludeSelectors: (_f = config.sanitize) === null || _f === void 0 ? void 0 : _f.excludeSelectors, }); } this.screenshot = new ScreenshotCapture(); this.console = new ConsoleCapture({ sanitizer: this.sanitizer }); this.network = new NetworkCapture({ sanitizer: this.sanitizer }); this.metadata = new MetadataCapture({ sanitizer: this.sanitizer }); // Note: DirectUploader is created per-report since it needs bugId // See submitBugReport() for initialization // Initialize DOM collector if replay is enabled if (((_g = config.replay) === null || _g === void 0 ? void 0 : _g.enabled) !== false) { this.domCollector = new DOMCollector({ duration: (_j = (_h = config.replay) === null || _h === void 0 ? void 0 : _h.duration) !== null && _j !== void 0 ? _j : 15, sampling: (_k = config.replay) === null || _k === void 0 ? void 0 : _k.sampling, sanitizer: this.sanitizer, }); this.domCollector.startRecording(); } // Initialize widget if enabled if (config.showWidget !== false) { this.widget = new FloatingButton(config.widgetOptions); this.widget.onClick(async () => { await this.handleBugReport(); }); } } static init(config) { if (!BugSpotter.instance) { BugSpotter.instance = new BugSpotter(config); } return BugSpotter.instance; } static getInstance() { return BugSpotter.instance || null; } /** * Capture bug report data * Note: Screenshot is captured for modal preview only (_screenshotPreview) * Actual file uploads use presigned URLs (screenshotKey/replayKey set after upload) */ async capture() { var _a, _b; const screenshotPreview = await this.screenshot.capture(); const replayEvents = (_b = (_a = this.domCollector) === null || _a === void 0 ? void 0 : _a.getEvents()) !== null && _b !== void 0 ? _b : []; return { console: this.console.getLogs(), network: this.network.getRequests(), metadata: this.metadata.capture(), replay: replayEvents, // Internal: screenshot preview for modal (not sent to API) _screenshotPreview: screenshotPreview, }; } async handleBugReport() { const report = await this.capture(); const modal = new BugReportModal({ onSubmit: async (data) => { logger.log('Submitting bug:', Object.assign(Object.assign({}, data), { report })); // Send to endpoint if configured if (this.config.endpoint) { try { await this.submitBugReport(Object.assign(Object.assign({}, data), { report })); logger.log('Bug report submitted successfully'); } catch (error) { logger.error('Failed to submit bug report:', error); // Re-throw to allow UI to handle errors if needed throw error; } } }, }); modal.show(report._screenshotPreview || ''); } async submitBugReport(payload) { if (!this.config.endpoint) { throw new Error('No endpoint configured for bug report submission'); } const contentHeaders = { 'Content-Type': 'application/json', }; logger.warn(`Submitting bug report to ${this.config.endpoint}`); let body; try { // Try to compress the payload const originalSize = estimateSize(payload); const compressed = await compressData(payload); const compressedSize = compressed.byteLength; const ratio = getCompressionRatio(originalSize, compressedSize); logger.log(`Payload compression: ${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB (${ratio}% reduction)`); // Use compression if it actually reduces size if (compressedSize < originalSize) { // Create a Blob from the compressed Uint8Array for proper binary upload // Use Uint8Array constructor to ensure clean ArrayBuffer (no extra padding bytes) body = new Blob([new Uint8Array(compressed)], { type: 'application/gzip' }); contentHeaders['Content-Encoding'] = 'gzip'; contentHeaders['Content-Type'] = 'application/gzip'; } else { body = JSON.stringify(payload); } } catch (error) { // Fallback to uncompressed if compression fails logger.warn('Compression failed, sending uncompressed payload:', error); body = JSON.stringify(payload); } // Determine auth configuration const auth = this.config.auth; // Submit with authentication, retry logic, and offline queue const response = await submitWithAuth(this.config.endpoint, body, contentHeaders, { auth, retry: this.config.retry, offline: this.config.offline, }); logger.warn(`${JSON.stringify(response)}`); if (!response.ok) { const errorText = await response.text().catch(() => { return 'Unknown error'; }); throw new Error(`Failed to submit bug report: ${response.status} ${response.statusText}. ${errorText}`); } return response.json().catch(() => { return undefined; }); } getConfig() { return Object.assign({}, this.config); } destroy() { var _a, _b; this.console.destroy(); this.network.destroy(); (_a = this.domCollector) === null || _a === void 0 ? void 0 : _a.destroy(); (_b = this.widget) === null || _b === void 0 ? void 0 : _b.destroy(); BugSpotter.instance = undefined; } } export { ScreenshotCapture } from './capture/screenshot'; export { ConsoleCapture } from './capture/console'; export { NetworkCapture } from './capture/network'; export { MetadataCapture } from './capture/metadata'; // Export collector modules export { DOMCollector } from './collectors'; // Export core utilities export { CircularBuffer } from './core/buffer'; // Export compression utilities export { compressData, decompressData, compressImage, estimateSize, getCompressionRatio, } from './core/compress'; // Export transport and authentication export { submitWithAuth, getAuthHeaders, clearOfflineQueue } from './core/transport'; export { getLogger, configureLogger, createLogger } from './utils/logger'; // Export upload utilities export { DirectUploader } from './core/uploader'; export { compressReplayEvents, canvasToBlob, estimateCompressedReplaySize, isWithinSizeLimit, } from './core/upload-helpers'; // Export sanitization utilities export { createSanitizer, Sanitizer } from './utils/sanitize'; // Export pattern configuration utilities export { DEFAULT_PATTERNS, PATTERN_PRESETS, PATTERN_CATEGORIES, PatternBuilder, createPatternConfig, getPattern, getPatternsByCategory, validatePattern, } from './utils/sanitize'; // Export widget components export { FloatingButton } from './widget/button'; export { BugReportModal } from './widget/modal'; /** * Convenience function to sanitize text with default PII patterns * Useful for quick sanitization without creating a Sanitizer instance * * @param text - Text to sanitize * @returns Sanitized text with PII redacted * * @example * ```typescript * const sanitized = sanitize('Email: user@example.com'); * // Returns: 'Email: [REDACTED]' * ``` */ export function sanitize(text) { const sanitizer = createSanitizer({ enabled: true, patterns: 'all', customPatterns: [], excludeSelectors: [], }); return sanitizer.sanitize(text); }