From 8070fac3b557e4613c5d0608375a036ec1203209 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 19:52:18 +0300 Subject: [PATCH 1/3] refactor(core): Extract shared modules and utilities - fetchTimer function moved to @hawk.so/core - Sanitizer moved to @hawk.so/core (except Element handling) - SanitizerTypeHandler feature added - browser Element SanitizerTypeHandler added - StackParser moved to @hawk.so/core - Transport moved to @hawk.so/core - event utility functions moved to @hawk.so/core - selector utility functions moved to @hawk.so/core - validation utility functions moved to @hawk.so/core - EventRejectedError moved to @hawk.so/core - suppress error-logs by StackParser in tests --- packages/{javascript => core}/src/errors.ts | 0 packages/core/src/index.ts | 8 ++ .../src/modules/fetch-timer.ts} | 2 +- .../src/modules/sanitizer.ts | 111 ++++++++-------- .../src/modules/stack-parser.ts} | 10 +- packages/core/src/transports/transport.ts | 8 ++ .../{javascript => core}/src/utils/event.ts | 8 +- .../src/utils/selector.ts | 0 .../src/utils/validation.ts | 30 ++--- packages/core/tests/modules/sanitizer.test.ts | 118 +++++++++++++++++ .../tests/utils/validation.test.ts | 6 +- packages/javascript/src/addons/breadcrumbs.ts | 5 +- .../javascript/src/addons/consoleCatcher.ts | 2 +- packages/javascript/src/catcher.ts | 39 +++--- .../src/modules/element-sanitizer.ts | 20 +++ packages/javascript/src/modules/socket.ts | 3 +- .../javascript/src/types/catcher-message.ts | 19 +-- packages/javascript/src/types/event.ts | 53 +------- .../src/types/hawk-initial-settings.ts | 4 +- packages/javascript/src/types/transport.ts | 7 +- .../javascript/tests/catcher.addons.test.ts | 7 +- .../tests/catcher.before-send.test.ts | 5 + .../tests/catcher.breadcrumbs.test.ts | 7 +- .../javascript/tests/catcher.context.test.ts | 7 +- .../tests/catcher.global-handlers.test.ts | 7 +- .../javascript/tests/catcher.release.test.ts | 7 +- packages/javascript/tests/catcher.test.ts | 9 +- .../tests/catcher.transport.test.ts | 7 +- .../javascript/tests/catcher.user.test.ts | 7 +- .../tests/modules/sanitizer.test.ts | 124 ++---------------- 30 files changed, 323 insertions(+), 317 deletions(-) rename packages/{javascript => core}/src/errors.ts (100%) rename packages/{javascript/src/modules/fetchTimer.ts => core/src/modules/fetch-timer.ts} (96%) rename packages/{javascript => core}/src/modules/sanitizer.ts (78%) rename packages/{javascript/src/modules/stackParser.ts => core/src/modules/stack-parser.ts} (95%) create mode 100644 packages/core/src/transports/transport.ts rename packages/{javascript => core}/src/utils/event.ts (80%) rename packages/{javascript => core}/src/utils/selector.ts (100%) rename packages/{javascript => core}/src/utils/validation.ts (68%) create mode 100644 packages/core/tests/modules/sanitizer.test.ts rename packages/{javascript => core}/tests/utils/validation.test.ts (92%) create mode 100644 packages/javascript/src/modules/element-sanitizer.ts diff --git a/packages/javascript/src/errors.ts b/packages/core/src/errors.ts similarity index 100% rename from packages/javascript/src/errors.ts rename to packages/core/src/errors.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66f1fca2..0a869e00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,11 @@ export type { RandomGenerator } from './utils/random'; export { HawkUserManager } from './users/hawk-user-manager'; export type { Logger, LogType } from './logger/logger'; export { isLoggerSet, setLogger, resetLogger, log } from './logger/logger'; +export { Sanitizer } from './modules/sanitizer'; +export type { SanitizerTypeHandler } from './modules/sanitizer'; +export { StackParser } from './modules/stack-parser'; +export { buildElementSelector } from './utils/selector'; +export type { Transport } from './transports/transport'; +export { EventRejectedError } from './errors'; +export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +export { isPlainObject, validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from './utils/validation'; diff --git a/packages/javascript/src/modules/fetchTimer.ts b/packages/core/src/modules/fetch-timer.ts similarity index 96% rename from packages/javascript/src/modules/fetchTimer.ts rename to packages/core/src/modules/fetch-timer.ts index a17c1d47..1405bfd2 100644 --- a/packages/javascript/src/modules/fetchTimer.ts +++ b/packages/core/src/modules/fetch-timer.ts @@ -1,4 +1,4 @@ -import { log } from '@hawk.so/core'; +import { log } from '../logger/logger'; /** * Sends AJAX request and wait for some time. diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts similarity index 78% rename from packages/javascript/src/modules/sanitizer.ts rename to packages/core/src/modules/sanitizer.ts index a02172d0..08472a13 100644 --- a/packages/javascript/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -1,12 +1,32 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { isPlainObject } from '../utils/validation'; + +/** + * Custom type handler for Sanitizer. + * + * Allows user to register their own formatters from external packages. + */ +export interface SanitizerTypeHandler { + /** + * Checks if this handler should be applied to given value + * + * @returns `true` + */ + check: (target: any) => boolean; + + /** + * Formats the value into a sanitized representation + */ + format: (target: any) => any; +} + /** * This class provides methods for preparing data to sending to Hawk * - trim long strings - * - represent html elements like
as "
" instead of "{}" * - represent big objects as "" * - represent class as or */ -export default class Sanitizer { +export class Sanitizer { /** * Maximum string length */ @@ -28,13 +48,31 @@ export default class Sanitizer { */ private static readonly maxArrayLength: number = 10; + /** + * Custom type handlers registered via {@link registerHandler}. + * + * Checked in {@link sanitize} before built-in type checks. + */ + private static readonly customHandlers: SanitizerTypeHandler[] = []; + /** * Check if passed variable is an object * * @param target - variable to check */ public static isObject(target: any): boolean { - return Sanitizer.typeOf(target) === 'object'; + return isPlainObject(target); + } + + /** + * Register a custom type handler. + * Handlers are checked before built-in type checks, in reverse registration order + * (last registered = highest priority). + * + * @param handler - handler to register + */ + public static registerHandler(handler: SanitizerTypeHandler): void { + Sanitizer.customHandlers.unshift(handler); } /** @@ -60,19 +98,21 @@ export default class Sanitizer { */ if (Sanitizer.isArray(data)) { return this.sanitizeArray(data, depth + 1, seen); + } - /** - * If value is an Element, format it as string with outer HTML - * HTMLDivElement -> "
" - */ - } else if (Sanitizer.isElement(data)) { - return Sanitizer.formatElement(data); + // Check additional handlers provided by env-specific modules or users + // to sanitize some additional cases (e.g. specific object types) + for (const handler of Sanitizer.customHandlers) { + if (handler.check(data)) { + return handler.format(data); + } + } - /** - * If values is a not-constructed class, it will be formatted as "" - * class Editor {...} -> - */ - } else if (Sanitizer.isClassPrototype(data)) { + /** + * If values is a not-constructed class, it will be formatted as "" + * class Editor {...} -> + */ + if (Sanitizer.isClassPrototype(data)) { return Sanitizer.formatClassPrototype(data); /** @@ -131,7 +171,9 @@ export default class Sanitizer { * @param depth - current depth of recursion * @param seen - Set of already seen objects to prevent circular references */ - private static sanitizeObject(data: { [key: string]: any }, depth: number, seen: WeakSet): Record | '' | '' { + private static sanitizeObject(data: { + [key: string]: any + }, depth: number, seen: WeakSet): Record | '' | '' { /** * If the maximum depth is reached, return a placeholder */ @@ -205,24 +247,6 @@ export default class Sanitizer { return typeof target === 'string'; } - /** - * Return string representation of the object type - * - * @param object - object to get type - */ - private static typeOf(object: any): string { - return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase(); - } - - /** - * Check if passed variable is an HTML Element - * - * @param target - variable to check - */ - private static isElement(target: any): boolean { - return target instanceof Element; - } - /** * Return name of a passed class * @@ -248,31 +272,12 @@ export default class Sanitizer { */ private static trimString(target: string): string { if (target.length > Sanitizer.maxStringLen) { - return target.substr(0, Sanitizer.maxStringLen) + '…'; + return target.substring(0, Sanitizer.maxStringLen) + '…'; } return target; } - /** - * Represent HTML Element as string with it outer-html - * HTMLDivElement -> "
" - * - * @param target - variable to format - */ - private static formatElement(target: Element): string { - /** - * Also, remove inner HTML because it can be BIG - */ - const innerHTML = target.innerHTML; - - if (innerHTML) { - return target.outerHTML.replace(target.innerHTML, '…'); - } - - return target.outerHTML; - } - /** * Represent not-constructed class as "" * diff --git a/packages/javascript/src/modules/stackParser.ts b/packages/core/src/modules/stack-parser.ts similarity index 95% rename from packages/javascript/src/modules/stackParser.ts rename to packages/core/src/modules/stack-parser.ts index f7ee32f8..8ea7518c 100644 --- a/packages/javascript/src/modules/stackParser.ts +++ b/packages/core/src/modules/stack-parser.ts @@ -1,12 +1,12 @@ import type { StackFrame } from 'error-stack-parser'; import ErrorStackParser from 'error-stack-parser'; import type { BacktraceFrame, SourceCodeLine } from '@hawk.so/types'; -import fetchTimer from './fetchTimer'; +import fetchTimer from './fetch-timer'; /** * This module prepares parsed backtrace */ -export default class StackParser { +export class StackParser { /** * Prevents loading one file several times * name -> content @@ -48,7 +48,7 @@ export default class StackParser { try { if (!frame.fileName) { return null; - }; + } if (!this.isValidUrl(frame.fileName)) { return null; @@ -118,9 +118,9 @@ export default class StackParser { /** * Downloads source file * - * @param {string} fileName - name of file to download + * @param fileName - name of file to download */ - private async loadSourceFile(fileName): Promise { + private async loadSourceFile(fileName: string): Promise { if (this.sourceFilesCache[fileName] !== undefined) { return this.sourceFilesCache[fileName]; } diff --git a/packages/core/src/transports/transport.ts b/packages/core/src/transports/transport.ts new file mode 100644 index 00000000..719510f7 --- /dev/null +++ b/packages/core/src/transports/transport.ts @@ -0,0 +1,8 @@ +import type { CatcherMessage, CatcherMessageType } from '@hawk.so/types'; + +/** + * Transport interface — anything that can send a CatcherMessage + */ +export interface Transport { + send(message: CatcherMessage): Promise; +} diff --git a/packages/javascript/src/utils/event.ts b/packages/core/src/utils/event.ts similarity index 80% rename from packages/javascript/src/utils/event.ts rename to packages/core/src/utils/event.ts index 63741533..ad381418 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/core/src/utils/event.ts @@ -1,4 +1,4 @@ -import { log } from '@hawk.so/core'; +import { log } from '../logger/logger'; /** * Symbol to mark error as processed by Hawk @@ -6,7 +6,7 @@ import { log } from '@hawk.so/core'; const errorSentShadowProperty = Symbol('__hawk_processed__'); /** - * Check if the error has alrady been sent to Hawk. + * Check if the error has already been sent to Hawk. * * Motivation: * Some integrations may catch errors on their own side and then normally re-throw them down. @@ -20,7 +20,7 @@ export function isErrorProcessed(error: unknown): boolean { return false; } - return error[errorSentShadowProperty] === true; + return (error as Record)[errorSentShadowProperty] === true; } /** @@ -35,7 +35,7 @@ export function markErrorAsProcessed(error: unknown): void { } Object.defineProperty(error, errorSentShadowProperty, { - enumerable: false, // Prevent from beight collected by Hawk + enumerable: false, // Prevent from being collected by Hawk value: true, writable: true, configurable: true, diff --git a/packages/javascript/src/utils/selector.ts b/packages/core/src/utils/selector.ts similarity index 100% rename from packages/javascript/src/utils/selector.ts rename to packages/core/src/utils/selector.ts diff --git a/packages/javascript/src/utils/validation.ts b/packages/core/src/utils/validation.ts similarity index 68% rename from packages/javascript/src/utils/validation.ts rename to packages/core/src/utils/validation.ts index 293cafc6..16311edd 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,6 +1,5 @@ -import { log } from '@hawk.so/core'; -import type { AffectedUser, Breadcrumb, EventContext, EventData, JavaScriptAddons } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; +import { log } from '../logger/logger'; +import type { AffectedUser, Breadcrumb, EventAddons, EventContext, EventData } from '@hawk.so/types'; /** * Validates user data - basic security checks @@ -8,7 +7,7 @@ import Sanitizer from '../modules/sanitizer'; * @param user - user data to validate */ export function validateUser(user: AffectedUser): boolean { - if (!user || !Sanitizer.isObject(user)) { + if (!user || !isPlainObject(user)) { log('validateUser: User must be an object', 'warn'); return false; @@ -30,7 +29,7 @@ export function validateUser(user: AffectedUser): boolean { * @param context - context data to validate */ export function validateContext(context: EventContext | undefined): boolean { - if (context && !Sanitizer.isObject(context)) { + if (context && !isPlainObject(context)) { log('validateContext: Context must be an object', 'warn'); return false; @@ -40,22 +39,23 @@ export function validateContext(context: EventContext | undefined): boolean { } /** - * Checks if value is a plain object (not array, Date, etc.) + * Checks if value is a plain object (not null, array, Date, Map, etc.) * * @param value - value to check + * @returns `true` if value is a plain object, otherwise `false` */ -function isPlainObject(value: unknown): value is Record { +export function isPlainObject(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]'; } /** * Runtime check for required EventData fields. * Per @hawk.so/types EventData, `title` is the only non-optional field. - * Additionally validates `backtrace` shape if present (must be an array). + * Additionally, validates `backtrace` shape if present (must be an array). * * @param payload - value to validate */ -export function isValidEventPayload(payload: unknown): payload is EventData { +export function isValidEventPayload(payload: unknown): payload is EventData { if (!isPlainObject(payload)) { return false; } @@ -64,11 +64,7 @@ export function isValidEventPayload(payload: unknown): payload is EventData { + describe('isObject', () => { + it('should return true for a plain object', () => { + expect(Sanitizer.isObject({})).toBe(true); + }); + + it('should return false for an array', () => { + expect(Sanitizer.isObject([])).toBe(false); + }); + + it('should return false for a string', () => { + expect(Sanitizer.isObject('x')).toBe(false); + }); + + it('should return false for a boolean', () => { + expect(Sanitizer.isObject(true)).toBe(false); + }); + + it('should return false for null', () => { + expect(Sanitizer.isObject(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(Sanitizer.isObject(undefined)).toBe(false); + }); + }); + + describe('sanitize', () => { + it('should pass through strings within the length limit', () => { + expect(Sanitizer.sanitize('hello')).toBe('hello'); + }); + + it('should trim strings longer than maxStringLen', () => { + const long = 'a'.repeat(201); + const result = Sanitizer.sanitize(long); + + expect(result).toBe('a'.repeat(200) + '…'); + }); + + it('should pass through short arrays unchanged', () => { + expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('should truncate arrays over maxArrayLength items and append placeholder', () => { + const arr = Array.from({ length: 12 }, (_, i) => i); + const result = Sanitizer.sanitize(arr); + + expect(result).toHaveLength(11); + expect(result[10]).toBe('<2 more items...>'); + }); + + it('should sanitize nested objects recursively', () => { + const longStr = 'a'.repeat(201); + const longArr = Array.from({ length: 12 }, (_, i) => i); + const obj = { + foo: 'x', + bar: longStr, + baz: longArr + } + const result = Sanitizer.sanitize(obj); + + expect(result.foo).toBe('x'); + expect(result.bar).toBe('a'.repeat(200) + '…'); + expect(result.baz).toHaveLength(11); + expect(result.baz[10]).toBe('<2 more items...>'); + }); + + it('should replace objects with more than 20 keys with placeholder', () => { + const big: Record = {}; + + for (let i = 0; i < 21; i++) { + big[`k${i}`] = i; + } + + expect(Sanitizer.sanitize(big)).toBe(''); + }); + + it('should replace deeply nested objects with placeholder', () => { + const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } }; + const result = Sanitizer.sanitize(deep); + + expect(result.a.b.c.d.e).toBe(''); + }); + + it('should format a class (not constructed) as ""', () => { + class Foo {} + + expect(Sanitizer.sanitize(Foo)).toBe(''); + }); + + it('should format a class instance as ""', () => { + class Foo {} + + expect(Sanitizer.sanitize(new Foo())).toBe(''); + }); + + it('should replace circular references with placeholder', () => { + const obj: any = { a: 1 }; + + obj.self = obj; + + const result = Sanitizer.sanitize(obj); + + expect(result.self).toBe(''); + }); + + it.each([ + { label: 'number', value: 42 }, + { label: 'boolean', value: true }, + { label: 'null', value: null }, + ])('should pass through $label primitives unchanged', ({ value }) => { + expect(Sanitizer.sanitize(value)).toBe(value); + }); + }); +}); diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/core/tests/utils/validation.test.ts similarity index 92% rename from packages/javascript/tests/utils/validation.test.ts rename to packages/core/tests/utils/validation.test.ts index 20d02944..bb2a16c2 100644 --- a/packages/javascript/tests/utils/validation.test.ts +++ b/packages/core/tests/utils/validation.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi } from 'vitest'; -import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src/utils/validation'; +import { describe, expect, it, vi } from 'vitest'; +import { isValidBreadcrumb, isValidEventPayload, validateContext, validateUser } from '../../src'; // Suppress log output produced by log() calls inside validation failures. -vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); +vi.mock('../../src/logger/logger', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); describe('validateUser', () => { it('should return false when user is null', () => { diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index 1e4f0b9b..c953e790 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -2,10 +2,7 @@ * @file Breadcrumbs module - captures chronological trail of events before an error */ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; -import { buildElementSelector } from '../utils/selector'; -import { log } from '@hawk.so/core'; -import { isValidBreadcrumb } from '../utils/validation'; +import { buildElementSelector, isValidBreadcrumb, log, Sanitizer } from '@hawk.so/core'; /** * Default maximum number of breadcrumbs to store diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 29519eaa..f5742b7e 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -2,7 +2,7 @@ * @file Module for intercepting console logs with stack trace capture */ import type { ConsoleLogEvent } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; +import { Sanitizer } from '@hawk.so/core'; /** * Maximum number of console logs to store diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 822d974c..be20245c 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,6 +1,5 @@ +import './modules/element-sanitizer'; import Socket from './modules/socket'; -import Sanitizer from './modules/sanitizer'; -import StackParser from './modules/stackParser'; import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import type { @@ -12,16 +11,26 @@ import type { Json, VueIntegrationAddons } from '@hawk.so/types'; -import type { JavaScriptCatcherIntegrations } from './types/integrations'; -import { EventRejectedError } from './errors'; -import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; -import { BrowserRandomGenerator } from './utils/random'; +import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { isValidEventPayload, validateContext, validateUser } from './utils/validation'; -import { HawkUserManager, isLoggerSet, log, setLogger } from '@hawk.so/core'; +import { + EventRejectedError, + HawkUserManager, + isErrorProcessed, + isLoggerSet, + isValidEventPayload, + log, + markErrorAsProcessed, + Sanitizer, + setLogger, + StackParser, + validateContext, + validateUser +} from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; +import { BrowserRandomGenerator } from './utils/random'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -55,7 +64,7 @@ export default class Catcher { /** * Catcher Type */ - private readonly type: string = 'errors/javascript'; + private readonly type = 'errors/javascript' as const; /** * User project's Integration Token @@ -508,7 +517,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } return (error as Error).name; @@ -518,7 +527,7 @@ export default class Catcher { * Release version */ private getRelease(): HawkJavaScriptEvent['release'] { - return this.release !== undefined ? String(this.release) : null; + return this.release !== undefined ? String(this.release) : undefined; } /** @@ -571,7 +580,7 @@ export default class Catcher { private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs(); - return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : null; + return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : undefined; } /** @@ -611,7 +620,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } try { @@ -619,7 +628,7 @@ export default class Catcher { } catch (e) { log('Can not parse stack:', 'warn', e); - return null; + return undefined; } } @@ -686,6 +695,6 @@ export default class Catcher { * @param integrationAddons - extra addons */ private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void { - Object.assign(errorFormatted.payload.addons, integrationAddons); + Object.assign(errorFormatted.payload.addons!, integrationAddons); } } diff --git a/packages/javascript/src/modules/element-sanitizer.ts b/packages/javascript/src/modules/element-sanitizer.ts new file mode 100644 index 00000000..071275a1 --- /dev/null +++ b/packages/javascript/src/modules/element-sanitizer.ts @@ -0,0 +1,20 @@ +import { Sanitizer } from '@hawk.so/core'; + +/** + * Registers browser-specific sanitizer handler for {@link Element} objects. + * + * Handles HTML Element and represents as string with it outer HTML with + * inner content replaced: HTMLDivElement -> "
" + */ +Sanitizer.registerHandler({ + check: (target) => target instanceof Element, + format: (target: Element) => { + const innerHTML = target.innerHTML; + + if (innerHTML) { + return target.outerHTML.replace(target.innerHTML, '…'); + } + + return target.outerHTML; + }, +}); diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 930a9e53..24add33d 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -1,6 +1,5 @@ import { log } from '@hawk.so/core'; -import type { CatcherMessage } from '@/types'; -import type { Transport } from '../types/transport'; +import type { CatcherMessage, Transport } from '@/types'; /** * Custom WebSocket wrapper class diff --git a/packages/javascript/src/types/catcher-message.ts b/packages/javascript/src/types/catcher-message.ts index d892e22a..84507534 100644 --- a/packages/javascript/src/types/catcher-message.ts +++ b/packages/javascript/src/types/catcher-message.ts @@ -1,21 +1,6 @@ -import type { HawkJavaScriptEvent } from './event'; +import type { CatcherMessage as HawkCatcherMessage } from '@hawk.so/types'; /** * Structure describing a message sending by Catcher */ -export interface CatcherMessage { - /** - * User project's Integration Token - */ - token: string; - - /** - * Hawk Catcher name - */ - catcherType: string; - - /** - * All information about the event - */ - payload: HawkJavaScriptEvent; -} +export type CatcherMessage = HawkCatcherMessage<'errors/javascript'>; diff --git a/packages/javascript/src/types/event.ts b/packages/javascript/src/types/event.ts index 82dec497..89eee08c 100644 --- a/packages/javascript/src/types/event.ts +++ b/packages/javascript/src/types/event.ts @@ -1,55 +1,6 @@ -import type { AffectedUser, BacktraceFrame, EventContext, EventData, JavaScriptAddons, Breadcrumb } from '@hawk.so/types'; - -/** - * Event data with JS specific addons - */ -type JSEventData = EventData; +import type { CatcherMessagePayload } from '@hawk.so/types'; /** * Event will be sent to Hawk by Hawk JavaScript SDK - * - * The listed EventData properties will always be sent, so we define them as required in the type */ -export type HawkJavaScriptEvent = Omit & { - /** - * Event type: TypeError, ReferenceError etc - * null for non-error events - */ - type: string | null; - - /** - * Current release (aka version, revision) of an application - */ - release: string | null; - - /** - * Breadcrumbs - chronological trail of events before the error - */ - breadcrumbs: Breadcrumb[] | null; - - /** - * Current authenticated user - */ - user: AffectedUser | null; - - /** - * Any other information collected and passed by user - */ - context: EventContext; - - /** - * Catcher-specific information - */ - addons: JavaScriptAddons; - - /** - * Stack - * From the latest call to the earliest - */ - backtrace: BacktraceFrame[] | null; - - /** - * Catcher version - */ - catcherVersion: string; -}; +export type HawkJavaScriptEvent = CatcherMessagePayload<'errors/javascript'>; diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 987cdf4c..f0a81bd3 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -1,6 +1,6 @@ -import type { EventContext, AffectedUser } from '@hawk.so/types'; +import type { AffectedUser, EventContext } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; -import type { Transport } from './transport'; +import type { Transport } from '@/types'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; /** diff --git a/packages/javascript/src/types/transport.ts b/packages/javascript/src/types/transport.ts index f2237dca..c8df0709 100644 --- a/packages/javascript/src/types/transport.ts +++ b/packages/javascript/src/types/transport.ts @@ -1,8 +1,7 @@ -import type { CatcherMessage } from './catcher-message'; +import type { Transport as HawkTransport } from '@hawk.so/core'; /** - * Transport interface — anything that can send a CatcherMessage + * Transport interface — anything that can send a {@link CatcherMessage}. */ -export interface Transport { - send(message: CatcherMessage): Promise; +export interface Transport extends HawkTransport<'errors/javascript'> { } diff --git a/packages/javascript/tests/catcher.addons.test.ts b/packages/javascript/tests/catcher.addons.test.ts index ff8c5689..09909361 100644 --- a/packages/javascript/tests/catcher.addons.test.ts +++ b/packages/javascript/tests/catcher.addons.test.ts @@ -3,9 +3,10 @@ import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/catcher.before-send.test.ts b/packages/javascript/tests/catcher.before-send.test.ts index bf18f18d..d7543770 100644 --- a/packages/javascript/tests/catcher.before-send.test.ts +++ b/packages/javascript/tests/catcher.before-send.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { createCatcher, createTransport, wait, getLastPayload } from './catcher.helpers'; +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = vi.fn().mockResolvedValue([]); } }; +}); + describe('Catcher', () => { it('should send event as-is when beforeSend returns it unchanged', async () => { const { sendSpy, transport } = createTransport(); diff --git a/packages/javascript/tests/catcher.breadcrumbs.test.ts b/packages/javascript/tests/catcher.breadcrumbs.test.ts index 45cd450a..8c065f6c 100644 --- a/packages/javascript/tests/catcher.breadcrumbs.test.ts +++ b/packages/javascript/tests/catcher.breadcrumbs.test.ts @@ -3,9 +3,10 @@ import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/catcher.context.test.ts b/packages/javascript/tests/catcher.context.test.ts index 231b653c..062ef2bb 100644 --- a/packages/javascript/tests/catcher.context.test.ts +++ b/packages/javascript/tests/catcher.context.test.ts @@ -3,9 +3,10 @@ import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 34c18108..e5936152 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -4,9 +4,10 @@ import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import { TEST_TOKEN, wait, createTransport, getLastPayload } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/catcher.release.test.ts b/packages/javascript/tests/catcher.release.test.ts index 2694711e..ec1c6844 100644 --- a/packages/javascript/tests/catcher.release.test.ts +++ b/packages/javascript/tests/catcher.release.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createCatcher, createTransport, getLastPayload, wait } from "./catcher.helpers"; +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = vi.fn().mockResolvedValue([]); } }; +}); + describe('Catcher', () => { it('should include release version when configured', async () => { const { sendSpy, transport } = createTransport(); diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts index 25a363a7..ffc918c4 100644 --- a/packages/javascript/tests/catcher.test.ts +++ b/packages/javascript/tests/catcher.test.ts @@ -5,11 +5,10 @@ import { TEST_TOKEN, wait, createTransport, getLastPayload, createCatcher } from // StackParser is mocked to prevent real network calls to source files in the jsdom environment. const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { - parse = mockParse; - }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); // ─── Tests ─────────────────────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.transport.test.ts b/packages/javascript/tests/catcher.transport.test.ts index a59f4b1e..42f760b1 100644 --- a/packages/javascript/tests/catcher.transport.test.ts +++ b/packages/javascript/tests/catcher.transport.test.ts @@ -4,9 +4,10 @@ import type { Transport } from '../src'; import { wait, createCatcher, createTransport } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/catcher.user.test.ts b/packages/javascript/tests/catcher.user.test.ts index 6f2d29a8..5b63d79f 100644 --- a/packages/javascript/tests/catcher.user.test.ts +++ b/packages/javascript/tests/catcher.user.test.ts @@ -3,9 +3,10 @@ import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('../src/modules/stackParser', () => ({ - default: class { parse = mockParse; }, -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, StackParser: class { parse = mockParse; } }; +}); describe('Catcher', () => { beforeEach(() => { diff --git a/packages/javascript/tests/modules/sanitizer.test.ts b/packages/javascript/tests/modules/sanitizer.test.ts index 7b79c6be..bbafdc23 100644 --- a/packages/javascript/tests/modules/sanitizer.test.ts +++ b/packages/javascript/tests/modules/sanitizer.test.ts @@ -1,91 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import Sanitizer from '../../src/modules/sanitizer'; +import { describe, expect, it } from 'vitest'; +import { Sanitizer } from '@hawk.so/core'; +import '../../src/modules/element-sanitizer'; -describe('Sanitizer', () => { - describe('isObject', () => { - it('should return true for a plain object', () => { - expect(Sanitizer.isObject({})).toBe(true); - }); - - it('should return false for an array', () => { - expect(Sanitizer.isObject([])).toBe(false); - }); - - it('should return false for a string', () => { - expect(Sanitizer.isObject('x')).toBe(false); - }); - - it('should return false for a boolean', () => { - expect(Sanitizer.isObject(true)).toBe(false); - }); - - it('should return false for null', () => { - expect(Sanitizer.isObject(null)).toBe(false); - }); - - it('should return false for undefined', () => { - expect(Sanitizer.isObject(undefined)).toBe(false); - }); - }); - - describe('sanitize', () => { - it('should pass through strings within the length limit', () => { - expect(Sanitizer.sanitize('hello')).toBe('hello'); - }); - - it('should trim strings longer than maxStringLen', () => { - const long = 'a'.repeat(201); - const result = Sanitizer.sanitize(long); - - expect(result).toBe('a'.repeat(200) + '…'); - }); - - it('should pass through short arrays unchanged', () => { - expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]); - }); - - it('should truncate arrays over maxArrayLength items and append placeholder', () => { - const arr = Array.from({ length: 12 }, (_, i) => i); - const result = Sanitizer.sanitize(arr); - - expect(result).toHaveLength(11); - expect(result[10]).toBe('<2 more items...>'); - }); - - it('should sanitize nested objects recursively', () => { - const longStr = 'a'.repeat(201); - const longArr = Array.from({ length: 12 }, (_, i) => i); - const obj = { - foo: 'x', - bar: longStr, - baz: longArr - } - const result = Sanitizer.sanitize(obj); - - expect(result.foo).toBe('x'); - expect(result.bar).toBe('a'.repeat(200) + '…'); - expect(result.baz).toHaveLength(11); - expect(result.baz[10]).toBe('<2 more items...>'); - }); - - it('should replace objects with more than 20 keys with placeholder', () => { - const big: Record = {}; - - for (let i = 0; i < 21; i++) { - big[`k${i}`] = i; - } - - expect(Sanitizer.sanitize(big)).toBe(''); - }); - - it('should replace deeply nested objects with placeholder', () => { - const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } }; - const result = Sanitizer.sanitize(deep); - - expect(result.a.b.c.d.e).toBe(''); - }); - - it('should format HTML elements as a string starting with tag', () => { +describe('Browser Sanitizer handlers', () => { + describe('Element handler', () => { + it('should format an empty HTML element as its outer HTML', () => { const el = document.createElement('div'); const result = Sanitizer.sanitize(el); @@ -93,34 +12,15 @@ describe('Sanitizer', () => { expect(result).toMatch(/^
"', () => { - class Foo {} - - expect(Sanitizer.sanitize(Foo)).toBe(''); - }); - - it('should format a class instance as ""', () => { - class Foo {} - - expect(Sanitizer.sanitize(new Foo())).toBe(''); - }); - - it('should replace circular references with placeholder', () => { - const obj: any = { a: 1 }; - - obj.self = obj; + it('should replace inner HTML content with ellipsis', () => { + const el = document.createElement('div'); - const result = Sanitizer.sanitize(obj); + el.innerHTML = 'some long content'; - expect(result.self).toBe(''); - }); + const result = Sanitizer.sanitize(el); - it.each([ - { label: 'number', value: 42 }, - { label: 'boolean', value: true }, - { label: 'null', value: null }, - ])('should pass through $label primitives unchanged', ({ value }) => { - expect(Sanitizer.sanitize(value)).toBe(value); + expect(result).toContain('…'); + expect(result).not.toContain('some long content'); }); }); }); From b4fb029c4b919f9c7339af1902f9820ba141624b Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 19:58:57 +0300 Subject: [PATCH 2/3] refactor(core): BreadcrumbStore abstraction and browser implementation added --- .../core/src/breadcrumbs/breadcrumb-store.ts | 27 ++++++ packages/core/src/index.ts | 1 + packages/javascript/src/addons/breadcrumbs.ts | 51 +++++----- .../{consoleCatcher.ts => console-catcher.ts} | 0 packages/javascript/src/catcher.ts | 25 ++--- .../javascript/src/types/breadcrumbs-api.ts | 11 --- packages/javascript/src/types/index.ts | 10 +- packages/javascript/tests/breadcrumbs.test.ts | 92 +++++++++---------- .../javascript/tests/catcher.addons.test.ts | 4 +- .../tests/catcher.breadcrumbs.test.ts | 4 +- .../javascript/tests/catcher.context.test.ts | 4 +- .../tests/catcher.global-handlers.test.ts | 4 +- packages/javascript/tests/catcher.test.ts | 8 +- .../tests/catcher.transport.test.ts | 4 +- .../javascript/tests/catcher.user.test.ts | 4 +- 15 files changed, 135 insertions(+), 114 deletions(-) create mode 100644 packages/core/src/breadcrumbs/breadcrumb-store.ts rename packages/javascript/src/addons/{consoleCatcher.ts => console-catcher.ts} (100%) delete mode 100644 packages/javascript/src/types/breadcrumbs-api.ts diff --git a/packages/core/src/breadcrumbs/breadcrumb-store.ts b/packages/core/src/breadcrumbs/breadcrumb-store.ts new file mode 100644 index 00000000..54e2eeec --- /dev/null +++ b/packages/core/src/breadcrumbs/breadcrumb-store.ts @@ -0,0 +1,27 @@ +import type { Breadcrumb } from '@hawk.so/types'; + +/** + * Hint passed to beforeBreadcrumb callback. + */ +export interface BreadcrumbHint { + [key: string]: unknown; +} + +/** + * Breadcrumb input type - breadcrumb data with optional timestamp. + */ +export type BreadcrumbInput = Omit & { timestamp?: number }; + +/** + * Contract for breadcrumb storage. Also serves as public breadcrumbs API. + */ +export interface BreadcrumbStore { + add(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void; + get(): Breadcrumb[]; + clear(): void; +} + +/** + * @deprecated Use {@link BreadcrumbStore} instead. + */ +export type BreadcrumbsAPI = BreadcrumbStore; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a869e00..e029b910 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,3 +11,4 @@ export type { Transport } from './transports/transport'; export { EventRejectedError } from './errors'; export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; export { isPlainObject, validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from './utils/validation'; +export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index c953e790..c243512e 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -2,6 +2,7 @@ * @file Breadcrumbs module - captures chronological trail of events before an error */ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from '@hawk.so/types'; +import type { BreadcrumbHint, BreadcrumbInput, BreadcrumbStore } from '@hawk.so/core'; import { buildElementSelector, isValidBreadcrumb, log, Sanitizer } from '@hawk.so/core'; /** @@ -10,9 +11,10 @@ import { buildElementSelector, isValidBreadcrumb, log, Sanitizer } from '@hawk.s const DEFAULT_MAX_BREADCRUMBS = 15; /** - * Hint object passed to beforeBreadcrumb callback + * Hint object passed to beforeBreadcrumb callback. + * Extends generic {@link BreadcrumbHint} with browser-specific data. */ -export interface BreadcrumbHint { +export interface BrowserBreadcrumbHint extends BreadcrumbHint { /** * Original event that triggered the breadcrumb (if any) */ @@ -51,7 +53,7 @@ export interface BreadcrumbsOptions { * - Return `false` — the breadcrumb will be discarded. * - Any other value is invalid — the original breadcrumb is stored as-is (a warning is logged). */ - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BrowserBreadcrumbHint) => Breadcrumb | false | void; /** * Enable automatic fetch/XHR breadcrumbs @@ -75,12 +77,6 @@ export interface BreadcrumbsOptions { trackClicks?: boolean; } -/** - * Breadcrumb input type - breadcrumb data with optional timestamp - * (timestamp will be auto-generated if not provided) - */ -export type BreadcrumbInput = Omit & { timestamp?: Breadcrumb['timestamp'] }; - /** * Internal breadcrumbs options - all fields except 'beforeBreadcrumb' are required * (they have default values and are always set during init) @@ -90,17 +86,18 @@ interface InternalBreadcrumbsOptions { trackFetch: boolean; trackNavigation: boolean; trackClicks: boolean; - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BrowserBreadcrumbHint) => Breadcrumb | false | void; } /** - * BreadcrumbManager - singleton that manages breadcrumb collection and storage + * Browser implementation of BreadcrumbStore. + * Singleton that manages breadcrumb collection and storage. */ -export class BreadcrumbManager { +export class BrowserBreadcrumbStore implements BreadcrumbStore { /** * Singleton instance */ - private static instance: BreadcrumbManager | null = null; + private static instance: BrowserBreadcrumbStore | null = null; /** * Breadcrumbs buffer (FIFO) @@ -167,10 +164,10 @@ export class BreadcrumbManager { /** * Get singleton instance */ - public static getInstance(): BreadcrumbManager { - BreadcrumbManager.instance ??= new BreadcrumbManager(); + public static getInstance(): BrowserBreadcrumbStore { + BrowserBreadcrumbStore.instance ??= new BrowserBreadcrumbStore(); - return BreadcrumbManager.instance; + return BrowserBreadcrumbStore.instance; } /** @@ -180,7 +177,7 @@ export class BreadcrumbManager { */ public init(options: BreadcrumbsOptions = {}): void { if (this.isInitialized) { - log('[BreadcrumbManager] init has already been called; breadcrumb configuration is global and subsequent init options are ignored.', 'warn'); + log('[BrowserBreadcrumbStore] init has already been called; breadcrumb configuration is global and subsequent init options are ignored.', 'warn'); return; } @@ -219,7 +216,7 @@ export class BreadcrumbManager { * @param hint - Optional hint object with original event data (Event, Response, XMLHttpRequest, etc.) * Used by beforeBreadcrumb callback to access original event context */ - public addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void { + public add(breadcrumb: BreadcrumbInput, hint?: BrowserBreadcrumbHint): void { /** * Ensure timestamp */ @@ -293,14 +290,14 @@ export class BreadcrumbManager { /** * Get current breadcrumbs snapshot (oldest to newest) */ - public getBreadcrumbs(): Breadcrumb[] { + public get(): Breadcrumb[] { return [ ...this.breadcrumbs ]; } /** * Clear all breadcrumbs */ - public clearBreadcrumbs(): void { + public clear(): void { this.breadcrumbs.length = 0; } @@ -358,9 +355,9 @@ export class BreadcrumbManager { this.popstateHandler = null; } - this.clearBreadcrumbs(); + this.clear(); this.isInitialized = false; - BreadcrumbManager.instance = null; + BrowserBreadcrumbStore.instance = null; } @@ -399,7 +396,7 @@ export class BreadcrumbManager { const duration = Date.now() - startTime; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'fetch', message: `${response.status} ${method} ${url}`, @@ -419,7 +416,7 @@ export class BreadcrumbManager { } catch (error) { const duration = Date.now() - startTime; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'fetch', message: `[FAIL] ${method} ${url}`, @@ -483,7 +480,7 @@ export class BreadcrumbManager { const url = this.hawkUrl || ''; const status = this.status; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'xhr', message: `${status} ${method} ${url}`, @@ -529,7 +526,7 @@ export class BreadcrumbManager { lastUrl = to; - manager.addBreadcrumb({ + manager.add({ type: 'navigation', category: 'navigation', message: `Navigated to ${to}`, @@ -599,7 +596,7 @@ export class BreadcrumbManager { */ const text = (target.textContent || target.innerText || '').trim().substring(0, 50); - manager.addBreadcrumb({ + manager.add({ type: 'ui', category: 'ui.click', message: `Click on ${selector}`, diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/console-catcher.ts similarity index 100% rename from packages/javascript/src/addons/consoleCatcher.ts rename to packages/javascript/src/addons/console-catcher.ts diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index be20245c..77dbc64c 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,6 +1,6 @@ import './modules/element-sanitizer'; import Socket from './modules/socket'; -import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; +import type { CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import type { AffectedUser, @@ -12,8 +12,9 @@ import type { VueIntegrationAddons } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; -import { ConsoleCatcher } from './addons/consoleCatcher'; -import { BreadcrumbManager } from './addons/breadcrumbs'; +import { ConsoleCatcher } from './addons/console-catcher'; +import { BrowserBreadcrumbStore } from './addons/breadcrumbs'; +import type { BreadcrumbStore } from '@hawk.so/core'; import { EventRejectedError, HawkUserManager, @@ -123,7 +124,7 @@ export default class Catcher { /** * Breadcrumb manager instance */ - private readonly breadcrumbManager: BreadcrumbManager | null; + private readonly breadcrumbStore: BrowserBreadcrumbStore | null; /** * Manages currently authenticated user identity. @@ -195,10 +196,10 @@ export default class Catcher { * Initialize breadcrumbs */ if (settings.breadcrumbs !== false) { - this.breadcrumbManager = BreadcrumbManager.getInstance(); - this.breadcrumbManager.init(settings.breadcrumbs ?? {}); + this.breadcrumbStore = BrowserBreadcrumbStore.getInstance(); + this.breadcrumbStore.init(settings.breadcrumbs ?? {}); } else { - this.breadcrumbManager = null; + this.breadcrumbStore = null; } /** @@ -297,11 +298,11 @@ export default class Catcher { * data: { userId: '123' } * }); */ - public get breadcrumbs(): BreadcrumbsAPI { + public get breadcrumbs(): BreadcrumbStore { return { - add: (breadcrumb, hint) => this.breadcrumbManager?.addBreadcrumb(breadcrumb, hint), - get: () => this.breadcrumbManager?.getBreadcrumbs() ?? [], - clear: () => this.breadcrumbManager?.clearBreadcrumbs(), + add: (breadcrumb, hint) => this.breadcrumbStore?.add(breadcrumb, hint), + get: () => this.breadcrumbStore?.get() ?? [], + clear: () => this.breadcrumbStore?.clear(), }; } @@ -578,7 +579,7 @@ export default class Catcher { * Get breadcrumbs for event payload */ private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { - const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs(); + const breadcrumbs = this.breadcrumbStore?.get(); return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : undefined; } diff --git a/packages/javascript/src/types/breadcrumbs-api.ts b/packages/javascript/src/types/breadcrumbs-api.ts deleted file mode 100644 index 777dcdf3..00000000 --- a/packages/javascript/src/types/breadcrumbs-api.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Breadcrumb } from '@hawk.so/types'; -import type { BreadcrumbInput, BreadcrumbHint } from '../addons/breadcrumbs'; - -/** - * Breadcrumbs API interface - */ -export interface BreadcrumbsAPI { - add: (breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint) => void; - get: () => Breadcrumb[]; - clear: () => void; -} diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index f3354c30..87ee0189 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -1,9 +1,14 @@ import type { CatcherMessage } from './catcher-message'; import type { HawkInitialSettings } from './hawk-initial-settings'; import type { Transport } from './transport'; +import type { BreadcrumbsAPI, BreadcrumbStore } from '@hawk.so/core'; import type { HawkJavaScriptEvent } from './event'; -import type { VueIntegrationData, NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations } from './integrations'; -import type { BreadcrumbsAPI } from './breadcrumbs-api'; +import type { + JavaScriptCatcherIntegrations, + NuxtIntegrationAddons, + NuxtIntegrationData, + VueIntegrationData +} from './integrations'; export type { CatcherMessage, @@ -14,5 +19,6 @@ export type { NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations, + BreadcrumbStore, BreadcrumbsAPI }; diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index 7afec05c..b73888c6 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import type { Breadcrumb } from '@hawk.so/types'; import * as core from '@hawk.so/core'; function resetManager(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; } -describe('BreadcrumbManager', () => { +describe('BrowserBreadcrumbStore', () => { let logSpy: ReturnType; beforeEach(() => { @@ -21,19 +21,19 @@ describe('BreadcrumbManager', () => { }); it('should return empty array when no breadcrumbs added', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - expect(m.getBreadcrumbs()).toEqual([]); + expect(m.get()).toEqual([]); }); it('should store breadcrumb with auto-generated timestamp', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + m.add({ type: 'default', message: 'test', level: 'info' }); - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(1); expect(crumbs[0].message).toBe('test'); @@ -41,24 +41,24 @@ describe('BreadcrumbManager', () => { }); it('should keep explicit timestamp as-is', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info', timestamp: 12345 }); + m.add({ type: 'default', message: 'test', level: 'info', timestamp: 12345 }); - expect(m.getBreadcrumbs()[0].timestamp).toBe(12345); + expect(m.get()[0].timestamp).toBe(12345); }); it('should drop oldest breadcrumbs when buffer overflows (FIFO)', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ maxBreadcrumbs: 3 }); for (let i = 0; i < 5; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(3); expect(crumbs[0].message).toBe('msg-2'); @@ -66,55 +66,55 @@ describe('BreadcrumbManager', () => { }); it('should store max 15 breadcrumbs by default', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); for (let i = 0; i < 20; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - expect(m.getBreadcrumbs()).toHaveLength(15); + expect(m.get()).toHaveLength(15); }); it('should empty buffer on clear', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); - m.clearBreadcrumbs(); + m.add({ type: 'default', message: 'test', level: 'info' }); + m.clear(); - expect(m.getBreadcrumbs()).toEqual([]); + expect(m.get()).toEqual([]); }); it('should return a copy, not the internal array', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + m.add({ type: 'default', message: 'test', level: 'info' }); - const first = m.getBreadcrumbs(); - const second = m.getBreadcrumbs(); + const first = m.get(); + const second = m.get(); expect(first).not.toBe(second); expect(first).toEqual(second); first.push({ type: 'default', message: 'injected', level: 'info', timestamp: 0 } as Breadcrumb); - expect(m.getBreadcrumbs()).toHaveLength(1); + expect(m.get()).toHaveLength(1); }); it('should ignore second init call', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ maxBreadcrumbs: 5 }); m.init({ maxBreadcrumbs: 100 }); for (let i = 0; i < 10; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - expect(m.getBreadcrumbs()).toHaveLength(5); + expect(m.get()).toHaveLength(5); }); }); @@ -132,7 +132,7 @@ describe('beforeBreadcrumb', () => { it('should store modified breadcrumb when hook returns changed object', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -143,25 +143,25 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); + m.add({ type: 'default', message: 'original', level: 'info' }); // Assert - expect(m.getBreadcrumbs()[0].message).toBe('MODIFIED'); + expect(m.get()[0].message).toBe('MODIFIED'); }); it('should not store breadcrumb when hook returns false', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb: () => false, }); // Act - m.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); + m.add({ type: 'default', message: 'drop', level: 'info' }); // Assert - expect(m.getBreadcrumbs()).toHaveLength(0); + expect(m.get()).toHaveLength(0); }); it.each([ @@ -172,7 +172,7 @@ describe('beforeBreadcrumb', () => { { label: 'true', value: true }, ])('should store original breadcrumb and warn when hook returns $label', ({ value }) => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -180,10 +180,10 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); + m.add({ type: 'default', message: 'original', level: 'info' }); // Assert - expect(m.getBreadcrumbs()[0].message).toBe('original'); + expect(m.get()[0].message).toBe('original'); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid beforeBreadcrumb value'), 'warn' @@ -192,7 +192,7 @@ describe('beforeBreadcrumb', () => { it('should store original breadcrumb and warn when hook deletes required field (message)', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -204,15 +204,15 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'keep-me', level: 'info' }); + m.add({ type: 'default', message: 'keep-me', level: 'info' }); // Assert — fallback to original, message preserved - expect(m.getBreadcrumbs()[0].message).toBe('keep-me'); + expect(m.get()[0].message).toBe('keep-me'); }); it('should filter breadcrumbs by category using hook', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -221,11 +221,11 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'public', level: 'info', category: 'public' }); - m.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); + m.add({ type: 'default', message: 'public', level: 'info', category: 'public' }); + m.add({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); // Assert - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(1); expect(crumbs[0].message).toBe('public'); diff --git a/packages/javascript/tests/catcher.addons.test.ts b/packages/javascript/tests/catcher.addons.test.ts index 09909361..e64aeddf 100644 --- a/packages/javascript/tests/catcher.addons.test.ts +++ b/packages/javascript/tests/catcher.addons.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── Environment addons ──────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.breadcrumbs.test.ts b/packages/javascript/tests/catcher.breadcrumbs.test.ts index 8c065f6c..b0d92cab 100644 --- a/packages/javascript/tests/catcher.breadcrumbs.test.ts +++ b/packages/javascript/tests/catcher.breadcrumbs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── Breadcrumbs trail ───────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.context.test.ts b/packages/javascript/tests/catcher.context.test.ts index 062ef2bb..aab3ffa3 100644 --- a/packages/javascript/tests/catcher.context.test.ts +++ b/packages/javascript/tests/catcher.context.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── Context enrichment ──────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index e5936152..dacea5d7 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Catcher from '../src/catcher'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { TEST_TOKEN, wait, createTransport, getLastPayload } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -13,7 +13,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── Global error handlers ───────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts index ffc918c4..9479913b 100644 --- a/packages/javascript/tests/catcher.test.ts +++ b/packages/javascript/tests/catcher.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Catcher from '../src/catcher'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; -import { TEST_TOKEN, wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; +import { createCatcher, createTransport, getLastPayload, TEST_TOKEN, wait } from './catcher.helpers'; // StackParser is mocked to prevent real network calls to source files in the jsdom environment. const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -16,7 +16,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── Constructor variants ────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.transport.test.ts b/packages/javascript/tests/catcher.transport.test.ts index 42f760b1..11991dcf 100644 --- a/packages/javascript/tests/catcher.transport.test.ts +++ b/packages/javascript/tests/catcher.transport.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import type { Transport } from '../src'; import { wait, createCatcher, createTransport } from './catcher.helpers'; @@ -13,7 +13,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); describe('transport failure', () => { diff --git a/packages/javascript/tests/catcher.user.test.ts b/packages/javascript/tests/catcher.user.test.ts index 5b63d79f..85c9ca00 100644 --- a/packages/javascript/tests/catcher.user.test.ts +++ b/packages/javascript/tests/catcher.user.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance = null; }); // ── User identity ───────────────────────────────────────────────────────── From 8b9a833012d820a178c3d3d7f1b30bdab5126b9c Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 20:13:38 +0300 Subject: [PATCH 3/3] refactor(core): Plugin interface and browser/vue plugins added --- packages/core/src/catchers/plugin.ts | 47 ++++ packages/core/src/index.ts | 1 + .../javascript/src/plugins/browser-addons.ts | 119 ++++++++++ packages/javascript/src/plugins/vue.ts | 217 ++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 packages/core/src/catchers/plugin.ts create mode 100644 packages/javascript/src/plugins/browser-addons.ts create mode 100644 packages/javascript/src/plugins/vue.ts diff --git a/packages/core/src/catchers/plugin.ts b/packages/core/src/catchers/plugin.ts new file mode 100644 index 00000000..789d8add --- /dev/null +++ b/packages/core/src/catchers/plugin.ts @@ -0,0 +1,47 @@ +import type { CatcherMessagePayload, CatcherMessageType } from '@hawk.so/types'; +import type { BreadcrumbStore } from '../breadcrumbs/breadcrumb-store'; + +/** + * Plugin interface for extending HawkCatcher functionality. + * + * Plugins can: + * - run setup logic when the catcher initializes ({@link setup}) + * - enrich or drop events in the send pipeline ({@link processEvent}) + * - receive the breadcrumb store to add breadcrumbs ({@link setupBreadcrumbs}) + */ +export interface HawkCatcherPlugin { + /** + * Unique plugin name + */ + readonly name: string; + + /** + * Called once when the catcher is constructed. + * Use this to set up error handlers, interceptors, etc. + * + * @param catcher - the catcher instance that owns this plugin + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setup?(catcher: any): void; + + /** + * Called after the catcher is set up with a reference to breadcrumb store. + * Use this to add breadcrumbs from your integration. + * + * @param store - active {@link BreadcrumbStore} + */ + setupBreadcrumbs?(store: BreadcrumbStore): void; + + /** + * Handles event before it is sent. + * + * @param event - event payload + * @param hint - extra data provided by the capture call + * @return either possibly mutated event to continue the pipeline + * or `null` to drop event silently + */ + processEvent?( + event: CatcherMessagePayload, + hint: Record + ): CatcherMessagePayload | null | Promise | null>; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e029b910..6d238f98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export { EventRejectedError } from './errors'; export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; export { isPlainObject, validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from './utils/validation'; export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; +export type { HawkCatcherPlugin } from './catchers/plugin'; diff --git a/packages/javascript/src/plugins/browser-addons.ts b/packages/javascript/src/plugins/browser-addons.ts new file mode 100644 index 00000000..b2a3c729 --- /dev/null +++ b/packages/javascript/src/plugins/browser-addons.ts @@ -0,0 +1,119 @@ +import type { CatcherMessagePayload } from '@hawk.so/types'; +import type { HawkCatcherPlugin } from '@hawk.so/core'; +import type { JavaScriptAddons } from '@hawk.so/types'; +import type { Json } from '@hawk.so/types'; +import { ConsoleCatcher } from '../addons/console-catcher'; + +/** + * Plugin that collects browser-specific addons (window size, userAgent, URL, GET params, + * console logs) and attaches them to outgoing event. + */ +export class BrowserAddonsPlugin implements HawkCatcherPlugin<'errors/javascript'> { + + public readonly name = 'browser-addons'; + + /** + * Whether to include console logs in the addons + */ + private readonly consoleTracking: boolean; + + /** + * Whether to include raw event data in the addons (debug mode) + */ + private readonly debug: boolean; + + /** + * Console catcher instance (initialized in setup) + */ + private consoleCatcher: ConsoleCatcher | null = null; + + constructor(options: { consoleTracking?: boolean; debug?: boolean } = {}) { + this.consoleTracking = options.consoleTracking ?? true; + this.debug = options.debug ?? false; + } + + /** + * Initialize the console catcher if console tracking is enabled + */ + public setup(): void { + if (this.consoleTracking) { + this.consoleCatcher = ConsoleCatcher.getInstance(); + this.consoleCatcher.init(); + } + } + + /** + * Attach browser addons to the event payload + */ + public processEvent( + event: CatcherMessagePayload<'errors/javascript'>, + hint: Record + ): CatcherMessagePayload<'errors/javascript'> { + const { innerWidth, innerHeight } = window; + const userAgent = window.navigator.userAgent; + const location = window.location.href; + const getParams = this.getGetParams(); + const consoleLogs = this.consoleTracking && this.consoleCatcher?.getConsoleLogStack(); + + const addons: JavaScriptAddons = { + ...(event.addons as JavaScriptAddons | undefined), + window: { innerWidth, innerHeight }, + userAgent, + url: location, + }; + + if (getParams) { + addons.get = getParams; + } + + if (this.debug) { + const error = hint['__error__']; + + if (error instanceof Error || typeof error === 'string') { + addons.RAW_EVENT_DATA = this.getRawData(error); + } + } + + if (consoleLogs && consoleLogs.length > 0) { + addons.consoleOutput = consoleLogs; + } + + return { ...event, addons }; + } + + /** + * Parse URL GET parameters into a key-value object + */ + private getGetParams(): Json | null { + const searchString = window.location.search.substring(1); + + if (!searchString) { + return null; + } + + const pairs = searchString.split('&'); + + return pairs.reduce>((acc, pair) => { + const [key, value] = pair.split('='); + + acc[key] = value; + + return acc; + }, {}); + } + + /** + * Build a raw data object from an Error for debug purposes + */ + private getRawData(error: Error | string): Json | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + return { + name: error.name, + message: error.message, + stack: error.stack ?? '', + }; + } +} diff --git a/packages/javascript/src/plugins/vue.ts b/packages/javascript/src/plugins/vue.ts new file mode 100644 index 00000000..69d1d700 --- /dev/null +++ b/packages/javascript/src/plugins/vue.ts @@ -0,0 +1,217 @@ +import type { CatcherMessagePayload } from '@hawk.so/types'; +import type { VueIntegrationAddons, JsonNode } from '@hawk.so/types'; +import type { HawkCatcherPlugin } from '@hawk.so/core'; + +/** + * Plugin that integrates with Vue 2 / Vue 3 error handling. + */ +export class VuePlugin implements HawkCatcherPlugin<'errors/javascript'> { + public readonly name = 'vue'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly vue: any; + + /** + * Whether to suppress the original Vue error handler + */ + private readonly disableVueErrorHandler: boolean; + + /** + * Saved original error handler (to chain calls) + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private existedHandler: any = null; + + /** + * @param vue - Vue app instance (Vue 2 or Vue 3) + * @param options - plugin options + */ + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vue: any, + options: { disableVueErrorHandler?: boolean } = {} + ) { + this.vue = vue; + this.disableVueErrorHandler = options.disableVueErrorHandler ?? false; + } + + /** + * Install Vue error handler + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public setup(catcher: any): void { + this.existedHandler = this.vue.config.errorHandler; + + if (this.disableVueErrorHandler) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const plugin = this; + + this.vue.config.errorHandler = ( + err: Error, + vm: { [key: string]: unknown }, + info: string + ): void => { + if (typeof plugin.existedHandler === 'function') { + plugin.existedHandler.call(plugin.vue, err, vm, info); + } + + const addons = plugin.spoilAddons(vm, info); + + plugin.printError(err, info, addons.component); + + catcher.captureError(err, { vue: addons }); + }; + } + + /** + * Attach Vue addons from hint to the event + */ + public processEvent( + event: CatcherMessagePayload<'errors/javascript'>, + hint: Record + ): CatcherMessagePayload<'errors/javascript'> { + if (hint['vue']) { + return { + ...event, + addons: { + ...(event.addons ?? {}), + vue: hint['vue'] as VueIntegrationAddons, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; + } + + return event; + } + + /** + * Extract Vue-specific addons from the component instance + */ + public spoilAddons(vm: { [key: string]: unknown }, info: string): VueIntegrationAddons { + const isVue3 = vm.$ !== undefined; + + if (isVue3) { + return this.spoilAddonsFromVue3(vm, info); + } + + return this.spoilAddonsFromVue2(vm, info); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private spoilAddonsFromVue2(vm: { [key: string]: any }, info: string): VueIntegrationAddons { + const addons: VueIntegrationAddons = { + lifecycle: info, + component: null, + }; + + if (vm.$root === vm) { + addons.component = vm.$el.outerHTML.replace(/>.*/, '>') + ' (root)'; + } else { + addons.component = '<' + (vm._isVue ? vm.$options.name || vm.$options._componentTag : vm.name) + '>'; + } + + if (vm.$options && vm.$options.propsData) { + addons.props = vm.$options.propsData; + } + + if (vm._data) { + addons.data = {}; + + Object.entries(vm._data).forEach(([key, value]) => { + addons.data![key] = value as JsonNode; + }); + } + + if (vm._computedWatchers) { + addons.computed = {}; + + Object.entries(vm._computedWatchers).forEach(([key, watcher]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addons.computed![key] = (watcher as { [key: string]: any }).value; + }); + } + + return addons; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private spoilAddonsFromVue3(vm: { [key: string]: any }, info: string): VueIntegrationAddons { + const addons: VueIntegrationAddons = { + lifecycle: this.getRuntimeErrorSourceByCode(info), + component: null, + }; + + if (vm.$options !== undefined) { + addons.component = `<${vm.$options.__name || vm.$options.name || vm.$options._componentTag || 'Anonymous'}>`; + } + + if (Object.keys(vm.$props).length) { + addons.props = vm.$props; + } + + return addons; + } + + /** + * Decode Vue 3 production error code to a human-readable string + */ + private getRuntimeErrorSourceByCode(code: string): string { + if (!code.includes('https://vuejs.org/error-reference/#runtime-')) { + return code; + } + + const codeParts = code.split('https://vuejs.org/error-reference/#runtime-'); + const errorCode = codeParts[codeParts.length - 1]; + + const errorCodeMap = new Map([ + ['0', 'setup function'], + ['1', 'render function'], + ['2', 'watcher getter'], + ['3', 'watcher callback'], + ['4', 'watcher cleanup function'], + ['5', 'native event handler'], + ['6', 'component event handler'], + ['7', 'vnode hook'], + ['8', 'directive hook'], + ['9', 'transition hook'], + ['10', 'app errorHandler'], + ['11', 'app warnHandler'], + ['12', 'ref function'], + ['13', 'async component loader'], + ['14', 'scheduler flush'], + ['15', 'component update'], + ['16', 'app unmount cleanup function'], + ['sp', 'serverPrefetch hook'], + ['bc', 'beforeCreate hook'], + ['c', 'created hook'], + ['bm', 'beforeMount hook'], + ['m', 'mounted hook'], + ['bu', 'beforeUpdate hook'], + ['u', 'updated'], + ['bum', 'beforeUnmount hook'], + ['um', 'unmounted hook'], + ['a', 'activated hook'], + ['da', 'deactivated hook'], + ['ec', 'errorCaptured hook'], + ['rtc', 'renderTracked hook'], + ['rtg', 'renderTriggered hook'], + ]); + + return errorCodeMap.get(errorCode) || code; + } + + private printError(err: Error, info: string, component: string | null): void { + const source = this.getRuntimeErrorSourceByCode(info); + + if (component === null) { + console.error(`${source}`, err); + + return; + } + + console.error(`${component} @ ${source}`, err); + } +}