@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
JavaScript
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);
}