From 63dc3e61bc74233d55a1e62e4cdc7918190c50a7 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:50:22 +0300 Subject: [PATCH] feat: add breadcrumb management to Catcher class --- package.json | 7 +- src/addons/breadcrumbs.ts | 566 +++++++++++++++++++++++++++++ src/catcher.ts | 89 ++++- src/types/event.ts | 9 +- src/types/hawk-initial-settings.ts | 36 +- yarn.lock | 8 +- 6 files changed, 703 insertions(+), 12 deletions(-) create mode 100644 src/addons/breadcrumbs.ts diff --git a/package.json b/package.json index 64ac827..fe225a5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hawk.so/javascript", "type": "commonjs", - "version": "3.2.11", + "version": "3.2.12", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" @@ -47,8 +47,9 @@ "vue": "^2" }, "dependencies": { - "@hawk.so/types": "^0.1.36", + "@hawk.so/types": "^0.1.38", "error-stack-parser": "^2.1.4", "vite-plugin-dts": "^4.2.4" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/addons/breadcrumbs.ts b/src/addons/breadcrumbs.ts new file mode 100644 index 0000000..a17df85 --- /dev/null +++ b/src/addons/breadcrumbs.ts @@ -0,0 +1,566 @@ +/** + * @file Breadcrumbs module - captures chronological trail of events before an error + * Similar to Sentry breadcrumbs: https://docs.sentry.io/product/issues/issue-details/breadcrumbs/ + */ +import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json } from '@hawk.so/types'; +import Sanitizer from '../modules/sanitizer'; + +/** + * Default maximum number of breadcrumbs to store + */ +const DEFAULT_MAX_BREADCRUMBS = 15; + +/** + * Maximum length for string values in breadcrumb data + */ +const DEFAULT_MAX_VALUE_LENGTH = 1024; + +/** + * Hint object passed to beforeBreadcrumb callback + */ +export interface BreadcrumbHint { + /** + * Original event that triggered the breadcrumb (if any) + */ + event?: Event | Response | XMLHttpRequest; + + /** + * Request info for fetch/xhr breadcrumbs + */ + input?: RequestInfo | URL; + + /** + * Response data for fetch/xhr breadcrumbs + */ + response?: Response; + + /** + * XHR instance for xhr breadcrumbs + */ + xhr?: XMLHttpRequest; +} + +/** + * Configuration options for breadcrumbs + */ +export interface BreadcrumbsOptions { + /** + * Maximum number of breadcrumbs to store (FIFO) + * @default 15 + */ + maxBreadcrumbs?: number; + + /** + * Maximum length for string values (will be trimmed) + * @default 1024 + */ + maxValueLength?: number; + + /** + * Hook called before each breadcrumb is stored + * Return null to discard the breadcrumb + * Return modified breadcrumb to store it + */ + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; + + /** + * Enable automatic fetch/XHR breadcrumbs + * @default true + */ + trackFetch?: boolean; + + /** + * Enable automatic navigation breadcrumbs (history API) + * @default true + */ + trackNavigation?: boolean; + + /** + * Enable automatic UI click breadcrumbs + * @default false + */ + trackClicks?: boolean; +} + +/** + * BreadcrumbManager - singleton that manages breadcrumb collection and storage + */ +export class BreadcrumbManager { + /** + * Singleton instance + */ + private static instance: BreadcrumbManager | null = null; + + /** + * Breadcrumbs buffer (FIFO) + */ + private readonly breadcrumbs: Breadcrumb[] = []; + + /** + * Configuration options + */ + private options: Required> & Pick; + + /** + * Initialization flag + */ + private isInitialized = false; + + /** + * Original fetch function (for restoration) + */ + private originalFetch: typeof fetch | null = null; + + /** + * Original XMLHttpRequest.open (for restoration) + */ + private originalXHROpen: typeof XMLHttpRequest.prototype.open | null = null; + + /** + * Original XMLHttpRequest.send (for restoration) + */ + private originalXHRSend: typeof XMLHttpRequest.prototype.send | null = null; + + /** + * Original history.pushState (for restoration) + */ + private originalPushState: typeof history.pushState | null = null; + + /** + * Original history.replaceState (for restoration) + */ + private originalReplaceState: typeof history.replaceState | null = null; + + /** + * Click event handler reference (for removal) + */ + private clickHandler: ((event: MouseEvent) => void) | null = null; + + /** + * Private constructor to enforce singleton pattern + */ + private constructor() { + this.options = { + maxBreadcrumbs: DEFAULT_MAX_BREADCRUMBS, + maxValueLength: DEFAULT_MAX_VALUE_LENGTH, + trackFetch: true, + trackNavigation: true, + trackClicks: false, + }; + } + + /** + * Get singleton instance + */ + public static getInstance(): BreadcrumbManager { + BreadcrumbManager.instance ??= new BreadcrumbManager(); + + return BreadcrumbManager.instance; + } + + /** + * Initialize breadcrumbs with options and start auto-capture + */ + public init(options: BreadcrumbsOptions = {}): void { + if (this.isInitialized) { + return; + } + + this.options = { + maxBreadcrumbs: options.maxBreadcrumbs ?? DEFAULT_MAX_BREADCRUMBS, + maxValueLength: options.maxValueLength ?? DEFAULT_MAX_VALUE_LENGTH, + beforeBreadcrumb: options.beforeBreadcrumb, + trackFetch: options.trackFetch ?? true, + trackNavigation: options.trackNavigation ?? true, + trackClicks: options.trackClicks ?? false, + }; + + this.isInitialized = true; + + // Setup auto-capture handlers + if (this.options.trackFetch) { + this.wrapFetch(); + this.wrapXHR(); + } + + if (this.options.trackNavigation) { + this.wrapHistory(); + } + + if (this.options.trackClicks) { + this.setupClickTracking(); + } + } + + /** + * Add a breadcrumb to the buffer + */ + public addBreadcrumb(breadcrumb: Omit & { timestamp?: Breadcrumb['timestamp'] }, hint?: BreadcrumbHint): void { + // Ensure timestamp + const bc: Breadcrumb = { + ...breadcrumb, + timestamp: breadcrumb.timestamp ?? Date.now(), + }; + + // Apply beforeBreadcrumb hook + if (this.options.beforeBreadcrumb) { + const result = this.options.beforeBreadcrumb(bc, hint); + + if (result === null) { + return; // Discard breadcrumb + } + + Object.assign(bc, result); + } + + // Sanitize and trim data + if (bc.data) { + bc.data = this.sanitizeData(bc.data); + } + + if (bc.message) { + bc.message = this.trimString(bc.message, this.options.maxValueLength); + } + + // Add to buffer (FIFO) + if (this.breadcrumbs.length >= this.options.maxBreadcrumbs) { + this.breadcrumbs.shift(); + } + + this.breadcrumbs.push(bc); + } + + /** + * Get current breadcrumbs snapshot (oldest to newest) + */ + public getBreadcrumbs(): Breadcrumb[] { + return [...this.breadcrumbs]; + } + + /** + * Clear all breadcrumbs + */ + public clearBreadcrumbs(): void { + this.breadcrumbs.length = 0; + } + + /** + * Sanitize and trim breadcrumb data object + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private sanitizeData(data: Record): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sanitized = Sanitizer.sanitize(data) as Record; + + // Trim string values + for (const key in sanitized) { + if (typeof sanitized[key] === 'string') { + sanitized[key] = this.trimString(sanitized[key], this.options.maxValueLength); + } + } + + return sanitized as Record; + } + + /** + * Trim string to max length + */ + private trimString(str: string, maxLength: number): string { + if (str.length > maxLength) { + return str.substring(0, maxLength) + '…'; + } + + return str; + } + + /** + * Wrap fetch API to capture HTTP breadcrumbs + */ + private wrapFetch(): void { + if (typeof fetch === 'undefined') { + return; + } + + this.originalFetch = fetch; + + const manager = this; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise { + const startTime = Date.now(); + const method = init?.method || 'GET'; + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + let response: Response; + + try { + response = await manager.originalFetch!.call(window, input, init); + + const duration = Date.now() - startTime; + + manager.addBreadcrumb({ + type: 'request', + category: 'fetch', + message: `${method} ${url} ${response.status}`, + level: response.ok ? 'info' : 'error', + data: { + url, + method, + status_code: response.status, + duration_ms: duration, + }, + }, { + input, + response, + }); + + return response; + } catch (error) { + const duration = Date.now() - startTime; + + manager.addBreadcrumb({ + type: 'request', + category: 'fetch', + message: `${method} ${url} failed`, + level: 'error', + data: { + url, + method, + duration_ms: duration, + error: error instanceof Error ? error.message : String(error), + }, + }, { + input, + }); + + throw error; + } + }; + } + + /** + * Wrap XMLHttpRequest to capture XHR breadcrumbs + */ + private wrapXHR(): void { + if (typeof XMLHttpRequest === 'undefined') { + return; + } + + const manager = this; + + this.originalXHROpen = XMLHttpRequest.prototype.open; + this.originalXHRSend = XMLHttpRequest.prototype.send; + + // Store request info on the XHR instance + interface XHRWithBreadcrumb extends XMLHttpRequest { + __hawk_method?: string; + __hawk_url?: string; + __hawk_start?: number; + } + + XMLHttpRequest.prototype.open = function (this: XHRWithBreadcrumb, method: string, url: string | URL, ...args: unknown[]) { + this.__hawk_method = method; + this.__hawk_url = typeof url === 'string' ? url : url.href; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return manager.originalXHROpen!.apply(this, [method, url, ...args] as any); + }; + + XMLHttpRequest.prototype.send = function (this: XHRWithBreadcrumb, body?: Document | XMLHttpRequestBodyInit | null) { + this.__hawk_start = Date.now(); + + const onReadyStateChange = (): void => { + if (this.readyState === XMLHttpRequest.DONE) { + const duration = Date.now() - (this.__hawk_start || Date.now()); + const method = this.__hawk_method || 'GET'; + const url = this.__hawk_url || ''; + const status = this.status; + + manager.addBreadcrumb({ + type: 'request', + category: 'xhr', + message: `${method} ${url} ${status}`, + level: status >= 200 && status < 400 ? 'info' : 'error', + data: { + url, + method, + status_code: status, + duration_ms: duration, + }, + }, { + xhr: this, + }); + } + }; + + // Add listener without overwriting existing one + this.addEventListener('readystatechange', onReadyStateChange); + + return manager.originalXHRSend!.call(this, body); + }; + } + + /** + * Wrap History API to capture navigation breadcrumbs + */ + private wrapHistory(): void { + if (typeof history === 'undefined') { + return; + } + + const manager = this; + let lastUrl = window.location.href; + + const createNavigationBreadcrumb = (to: string): void => { + const from = lastUrl; + + lastUrl = to; + + manager.addBreadcrumb({ + type: 'navigation', + category: 'navigation', + message: `Navigated to ${to}`, + level: 'info', + data: { + from, + to, + }, + }); + }; + + // Wrap pushState + this.originalPushState = history.pushState; + history.pushState = function (...args) { + const result = manager.originalPushState!.apply(this, args); + + createNavigationBreadcrumb(window.location.href); + + return result; + }; + + // Wrap replaceState + this.originalReplaceState = history.replaceState; + history.replaceState = function (...args) { + const result = manager.originalReplaceState!.apply(this, args); + + createNavigationBreadcrumb(window.location.href); + + return result; + }; + + // Listen for popstate (back/forward) + window.addEventListener('popstate', () => { + createNavigationBreadcrumb(window.location.href); + }); + } + + /** + * Setup click event tracking for UI breadcrumbs + */ + private setupClickTracking(): void { + const manager = this; + + this.clickHandler = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + + if (!target) { + return; + } + + // Build a simple selector + let selector = target.tagName.toLowerCase(); + + if (target.id) { + selector += `#${target.id}`; + } else if (target.className && typeof target.className === 'string') { + selector += `.${target.className.split(' ').filter(Boolean).join('.')}`; + } + + // Get text content (limited) + const text = (target.textContent || target.innerText || '').trim().substring(0, 50); + + manager.addBreadcrumb({ + type: 'ui', + category: 'ui.click', + message: `Click on ${selector}`, + level: 'info', + data: { + selector, + text: text || undefined, + tagName: target.tagName, + }, + }, { + event, + }); + }; + + document.addEventListener('click', this.clickHandler, { capture: true }); + } + + /** + * Destroy the manager and restore original functions + */ + public destroy(): void { + // Restore fetch + if (this.originalFetch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).fetch = this.originalFetch; + this.originalFetch = null; + } + + // Restore XHR + if (this.originalXHROpen) { + XMLHttpRequest.prototype.open = this.originalXHROpen; + this.originalXHROpen = null; + } + + if (this.originalXHRSend) { + XMLHttpRequest.prototype.send = this.originalXHRSend; + this.originalXHRSend = null; + } + + // Restore history + if (this.originalPushState) { + history.pushState = this.originalPushState; + this.originalPushState = null; + } + + if (this.originalReplaceState) { + history.replaceState = this.originalReplaceState; + this.originalReplaceState = null; + } + + // Remove click handler + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler, { capture: true }); + this.clickHandler = null; + } + + this.clearBreadcrumbs(); + this.isInitialized = false; + BreadcrumbManager.instance = null; + } +} + +/** + * Helper function to create a breadcrumb object + */ +export function createBreadcrumb( + message: string, + options?: { + type?: BreadcrumbType; + category?: string; + level?: BreadcrumbLevel; + data?: Record; + } +): Breadcrumb { + return { + timestamp: Date.now(), + message, + type: options?.type ?? 'default', + category: options?.category, + level: options?.level ?? 'info', + data: options?.data, + }; +} + diff --git a/src/catcher.ts b/src/catcher.ts index 3511a65..45bc48f 100644 --- a/src/catcher.ts +++ b/src/catcher.ts @@ -10,13 +10,15 @@ import type { EventContext, JavaScriptAddons, VueIntegrationAddons, - Json, EncodedIntegrationToken, DecodedIntegrationToken + Json, EncodedIntegrationToken, DecodedIntegrationToken, + Breadcrumb, } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; +import { BreadcrumbManager, type BreadcrumbHint } from './addons/breadcrumbs'; import { validateUser, validateContext } from './utils/validation'; /** @@ -103,6 +105,11 @@ export default class Catcher { */ private readonly consoleCatcher: ConsoleCatcher | null = null; + /** + * Breadcrumb manager instance + */ + private readonly breadcrumbManager: BreadcrumbManager | null = null; + /** * Catcher constructor * @@ -159,6 +166,18 @@ export default class Catcher { this.consoleCatcher.init(); } + /** + * Initialize breadcrumbs + */ + this.breadcrumbManager = BreadcrumbManager.getInstance(); + this.breadcrumbManager.init({ + maxBreadcrumbs: settings.maxBreadcrumbs, + trackFetch: settings.trackFetch, + trackNavigation: settings.trackNavigation, + trackClicks: settings.trackClicks, + beforeBreadcrumb: settings.beforeBreadcrumb, + }); + /** * Set global handlers */ @@ -264,6 +283,43 @@ export default class Catcher { this.user = Catcher.getGeneratedUser(); } + /** + * Add a breadcrumb manually + * Breadcrumbs are chronological trail of events leading up to an error + * + * @param breadcrumb - Breadcrumb data (timestamp is auto-generated if not provided) + * @param hint - Optional hint object with additional context + * + * @example + * hawk.addBreadcrumb({ + * type: 'user', + * category: 'auth', + * message: 'User logged in', + * level: 'info', + * data: { userId: '123' } + * }); + */ + public addBreadcrumb( + breadcrumb: Omit & { timestamp?: Breadcrumb['timestamp'] }, + hint?: BreadcrumbHint + ): void { + this.breadcrumbManager?.addBreadcrumb(breadcrumb, hint); + } + + /** + * Get current breadcrumbs (oldest to newest) + */ + public getBreadcrumbs(): Breadcrumb[] { + return this.breadcrumbManager?.getBreadcrumbs() ?? []; + } + + /** + * Clear all breadcrumbs + */ + public clearBreadcrumbs(): void { + this.breadcrumbManager?.clearBreadcrumbs(); + } + /** * Update the context data that will be sent with all events * @@ -315,6 +371,27 @@ export default class Catcher { error = (event as ErrorEvent).message; } + /** + * Add error as breadcrumb before sending + */ + if (this.breadcrumbManager) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorType = error instanceof Error ? error.name : 'Error'; + + this.breadcrumbManager.addBreadcrumb({ + type: 'error', + category: 'error', + message: errorMessage, + level: 'error', + data: { + type: errorType, + ...(error instanceof Error && error.stack ? { stack: error.stack } : {}), + }, + }, { + event: event instanceof ErrorEvent ? event : undefined, + }); + } + void this.formatAndSend(error); } @@ -388,6 +465,7 @@ export default class Catcher { title: this.getTitle(error), type: this.getType(error), release: this.getRelease(), + breadcrumbs: this.getBreadcrumbsForEvent(), context: this.getContext(context), user: this.getUser(), addons: this.getAddons(error), @@ -504,6 +582,15 @@ export default class Catcher { return this.user || null; } + /** + * Get breadcrumbs for event payload + */ + private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { + const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs(); + + return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : null; + } + /** * Get parameters */ diff --git a/src/types/event.ts b/src/types/event.ts index eebaf63..82dec49 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -1,4 +1,4 @@ -import type { AffectedUser, BacktraceFrame, EventContext, EventData, JavaScriptAddons } from '@hawk.so/types'; +import type { AffectedUser, BacktraceFrame, EventContext, EventData, JavaScriptAddons, Breadcrumb } from '@hawk.so/types'; /** * Event data with JS specific addons @@ -10,7 +10,7 @@ type JSEventData = EventData; * * The listed EventData properties will always be sent, so we define them as required in the type */ -export type HawkJavaScriptEvent = Omit & { +export type HawkJavaScriptEvent = Omit & { /** * Event type: TypeError, ReferenceError etc * null for non-error events @@ -22,6 +22,11 @@ export type HawkJavaScriptEvent = Omit Breadcrumb | null; } diff --git a/yarn.lock b/yarn.lock index 3aa494e..2f2e317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -316,10 +316,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@hawk.so/types@^0.1.36": - version "0.1.36" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.36.tgz#234b0e4c81bf5f50b1208910d45fc4ffb62e8ae1" - integrity sha512-AjW4FZPMqlDoXk63ntkTGOC1tdbHuGXIhEbVtBvz8YC9A7qcuxenzfGtjwuW6B9tqyADMGehh+/d+uQbAX7w0Q== +"@hawk.so/types@^0.1.38": + version "0.1.38" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.38.tgz#b287f6d22025f53b6de7858c7c2eba1d8c52a00a" + integrity sha512-IaAiM+T8sc+twZiZcAd90AwE7rEZfmfN1gvo8d+Ax53dhQCMBU+c/+6L+Z7XdCGe696mPGWqJGY26S8mRUg3BA== dependencies: "@types/mongodb" "^3.5.34"