@lumberjack-sdk/browser
Version:
Browser error tracking, custom events, and session replay for Lumberjack
640 lines (632 loc) • 18.2 kB
JavaScript
"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
});