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
+
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
});