From f9ab73d1203368de16bd2ce386b64e0c8fa42639 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:45:27 +0300 Subject: [PATCH 1/4] chore: init --- packages/javascript/example/index.html | 34 + packages/javascript/example/sample-errors.js | 62 ++ packages/javascript/package.json | 2 +- packages/javascript/src/addons/breadcrumbs.ts | 618 ++++++++++++++++++ packages/javascript/src/catcher.ts | 89 ++- packages/javascript/src/types/event.ts | 9 +- .../src/types/hawk-initial-settings.ts | 38 +- yarn.lock | 8 +- 8 files changed, 851 insertions(+), 9 deletions(-) create mode 100644 packages/javascript/src/addons/breadcrumbs.ts diff --git a/packages/javascript/example/index.html b/packages/javascript/example/index.html index 7b6f125..432ea89 100644 --- a/packages/javascript/example/index.html +++ b/packages/javascript/example/index.html @@ -174,6 +174,40 @@

Context Management



+
+

Breadcrumbs Management

+ + +

+ + +

+ + +

+ + +

+ + + +

+ + +

Test Vue integration: $root

diff --git a/packages/javascript/example/sample-errors.js b/packages/javascript/example/sample-errors.js index 6e8ab33..8b67bd8 100644 --- a/packages/javascript/example/sample-errors.js +++ b/packages/javascript/example/sample-errors.js @@ -123,3 +123,65 @@ buttonSetContext.addEventListener('click', () => { window.hawk.setContext(context); }); + +/** + * Breadcrumbs Management + */ +const buttonAddBreadcrumb = document.getElementById('btn-add-breadcrumb'); +const buttonGetBreadcrumbs = document.getElementById('btn-get-breadcrumbs'); +const buttonClearBreadcrumbs = document.getElementById('btn-clear-breadcrumbs'); +const buttonTestFetch = document.getElementById('btn-test-fetch-breadcrumb'); +const breadcrumbsOutput = document.getElementById('breadcrumbs-output'); + +buttonAddBreadcrumb.addEventListener('click', () => { + const message = document.getElementById('breadcrumbMessage').value; + const type = document.getElementById('breadcrumbType').value; + const level = document.getElementById('breadcrumbLevel').value; + const category = document.getElementById('breadcrumbCategory').value; + + if (!message.trim()) { + alert('Breadcrumb message is required'); + return; + } + + window.hawk.addBreadcrumb({ + message, + type, + level, + ...(category.trim() && { category }), + data: { + timestamp: new Date().toISOString(), + custom: 'manual breadcrumb', + }, + }); + + breadcrumbsOutput.textContent = `✓ Breadcrumb added: ${message}`; +}); + +buttonGetBreadcrumbs.addEventListener('click', () => { + const breadcrumbs = window.hawk.getBreadcrumbs(); + + if (breadcrumbs.length === 0) { + breadcrumbsOutput.textContent = 'No breadcrumbs yet'; + return; + } + + breadcrumbsOutput.textContent = JSON.stringify(breadcrumbs, null, 2); +}); + +buttonClearBreadcrumbs.addEventListener('click', () => { + window.hawk.clearBreadcrumbs(); + breadcrumbsOutput.textContent = '✓ Breadcrumbs cleared'; +}); + +buttonTestFetch.addEventListener('click', async () => { + breadcrumbsOutput.textContent = 'Testing fetch breadcrumb...'; + + try { + const response = await fetch('https://api.github.com/zen'); + const text = await response.text(); + breadcrumbsOutput.textContent = `✓ Fetch completed (${response.status}): "${text}". Check breadcrumbs!`; + } catch (error) { + breadcrumbsOutput.textContent = `✗ Fetch failed: ${error.message}`; + } +}); diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 081f0a1..2cb2e77 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -35,7 +35,7 @@ }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", "dependencies": { - "@hawk.so/types": "^0.1.36", + "@hawk.so/types": "^0.1.38", "error-stack-parser": "^2.1.4" }, "devDependencies": { diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts new file mode 100644 index 0000000..e218f9c --- /dev/null +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -0,0 +1,618 @@ +/** + * @file Breadcrumbs module - captures chronological trail of events before an error + */ +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 + * + * @param options + */ + 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 + * + * @param breadcrumb + * @param hint + */ + 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) { + /** + * Discard breadcrumb + */ + return; + } + + 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 + * + * @param data + */ + private sanitizeData(data: Record): Record { + 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 + * + * @param str + * @param maxLength + */ + 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; + } + + const originalFetch = window.fetch.bind(window); + + this.originalFetch = originalFetch; + + const manager = this; + + window.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 originalFetch(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; + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + this.originalXHROpen = originalOpen; + this.originalXHRSend = originalSend; + + /** + * 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; + + return originalOpen.apply(this, [method, url, ...args] as Parameters); + }; + + 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 originalSend.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) { + window.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 + * + * @param message + * @param options + */ +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/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 3511a65..da1b322 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/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/packages/javascript/src/types/event.ts b/packages/javascript/src/types/event.ts index eebaf63..82dec49 100644 --- a/packages/javascript/src/types/event.ts +++ b/packages/javascript/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" From 05ab9bec78bbc5a32ba2c5fbbe0c308e540f8db1 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:32:40 +0300 Subject: [PATCH 2/4] chore: lint fix --- packages/javascript/package.json | 3 +- packages/javascript/src/addons/breadcrumbs.ts | 184 ++++++++++-------- 2 files changed, 100 insertions(+), 87 deletions(-) diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 2cb2e77..e96b1b5 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -18,7 +18,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "stats": "size-limit > stats.txt" + "stats": "size-limit > stats.txt", + "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, "repository": { "type": "git", diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index e218f9c..907f61b 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -165,7 +165,7 @@ export class BreadcrumbManager { /** * Initialize breadcrumbs with options and start auto-capture * - * @param options + * @param options - Configuration options for breadcrumbs */ public init(options: BreadcrumbsOptions = {}): void { if (this.isInitialized) { @@ -203,8 +203,8 @@ export class BreadcrumbManager { /** * Add a breadcrumb to the buffer * - * @param breadcrumb - * @param hint + * @param breadcrumb - The breadcrumb data to add + * @param hint - Optional hint object with original event data */ public addBreadcrumb(breadcrumb: Omit & { timestamp?: Breadcrumb['timestamp'] }, hint?: BreadcrumbHint): void { /** @@ -266,10 +266,61 @@ export class BreadcrumbManager { this.breadcrumbs.length = 0; } + /** + * Destroy the manager and restore original functions + */ + public destroy(): void { + /** + * Restore fetch + */ + if (this.originalFetch) { + window.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; + } + /** * Sanitize and trim breadcrumb data object * - * @param data + * @param data - The data object to sanitize */ private sanitizeData(data: Record): Record { const sanitized = Sanitizer.sanitize(data) as Record; @@ -287,8 +338,8 @@ export class BreadcrumbManager { /** * Trim string to max length * - * @param str - * @param maxLength + * @param str - The string to trim + * @param maxLength - Maximum allowed length */ private trimString(str: string, maxLength: number): string { if (str.length > maxLength) { @@ -310,12 +361,21 @@ export class BreadcrumbManager { this.originalFetch = originalFetch; + // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this; window.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 url: string; + + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input.url; + } let response: Response; @@ -330,10 +390,10 @@ export class BreadcrumbManager { message: `${method} ${url} ${response.status}`, level: response.ok ? 'info' : 'error', data: { - url, - method, - status_code: response.status, - duration_ms: duration, + url: url as unknown as Json, + method: method as unknown as Json, + statusCode: response.status as unknown as Json, + durationMs: duration as unknown as Json, }, }, { input, @@ -350,10 +410,10 @@ export class BreadcrumbManager { message: `${method} ${url} failed`, level: 'error', data: { - url, - method, - duration_ms: duration, - error: error instanceof Error ? error.message : String(error), + url: url as unknown as Json, + method: method as unknown as Json, + durationMs: duration as unknown as Json, + error: (error instanceof Error ? error.message : String(error)) as unknown as Json, }, }, { input, @@ -372,6 +432,7 @@ export class BreadcrumbManager { return; } + // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this; const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; @@ -383,26 +444,26 @@ export class BreadcrumbManager { * Store request info on the XHR instance */ interface XHRWithBreadcrumb extends XMLHttpRequest { - __hawk_method?: string; - __hawk_url?: string; - __hawk_start?: number; + hawkMethod?: string; + hawkUrl?: string; + hawkStart?: 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; + this.hawkMethod = method; + this.hawkUrl = typeof url === 'string' ? url : url.href; return originalOpen.apply(this, [method, url, ...args] as Parameters); }; XMLHttpRequest.prototype.send = function (this: XHRWithBreadcrumb, body?: Document | XMLHttpRequestBodyInit | null) { - this.__hawk_start = Date.now(); + this.hawkStart = 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 duration = Date.now() - (this.hawkStart || Date.now()); + const method = this.hawkMethod || 'GET'; + const url = this.hawkUrl || ''; const status = this.status; manager.addBreadcrumb({ @@ -411,10 +472,10 @@ export class BreadcrumbManager { message: `${method} ${url} ${status}`, level: status >= 200 && status < 400 ? 'info' : 'error', data: { - url, - method, - status_code: status, - duration_ms: duration, + url: url as unknown as Json, + method: method as unknown as Json, + statusCode: status as unknown as Json, + durationMs: duration as unknown as Json, }, }, { xhr: this, @@ -439,6 +500,7 @@ export class BreadcrumbManager { return; } + // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this; let lastUrl = window.location.href; @@ -453,8 +515,8 @@ export class BreadcrumbManager { message: `Navigated to ${to}`, level: 'info', data: { - from, - to, + from: from as unknown as Json, + to: to as unknown as Json, }, }); }; @@ -495,6 +557,7 @@ export class BreadcrumbManager { * Setup click event tracking for UI breadcrumbs */ private setupClickTracking(): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias const manager = this; this.clickHandler = (event: MouseEvent): void => { @@ -527,9 +590,9 @@ export class BreadcrumbManager { message: `Click on ${selector}`, level: 'info', data: { - selector, - text: text || undefined, - tagName: target.tagName, + selector: selector as unknown as Json, + text: (text || undefined) as unknown as Json, + tagName: target.tagName as unknown as Json, }, }, { event, @@ -538,64 +601,13 @@ export class BreadcrumbManager { document.addEventListener('click', this.clickHandler, { capture: true }); } - - /** - * Destroy the manager and restore original functions - */ - public destroy(): void { - /** - * Restore fetch - */ - if (this.originalFetch) { - window.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 * - * @param message - * @param options + * @param message - The breadcrumb message + * @param options - Optional breadcrumb configuration */ export function createBreadcrumb( message: string, From 3c22e7877858057088c5f704caaf2bceec633d4d Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:24:48 +0300 Subject: [PATCH 3/4] chore: add comprehensive testing for all breadcrumb types --- packages/javascript/example/index.html | 17 +- packages/javascript/example/sample-errors.js | 218 ++++++++++++++++++- 2 files changed, 228 insertions(+), 7 deletions(-) diff --git a/packages/javascript/example/index.html b/packages/javascript/example/index.html index 432ea89..a257806 100644 --- a/packages/javascript/example/index.html +++ b/packages/javascript/example/index.html @@ -204,10 +204,22 @@

Breadcrumbs Management

-

-
+
+

Test All Breadcrumb Types Quick examples for each type

+
+ + + + + + +
+
+ +
+

Test Vue integration: $root

@@ -293,6 +305,7 @@

Test Vue integration: <test-component>

context: { rootContextSample: '12345', }, + // trackClicks: true }); diff --git a/packages/javascript/example/sample-errors.js b/packages/javascript/example/sample-errors.js index 8b67bd8..0aaeb42 100644 --- a/packages/javascript/example/sample-errors.js +++ b/packages/javascript/example/sample-errors.js @@ -130,7 +130,6 @@ buttonSetContext.addEventListener('click', () => { const buttonAddBreadcrumb = document.getElementById('btn-add-breadcrumb'); const buttonGetBreadcrumbs = document.getElementById('btn-get-breadcrumbs'); const buttonClearBreadcrumbs = document.getElementById('btn-clear-breadcrumbs'); -const buttonTestFetch = document.getElementById('btn-test-fetch-breadcrumb'); const breadcrumbsOutput = document.getElementById('breadcrumbs-output'); buttonAddBreadcrumb.addEventListener('click', () => { @@ -174,14 +173,223 @@ buttonClearBreadcrumbs.addEventListener('click', () => { breadcrumbsOutput.textContent = '✓ Breadcrumbs cleared'; }); -buttonTestFetch.addEventListener('click', async () => { - breadcrumbsOutput.textContent = 'Testing fetch breadcrumb...'; +/** + * Test All Breadcrumb Types + */ +const buttonTestDefault = document.getElementById('btn-test-default'); +const buttonTestRequest = document.getElementById('btn-test-request'); +const buttonTestUI = document.getElementById('btn-test-ui'); +const buttonTestNavigation = document.getElementById('btn-test-navigation'); +const buttonTestLogic = document.getElementById('btn-test-logic'); +const buttonTestError = document.getElementById('btn-test-error'); +const buttonTestAllTypes = document.getElementById('btn-test-all-types'); + +// Test Default breadcrumb +buttonTestDefault.addEventListener('click', () => { + window.hawk.addBreadcrumb({ + type: 'default', + level: 'info', + category: 'user.action', + message: 'User clicked on default event button', + data: { + action: 'button_click', + context: 'breadcrumb_testing', + }, + }); + breadcrumbsOutput.textContent = '✓ Default breadcrumb added'; +}); + +// Test Request breadcrumb (automatic via fetch) +buttonTestRequest.addEventListener('click', async () => { + breadcrumbsOutput.textContent = 'Testing request breadcrumb...'; try { const response = await fetch('https://api.github.com/zen'); const text = await response.text(); - breadcrumbsOutput.textContent = `✓ Fetch completed (${response.status}): "${text}". Check breadcrumbs!`; + breadcrumbsOutput.textContent = `✓ Request breadcrumb added (${response.status}): "${text}"`; } catch (error) { - breadcrumbsOutput.textContent = `✗ Fetch failed: ${error.message}`; + breadcrumbsOutput.textContent = `✗ Request failed: ${error.message}`; } }); + +// Test UI breadcrumb +buttonTestUI.addEventListener('click', () => { + window.hawk.addBreadcrumb({ + type: 'ui', + level: 'info', + category: 'ui.click', + message: 'Click on test button#btn-test-ui', + data: { + selector: 'button#btn-test-ui', + text: '👆 UI Click', + tagName: 'BUTTON', + coordinates: { + x: 100, + y: 200, + }, + }, + }); + breadcrumbsOutput.textContent = '✓ UI Click breadcrumb added'; +}); + +// Test Navigation breadcrumb +buttonTestNavigation.addEventListener('click', () => { + const currentUrl = window.location.href; + const testUrl = currentUrl.split('#')[0] + '#breadcrumb-test-' + Date.now(); + + window.hawk.addBreadcrumb({ + type: 'navigation', + level: 'info', + category: 'navigation', + message: `Navigated to ${testUrl}`, + data: { + from: currentUrl, + to: testUrl, + method: 'hash_change', + }, + }); + + // Actually change the hash to trigger real navigation breadcrumb too + window.location.hash = 'breadcrumb-test-' + Date.now(); + + breadcrumbsOutput.textContent = '✓ Navigation breadcrumb added'; +}); + +// Test Logic breadcrumb +buttonTestLogic.addEventListener('click', () => { + // Simulate some logic operations + const startTime = performance.now(); + + /** + * Complex calculation for testing + * + * @param {number} n - Number of iterations + * @returns {number} Calculation result + */ + function complexCalculation(n) { + let result = 0; + + for (let i = 0; i < n; i++) { + result += Math.sqrt(i); + } + + return result; + } + + const result = complexCalculation(10000); + const duration = performance.now() - startTime; + + window.hawk.addBreadcrumb({ + type: 'logic', + level: 'debug', + category: 'calculation.complex', + message: 'Performed complex calculation', + data: { + operation: 'complexCalculation', + iterations: 10000, + result: result, + durationMs: duration.toFixed(2), + }, + }); + + breadcrumbsOutput.textContent = `✓ Logic breadcrumb added (${duration.toFixed(2)}ms)`; +}); + +// Test Error breadcrumb +buttonTestError.addEventListener('click', () => { + try { + // Intentionally cause an error but catch it + JSON.parse('invalid json {{{'); + } catch (error) { + window.hawk.addBreadcrumb({ + type: 'error', + level: 'error', + category: 'json.parse', + message: `JSON parse error: ${error.message}`, + data: { + error: error.name, + message: error.message, + input: 'invalid json {{{', + }, + }); + + breadcrumbsOutput.textContent = `✓ Error breadcrumb added: ${error.message}`; + } +}); + +// Test All Types in sequence +buttonTestAllTypes.addEventListener('click', async () => { + breadcrumbsOutput.textContent = 'Running all breadcrumb types...'; + + // 1. Default + window.hawk.addBreadcrumb({ + type: 'default', + level: 'info', + message: 'Sequence started', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // 2. Logic + window.hawk.addBreadcrumb({ + type: 'logic', + level: 'debug', + category: 'sequence.step', + message: 'Processing step 1', + data: { + step: 1, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // 3. UI + window.hawk.addBreadcrumb({ + type: 'ui', + level: 'info', + category: 'ui.interaction', + message: 'User initiated sequence', + data: { + action: 'sequence_test', + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // 4. Request + try { + await fetch('https://api.github.com/zen'); + } catch (error) { + // Fetch will be captured automatically + } + + await new Promise(resolve => setTimeout(resolve, 200)); + + // 5. Navigation + window.hawk.addBreadcrumb({ + type: 'navigation', + level: 'info', + message: 'Internal route change', + data: { + route: '/test', + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // 6. Error + try { + throw new Error('Test error in sequence'); + } catch (error) { + window.hawk.addBreadcrumb({ + type: 'error', + level: 'warning', + message: `Caught error: ${error.message}`, + data: { + error: error.name, + }, + }); + } + + breadcrumbsOutput.textContent = '✓ All breadcrumb types added! Check "Get Breadcrumbs"'; +}); From 9392108647842842e4a6b82c66a30e6bba48f108 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:49:10 +0300 Subject: [PATCH 4/4] style: update button labels and adjust breadcrumb output margin --- packages/javascript/example/index.html | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/javascript/example/index.html b/packages/javascript/example/index.html index a257806..4c631ab 100644 --- a/packages/javascript/example/index.html +++ b/packages/javascript/example/index.html @@ -204,20 +204,18 @@

Breadcrumbs Management

- -
-
+

Test All Breadcrumb Types Quick examples for each type

- - - - - - + + + + + +
- +