UNPKG

@lumberjack-sdk/browser

Version:

Browser error tracking, custom events, and session replay for Lumberjack

640 lines (632 loc) 18.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ConsoleExporter: () => ConsoleExporter, HttpExporter: () => HttpExporter, getInstance: () => getInstance, init: () => init, start: () => start }); module.exports = __toCommonJS(index_exports); // src/session.ts var SessionManager = class { // 30 minutes constructor(enableReplay, replaySampleRate, maxSessionLength = 60 * 60 * 1e3) { this.enableReplay = enableReplay; this.replaySampleRate = replaySampleRate; this.maxSessionLength = maxSessionLength; this.session = null; this.SESSION_TIMEOUT = 30 * 60 * 1e3; this.recoverSession(); } getOrCreateSession() { const now = Date.now(); if (this.session && this.isSessionValid(this.session, now)) { this.session.lastActivity = now; this.saveSession(); return this.session; } return this.createNewSession(now); } isSessionValid(session, now) { if (now - session.lastActivity >= this.SESSION_TIMEOUT) { return false; } if (now - session.startTime >= this.maxSessionLength) { return false; } return true; } createNewSession(now) { const shouldReplay = this.enableReplay && Math.random() < this.replaySampleRate; this.session = { id: this.generateSessionId(), startTime: now, lastActivity: now, hasReplay: shouldReplay }; this.saveSession(); return this.session; } recoverSession() { try { if (typeof sessionStorage === "undefined") return; const stored = sessionStorage.getItem("lumberjack_session"); if (!stored) return; const session = JSON.parse(stored); const now = Date.now(); if (session && this.isSessionValid(session, now)) { this.session = session; } else { sessionStorage.removeItem("lumberjack_session"); } } catch (error) { console.warn("Failed to recover session:", error); } } saveSession() { try { if (typeof sessionStorage !== "undefined" && this.session) { sessionStorage.setItem( "lumberjack_session", JSON.stringify(this.session) ); } } catch (error) { console.warn("Failed to save session:", error); } } getCurrentSession() { return this.session; } updateActivity() { if (this.session) { this.session.lastActivity = Date.now(); this.saveSession(); } } generateSessionId() { const array = new Uint8Array(16); crypto.getRandomValues(array); array[6] = array[6] & 15 | 64; array[8] = array[8] & 63 | 128; const hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join( "" ); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( 12, 16 )}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } }; // src/buffer.ts var EventBuffer = class { constructor(maxSize, flushInterval, onFlush) { this.maxSize = maxSize; this.flushInterval = flushInterval; this.onFlush = onFlush; this.buffer = []; this.startTimer(); } add(event) { this.buffer.push(event); if (this.buffer.length >= this.maxSize) { this.flush(); } } async flush() { if (this.buffer.length === 0) return; const events = [...this.buffer]; this.buffer = []; try { await this.onFlush(events); } catch (error) { const reAddCount = Math.min(events.length, Math.floor(this.maxSize / 2)); this.buffer.unshift(...events.slice(0, reAddCount)); } } startTimer() { this.stopTimer(); this.flushTimer = window.setInterval( () => this.flush(), this.flushInterval ); } stopTimer() { if (this.flushTimer) { window.clearInterval(this.flushTimer); } } destroy() { this.stopTimer(); this.flush(); } }; // src/error-tracker.ts var ErrorTracker = class { // 30 seconds constructor(callback, sessionId, sampleRate) { this.callback = callback; this.sessionId = sessionId; this.sampleRate = sampleRate; this.errorCount = 0; this.recentErrors = /* @__PURE__ */ new Map(); this.DEDUPE_WINDOW = 3e4; } start() { window.addEventListener("error", (event) => { if (Math.random() > this.sampleRate) return; this.trackError({ message: event.message, stack: event.error?.stack, filename: event.filename, lineno: event.lineno, colno: event.colno, type: "error" }); }); window.addEventListener("unhandledrejection", (event) => { if (Math.random() > this.sampleRate) return; this.trackError({ message: event.reason?.message || String(event.reason), stack: event.reason?.stack, type: "unhandledRejection" }); }); this.setupResourceErrorHandling(); } setupResourceErrorHandling() { window.addEventListener( "error", (event) => { const target = event.target; if (target && target !== window && target.tagName) { if (Math.random() > this.sampleRate) return; this.trackError({ message: `Failed to load ${target.tagName} resource`, filename: target.src || target.href, type: "resourceError" }); } }, true ); } trackError(data) { const fingerprint = this.getErrorFingerprint(data); const now = Date.now(); const lastSeen = this.recentErrors.get(fingerprint); if (lastSeen && now - lastSeen < this.DEDUPE_WINDOW) { return; } this.recentErrors.set(fingerprint, now); this.errorCount++; if (this.recentErrors.size > 100) { this.cleanupOldErrors(now); } this.callback({ type: "error", timestamp: now, sessionId: this.sessionId(), data }); } getErrorFingerprint(data) { const stackLine = data.stack?.split("\n")[0] || ""; return `${data.type}:${data.message}:${stackLine}`; } cleanupOldErrors(now) { for (const [fingerprint, timestamp] of this.recentErrors.entries()) { if (now - timestamp > this.DEDUPE_WINDOW * 2) { this.recentErrors.delete(fingerprint); } } } getErrorCount() { return this.errorCount; } }; // src/session-replay.ts var import_rrweb = require("rrweb"); var SessionReplay = class { constructor(callback, sessionId, _privacyMode, onActivity, config = {}) { this.callback = callback; this.sessionId = sessionId; this.onActivity = onActivity; this.events = []; this.lastFlush = Date.now(); const defaultSampling = { mousemove: false, mouseInteraction: true, scroll: 150, input: "last" }; this.config = { flushInterval: 5e3, maxEventsPerChunk: 100, maskAllInputs: true, blockClass: "lumberjack-block", ignoreClass: "lumberjack-ignore", maskTextSelector: "lumberjack-mask", recordCanvas: false, inlineImages: false, inlineStylesheet: true, ...config, // Merge sampling config properly sampling: { ...defaultSampling, ...config.sampling } }; } start(blockSelectors = []) { const recordConfig = { ...this.config, emit: (event) => { this.events.push(event); this.onActivity(); const timeSinceFlush = Date.now() - this.lastFlush; if (timeSinceFlush > this.config.flushInterval || this.events.length > this.config.maxEventsPerChunk) { this.flush(); } }, blockSelector: blockSelectors.join(",") }; if (this.config.maskTextFn) { recordConfig.maskTextFn = this.config.maskTextFn; } if (this.config.maskInputFn) { recordConfig.maskInputFn = this.config.maskInputFn; } this.stopFn = (0, import_rrweb.record)(recordConfig); } flush() { if (this.events.length === 0) return; const event = { type: "session_replay", timestamp: Date.now(), sessionId: this.sessionId(), data: { events: [...this.events], startTime: this.events[0].timestamp, endTime: this.events[this.events.length - 1].timestamp } }; this.callback(event); this.events = []; this.lastFlush = Date.now(); } stop() { this.flush(); this.stopFn?.(); } }; // src/exporter.ts var HttpExporter = class { constructor(apiKey, projectName, endpoint) { this.apiKey = apiKey; this.projectName = projectName; this.endpoint = endpoint || "https://api.trylumberjack.com/rum/events"; } async export(events, sessionId, userContext) { const payload = { project_name: this.projectName, session_id: sessionId, user_context: userContext, events: events.map((event) => ({ type: event.type, timestamp: event.timestamp, data: event.data })) }; const response = await fetch(this.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Export failed: ${response.status}`); } } }; var ConsoleExporter = class { async export(events, sessionId) { console.group(`\u{1FAB5} Lumberjack Export - Session: ${sessionId}`); events.forEach((event) => { const emoji = event.type === "error" ? "\u274C" : event.type === "custom" ? "\u{1F4CA}" : "\u{1F3A5}"; console.log(`${emoji} ${event.type}:`, { timestamp: new Date(event.timestamp).toISOString(), user: event.userId, data: event.data }); }); console.groupEnd(); } }; // src/index.ts var LumberjackSDK = class { constructor(config) { this.userContext = null; this.isStarted = false; this.validateConfig(config); this.config = { endpoint: "https://api.trylumberjack.com/rum/events", bufferSize: 100, flushInterval: 1e4, maxSessionLength: 60 * 60 * 1e3, // 1 hour enableSessionReplay: true, replayPrivacyMode: "standard", blockSelectors: [], errorSampleRate: 1, replaySampleRate: 0.1, ...config }; this.exporter = this.config.exporter || new HttpExporter( this.config.apiKey, this.config.projectName, this.config.endpoint ); } // Start the session with user context start(userContext) { if (this.isStarted) { console.warn("Lumberjack: Session already started"); return; } if (!userContext.id || typeof userContext.id !== "string") { throw new Error("Lumberjack: user.id is required and must be a string"); } this.userContext = userContext; this.isStarted = true; this.sessionManager = new SessionManager( this.config.enableSessionReplay, this.config.replaySampleRate, this.config.maxSessionLength ); this.buffer = new EventBuffer( this.config.bufferSize, this.config.flushInterval, this.flushEvents.bind(this) ); const session = this.sessionManager.getOrCreateSession(); this.errorTracker = new ErrorTracker( this.trackEvent.bind(this), () => this.sessionManager?.getCurrentSession()?.id || "", this.config.errorSampleRate ); this.errorTracker.start(); if (session.hasReplay) { this.sessionReplay = new SessionReplay( this.trackEvent.bind(this), () => session.id, this.config.replayPrivacyMode, () => this.sessionManager?.updateActivity(), // Update session activity on rrweb events this.config.sessionReplayConfig || {} ); this.sessionReplay.start(this.config.blockSelectors); } this.setupLifecycleHandlers(); this.trackEvent({ type: "custom", timestamp: Date.now(), sessionId: session.id, data: { name: "session_started", properties: { hasReplay: session.hasReplay } } }); if (this.config.debug) { console.log("[Lumberjack] Starting SDK..."); } } validateConfig(config) { if (!config.exporter) { if (!config.apiKey || typeof config.apiKey !== "string") { throw new Error( "Lumberjack: apiKey is required when not using custom exporter" ); } } if (!config.projectName || typeof config.projectName !== "string") { throw new Error( "Lumberjack: projectName is required and must be a string" ); } if (config.errorSampleRate !== void 0 && (config.errorSampleRate < 0 || config.errorSampleRate > 1)) { throw new Error("Lumberjack: errorSampleRate must be between 0 and 1"); } if (config.replaySampleRate !== void 0 && (config.replaySampleRate < 0 || config.replaySampleRate > 1)) { throw new Error("Lumberjack: replaySampleRate must be between 0 and 1"); } if (config.maxSessionLength !== void 0 && (typeof config.maxSessionLength !== "number" || config.maxSessionLength <= 0)) { throw new Error( "Lumberjack: maxSessionLength must be a positive number (milliseconds)" ); } } trackEvent(event) { if (!this.isStarted || !this.buffer) { console.warn("Lumberjack: Cannot track events before calling start()"); return; } if (this.userContext) { event.userId = this.userContext.id; event.userContext = this.userContext; } this.buffer.add(event); } async flushEvents(events) { if (!this.sessionManager) return; const session = this.sessionManager.getCurrentSession(); if (!session || !this.userContext) { if (this.config.debug) { console.warn( "Attempted to flush logs with either no session or user context", session, this.userContext ); } return; } if (this.config.debug) { console.log("Flushing events", events.length); } await this.exporter.export(events, session.id, this.userContext); } setupLifecycleHandlers() { document.addEventListener("visibilitychange", () => { if (document.hidden) { this.buffer?.flush(); } }); window.addEventListener("beforeunload", () => { this.buffer?.flush(); }); window.addEventListener("online", () => { this.buffer?.flush(); }); } // Public API for user context (now handled by start()) setUser(user) { if (!this.isStarted) { console.warn( "Lumberjack: Cannot set user before calling start(). Use start(userContext) instead." ); return; } if (!user.id || typeof user.id !== "string") { throw new Error("Lumberjack: user.id is required and must be a string"); } this.userContext = user; } getUser() { return this.userContext; } clearUser() { this.userContext = null; } // Public API for custom events track(eventName, properties) { if (!this.isStarted) { console.warn("Lumberjack: Cannot track events before calling start()"); return; } const session = this.sessionManager?.getCurrentSession(); if (!session) return; this.trackEvent({ type: "custom", timestamp: Date.now(), sessionId: session.id, data: { name: eventName, properties } }); } // Public API for manual error tracking captureError(error, context) { if (!this.isStarted) { console.warn("Lumberjack: Cannot capture errors before calling start()"); return; } const session = this.sessionManager?.getCurrentSession(); if (!session) return; this.trackEvent({ type: "error", timestamp: Date.now(), sessionId: session.id, data: { message: error.message, stack: error.stack || "", type: "error", ...context } }); } // Public API for session management getSession() { if (!this.isStarted || !this.sessionManager) { return null; } return this.sessionManager.getCurrentSession(); } getSessionId() { const session = this.getSession(); return session ? session.id : null; } isRecording() { const session = this.getSession(); return session ? session.hasReplay : false; } getSessionDuration() { const session = this.getSession(); if (!session) return 0; return Date.now() - session.startTime; } getSessionRemainingTime() { const session = this.getSession(); if (!session) return 0; const elapsed = this.getSessionDuration(); return Math.max(0, this.config.maxSessionLength - elapsed); } async shutdown() { this.sessionReplay?.stop(); this.buffer?.destroy(); this.isStarted = false; } }; var instance = null; function init(config) { if (!instance) { instance = new LumberjackSDK(config); } return instance; } function getInstance() { return instance; } function start(userContext) { const sdk = getInstance(); if (!sdk) { console.warn("Lumberjack: SDK not initialized. Call init() first."); return null; } sdk.start(userContext); return () => sdk.shutdown(); } if (typeof window !== "undefined" && window.__LUMBERJACK_CONFIG__) { init(window.__LUMBERJACK_CONFIG__); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ConsoleExporter, HttpExporter, getInstance, init, start });