From 88436ca12c4323c133b23d498c334f8e04d40c0b Mon Sep 17 00:00:00 2001 From: Reversean Date: Thu, 12 Feb 2026 20:59:50 +0300 Subject: [PATCH 1/5] refactor(core): HawkUserManager added --- .github/workflows/main.yml | 4 +- package.json | 2 + packages/core/package.json | 12 +++- packages/core/src/index.ts | 1 + packages/core/src/users/hawk-user-manager.ts | 71 ++++++++++++++++++ .../tests/users/hawk-user-manager.test.ts | 72 +++++++++++++++++++ packages/core/tsconfig.test.json | 13 ++++ packages/core/vitest.config.ts | 15 ++++ packages/javascript/src/catcher.ts | 57 ++++++--------- .../src/storages/hawk-local-storage.ts | 2 +- packages/javascript/vite.config.ts | 1 + packages/javascript/vitest.config.ts | 1 + yarn.lock | 3 + 13 files changed, 216 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/users/hawk-user-manager.ts create mode 100644 packages/core/tests/users/hawk-user-manager.test.ts create mode 100644 packages/core/tsconfig.test.json create mode 100644 packages/core/vitest.config.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fade8851..1881dea1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,13 +22,15 @@ jobs: CI_JOB_NUMBER: 2 steps: - uses: actions/checkout@v1 + with: + fetch-depth: 0 - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - run: corepack enable - run: yarn install - - run: yarn workspace @hawk.so/javascript test + - run: yarn test:modified origin/${{ github.event.pull_request.base.ref }} build: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 01c1ec1d..d59466ec 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev": "yarn workspace @hawk.so/javascript dev", "build:all": "yarn workspaces foreach -Apt run build", "build:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run build", + "test:all": "yarn workspaces foreach -Apt run test", + "test:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run test", "stats": "yarn workspace @hawk.so/javascript stats", "lint": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js --fix", "lint-test": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js" diff --git a/packages/core/package.json b/packages/core/package.json index e06be1d4..6797c920 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,7 +17,10 @@ } }, "scripts": { - "build": "vite build" + "build": "vite build", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, "repository": { "type": "git", @@ -33,8 +36,13 @@ "url": "https://github.com/codex-team/hawk.javascript/issues" }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", + "dependencies": { + "@hawk.so/types": "0.5.8" + }, "devDependencies": { + "@vitest/coverage-v8": "^4.0.18", "vite": "^7.3.1", - "vite-plugin-dts": "^4.2.4" + "vite-plugin-dts": "^4.2.4", + "vitest": "^4.0.18" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a701ebc..1a05cf1f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,2 @@ export type { HawkStorage } from './storages/hawk-storage'; +export { HawkUserManager } from './users/hawk-user-manager'; diff --git a/packages/core/src/users/hawk-user-manager.ts b/packages/core/src/users/hawk-user-manager.ts new file mode 100644 index 00000000..c6b9e73f --- /dev/null +++ b/packages/core/src/users/hawk-user-manager.ts @@ -0,0 +1,71 @@ +import type { AffectedUser } from '@hawk.so/types'; +import type { HawkStorage } from '../storages/hawk-storage'; + +/** + * Storage key used to persist the auto-generated user ID. + */ +export const SESSION_STORAGE_KEY = 'hawk-user-id'; + +/** + * Manages the affected user identity. + * + * Manually provided users are kept in memory only (they don't change restarts). + * {@link HawkStorage} is used solely to persist the auto-generated ID + * so it survives across sessions. + */ +export class HawkUserManager { + /** + * In-memory user set explicitly via {@link setUser}. + */ + private user: AffectedUser | null = null; + + /** + * Underlying storage used to persist auto-generated user ID. + */ + private readonly storage: HawkStorage; + + /** + * @param storage - Storage backend to use for persistence. + */ + constructor(storage: HawkStorage) { + this.storage = storage; + } + + /** + * Returns the current affected user, or `null` if none is available. + * + * Priority: in-memory user > persisted user ID. + */ + public getUser(): AffectedUser | null { + if (this.user) { + return this.user; + } + const storedId = this.storage.getItem(SESSION_STORAGE_KEY); + return storedId ? { id: storedId } : null; + } + + /** + * Sets the user explicitly (in memory only). + * + * @param user - The affected user provided by the application. + */ + public setUser(user: AffectedUser): void { + this.user = user; + } + + /** + * Persists an auto-generated user ID to storage. + * + * @param id - The generated ID to persist. + */ + public persistGeneratedId(id: string): void { + this.storage.setItem(SESSION_STORAGE_KEY, id); + } + + /** + * Clears the explicitly set user, falling back to the persisted user ID. + */ + public clear(): void { + this.user = null; + } +} diff --git a/packages/core/tests/users/hawk-user-manager.test.ts b/packages/core/tests/users/hawk-user-manager.test.ts new file mode 100644 index 00000000..3ef9a72f --- /dev/null +++ b/packages/core/tests/users/hawk-user-manager.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HawkUserManager } from '../../src'; +import type { HawkStorage } from '../../src'; + +describe('HawkUserManager', () => { + let storage: HawkStorage; + let manager: HawkUserManager; + + beforeEach(() => { + storage = { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + manager = new HawkUserManager(storage); + }); + + it('should return null when no user is set and storage is empty', () => { + expect(manager.getUser()).toBeNull(); + }); + + it('should return in-memory user set via setUser()', () => { + const user = { id: 'user-1', name: 'Ryan Gosling', url: 'https://example.com', photo: 'https://example.com/photo.png' }; + + manager.setUser(user); + + expect(manager.getUser()).toEqual(user); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('should not touch storage when setUser() is called', () => { + manager.setUser({ id: 'user-1' }); + + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + }); + + it('should return anonymous user from storage when no in-memory user is set', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + + expect(manager.getUser()).toEqual({ id: 'anon-123' }); + expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id'); + }); + + it('should prefer in-memory user over persisted anonymous ID', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + manager.setUser({ id: 'explicit-user' }); + + expect(manager.getUser()).toEqual({ id: 'explicit-user' }); + }); + + it('should persist anonymous ID via persistGeneratedId()', () => { + manager.persistGeneratedId('anon-456'); + + expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', 'anon-456'); + }); + + it('should clear in-memory user and fall back to persisted anonymous ID', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + manager.setUser({ id: 'user-1' }); + manager.clear(); + + expect(manager.getUser()).toEqual({ id: 'anon-123' }); + }); + + it('should return null after clear() when no anonymous ID is persisted', () => { + manager.setUser({ id: 'user-1' }); + manager.clear(); + + expect(manager.getUser()).toBeNull(); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 00000000..38db19a8 --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": null, + "declaration": false, + "types": ["vitest/globals"] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts" + ] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..aee26683 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['tests/**/*.test.ts'], + typecheck: { + tsconfig: './tsconfig.test.json', + }, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + }, + }, +}); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b18d8683..63bcb29b 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -4,7 +4,6 @@ import log from './utils/log'; import StackParser from './modules/stackParser'; import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; import { VueIntegration } from './integrations/vue'; -import { id } from './utils/id'; import type { AffectedUser, EventContext, @@ -19,6 +18,9 @@ import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; +import { HawkUserManager } from '@hawk.so/core'; +import { HawkLocalStorage } from './storages/hawk-local-storage'; +import { id } from './utils/id'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -62,11 +64,6 @@ export default class Catcher { */ private readonly release: string | undefined; - /** - * Current authenticated user - */ - private user: AffectedUser; - /** * Any additional data passed by user for sending with all messages */ @@ -111,6 +108,11 @@ export default class Catcher { */ private readonly breadcrumbManager: BreadcrumbManager | null; + /** + * Current authenticated user manager instance + */ + private readonly userManager: HawkUserManager = new HawkUserManager(new HawkLocalStorage()); + /** * Catcher constructor * @@ -126,7 +128,9 @@ export default class Catcher { this.token = settings.token; this.debug = settings.debug || false; this.release = settings.release !== undefined ? String(settings.release) : undefined; - this.setUser(settings.user || Catcher.getGeneratedUser()); + if (settings.user) { + this.setUser(settings.user); + } this.setContext(settings.context || undefined); this.beforeSend = settings.beforeSend; this.disableVueErrorHandler = @@ -189,27 +193,6 @@ export default class Catcher { } } - /** - * Generates user if no one provided via HawkCatcher settings - * After generating, stores user for feature requests - */ - private static getGeneratedUser(): AffectedUser { - let userId: string; - const LOCAL_STORAGE_KEY = 'hawk-user-id'; - const storedId = localStorage.getItem(LOCAL_STORAGE_KEY); - - if (storedId) { - userId = storedId; - } else { - userId = id(); - localStorage.setItem(LOCAL_STORAGE_KEY, userId); - } - - return { - id: userId, - }; - } - /** * Send test event from client */ @@ -272,14 +255,14 @@ export default class Catcher { return; } - this.user = user; + this.userManager.setUser(user); } /** - * Clear current user information (revert to generated user) + * Clear current user information */ public clearUser(): void { - this.user = Catcher.getGeneratedUser(); + this.userManager.clear(); } /** @@ -565,10 +548,16 @@ export default class Catcher { } /** - * Current authenticated user + * Returns the current user if set, otherwise generates and persists an anonymous ID. */ - private getUser(): HawkJavaScriptEvent['user'] { - return this.user || null; + private getUser(): AffectedUser { + const user = this.userManager.getUser(); + if (user) { + return user; + } + const generatedId = id(); + this.userManager.persistGeneratedId(generatedId); + return { id: generatedId }; } /** diff --git a/packages/javascript/src/storages/hawk-local-storage.ts b/packages/javascript/src/storages/hawk-local-storage.ts index c0362ea9..68c2c201 100644 --- a/packages/javascript/src/storages/hawk-local-storage.ts +++ b/packages/javascript/src/storages/hawk-local-storage.ts @@ -1,5 +1,5 @@ import type { HawkStorage } from '@hawk.so/core'; -import log from '../utils/log.ts'; +import log from '../utils/log'; /** * {@link HawkStorage} implementation backed by the browser's {@linkcode localStorage}. diff --git a/packages/javascript/vite.config.ts b/packages/javascript/vite.config.ts index 47fe52e7..65b622e0 100644 --- a/packages/javascript/vite.config.ts +++ b/packages/javascript/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(() => { fileName: 'hawk', }, rollupOptions: { + external: ['@hawk.so/core'], plugins: [ license({ thirdParty: { diff --git a/packages/javascript/vitest.config.ts b/packages/javascript/vitest.config.ts index 47ad6a2c..68e2cda4 100644 --- a/packages/javascript/vitest.config.ts +++ b/packages/javascript/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ alias: { '@/types': path.resolve(__dirname, './src/types'), }, + conditions: ['source'], }, }); diff --git a/yarn.lock b/yarn.lock index a2aacdff..65b88ef1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -587,8 +587,11 @@ __metadata: version: 0.0.0-use.local resolution: "@hawk.so/core@workspace:packages/core" dependencies: + "@hawk.so/types": "npm:0.5.8" + "@vitest/coverage-v8": "npm:^4.0.18" vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.2.4" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft From f727fc03b821645e73bcb41c7faeaf6845e40ec6 Mon Sep 17 00:00:00 2001 From: Reversean Date: Sat, 7 Mar 2026 17:56:00 +0300 Subject: [PATCH 2/5] refactor(core): anonymous user id generation moved to core --- packages/core/src/index.ts | 1 + packages/core/src/users/hawk-user-manager.ts | 49 +++++++++++++------ packages/core/src/utils/id.ts | 16 ++++++ packages/core/src/utils/random.ts | 13 +++++ .../tests/users/hawk-user-manager.test.ts | 30 +++++++----- packages/javascript/src/catcher.ts | 28 +++++------ .../src/storages/hawk-local-storage.ts | 1 + packages/javascript/src/utils/random.ts | 19 +++++++ 8 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/utils/id.ts create mode 100644 packages/core/src/utils/random.ts create mode 100644 packages/javascript/src/utils/random.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a05cf1f..d63e1f56 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export type { HawkStorage } from './storages/hawk-storage'; +export type { RandomGenerator } from './utils/random'; export { HawkUserManager } from './users/hawk-user-manager'; diff --git a/packages/core/src/users/hawk-user-manager.ts b/packages/core/src/users/hawk-user-manager.ts index c6b9e73f..b280faaf 100644 --- a/packages/core/src/users/hawk-user-manager.ts +++ b/packages/core/src/users/hawk-user-manager.ts @@ -1,10 +1,12 @@ import type { AffectedUser } from '@hawk.so/types'; import type { HawkStorage } from '../storages/hawk-storage'; +import { id } from '../utils/id'; +import type { RandomGenerator } from '../utils/random'; /** * Storage key used to persist the auto-generated user ID. */ -export const SESSION_STORAGE_KEY = 'hawk-user-id'; +const SESSION_STORAGE_KEY = 'hawk-user-id'; /** * Manages the affected user identity. @@ -12,6 +14,9 @@ export const SESSION_STORAGE_KEY = 'hawk-user-id'; * Manually provided users are kept in memory only (they don't change restarts). * {@link HawkStorage} is used solely to persist the auto-generated ID * so it survives across sessions. + * + * @remarks changes to user data in storage from outside manager are not tracked; + * for changes to take effect call {@link clear}. */ export class HawkUserManager { /** @@ -25,23 +30,44 @@ export class HawkUserManager { private readonly storage: HawkStorage; /** - * @param storage - Storage backend to use for persistence. + * Random generator used to produce anonymous user IDs. + */ + private readonly randomGenerator: RandomGenerator; + + /** + * @param storage - storage backend to use for persistence + * @param randomGenerator - utilities related to RandomGenerator generated values */ - constructor(storage: HawkStorage) { + constructor( + storage: HawkStorage, + randomGenerator: RandomGenerator + ) { this.storage = storage; + this.randomGenerator = randomGenerator; } /** - * Returns the current affected user, or `null` if none is available. + * Returns current affected user if set, otherwise generates and persists an anonymous ID. * * Priority: in-memory user > persisted user ID. + * + * @returns set affected user or user with generated ID */ - public getUser(): AffectedUser | null { + public getUser(): AffectedUser { if (this.user) { return this.user; } - const storedId = this.storage.getItem(SESSION_STORAGE_KEY); - return storedId ? { id: storedId } : null; + + let storedId = this.storage.getItem(SESSION_STORAGE_KEY); + + if (!storedId) { + storedId = id(this.randomGenerator); + this.storage.setItem(SESSION_STORAGE_KEY, storedId); + } + + this.user = { id: storedId }; + + return this.user!; } /** @@ -53,15 +79,6 @@ export class HawkUserManager { this.user = user; } - /** - * Persists an auto-generated user ID to storage. - * - * @param id - The generated ID to persist. - */ - public persistGeneratedId(id: string): void { - this.storage.setItem(SESSION_STORAGE_KEY, id); - } - /** * Clears the explicitly set user, falling back to the persisted user ID. */ diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts new file mode 100644 index 00000000..ee3823dd --- /dev/null +++ b/packages/core/src/utils/id.ts @@ -0,0 +1,16 @@ +import type { RandomGenerator } from './random'; + +/** + * Returns random string + * + * @param random + */ +export function id(random: RandomGenerator): string { + const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + const randomSequence = random + .getRandomNumbers(40) + .map(x => validChars.charCodeAt(x % validChars.length)); + + return String.fromCharCode.apply(null, randomSequence); +} diff --git a/packages/core/src/utils/random.ts b/packages/core/src/utils/random.ts new file mode 100644 index 00000000..e8abd358 --- /dev/null +++ b/packages/core/src/utils/random.ts @@ -0,0 +1,13 @@ +/** + * Abstraction over random value generator. + * Allows platform-specific implementations to be injected wherever random values are needed. + */ +export interface RandomGenerator { + /** + * Generates sequence of random unsigned numbers. + * + * @param length - Length of generated sequence. + * @returns Array filled with random unsigned numbers. + */ + getRandomNumbers(length: number): Uint8Array; +} diff --git a/packages/core/tests/users/hawk-user-manager.test.ts b/packages/core/tests/users/hawk-user-manager.test.ts index 3ef9a72f..3fc80231 100644 --- a/packages/core/tests/users/hawk-user-manager.test.ts +++ b/packages/core/tests/users/hawk-user-manager.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { HawkUserManager } from '../../src'; -import type { HawkStorage } from '../../src'; +import type { HawkStorage, RandomGenerator } from '../../src'; describe('HawkUserManager', () => { let storage: HawkStorage; + let randomGenerator: RandomGenerator; let manager: HawkUserManager; beforeEach(() => { @@ -12,11 +13,17 @@ describe('HawkUserManager', () => { setItem: vi.fn(), removeItem: vi.fn(), }; - manager = new HawkUserManager(storage); + randomGenerator = { + getRandomNumbers: vi.fn().mockReturnValue(new Uint8Array(40).fill(42)), + }; + manager = new HawkUserManager(storage, randomGenerator); }); - it('should return null when no user is set and storage is empty', () => { - expect(manager.getUser()).toBeNull(); + it('should return anonymous ID when no user is set and no ID is persisted', () => { + const user = manager.getUser(); + + expect(user.id).toBeTruthy(); + expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id); }); it('should return in-memory user set via setUser()', () => { @@ -28,7 +35,7 @@ describe('HawkUserManager', () => { expect(storage.setItem).not.toHaveBeenCalled(); }); - it('should not touch storage when setUser() is called', () => { + it('should not affect storage when setUser() is called', () => { manager.setUser({ id: 'user-1' }); expect(storage.setItem).not.toHaveBeenCalled(); @@ -49,12 +56,6 @@ describe('HawkUserManager', () => { expect(manager.getUser()).toEqual({ id: 'explicit-user' }); }); - it('should persist anonymous ID via persistGeneratedId()', () => { - manager.persistGeneratedId('anon-456'); - - expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', 'anon-456'); - }); - it('should clear in-memory user and fall back to persisted anonymous ID', () => { vi.mocked(storage.getItem).mockReturnValue('anon-123'); manager.setUser({ id: 'user-1' }); @@ -63,10 +64,13 @@ describe('HawkUserManager', () => { expect(manager.getUser()).toEqual({ id: 'anon-123' }); }); - it('should return null after clear() when no anonymous ID is persisted', () => { + it('should return new anonymous ID after clear() when no ID is persisted', () => { manager.setUser({ id: 'user-1' }); manager.clear(); - expect(manager.getUser()).toBeNull(); + const user = manager.getUser(); + + expect(user.id).toBeTruthy(); + expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id); }); }); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 63bcb29b..936f8d1f 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -2,25 +2,26 @@ import Socket from './modules/socket'; import Sanitizer from './modules/sanitizer'; import log from './utils/log'; import StackParser from './modules/stackParser'; -import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; +import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import type { AffectedUser, + DecodedIntegrationToken, + EncodedIntegrationToken, EventContext, JavaScriptAddons, - VueIntegrationAddons, - Json, EncodedIntegrationToken, DecodedIntegrationToken + Json, + VueIntegrationAddons } 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 { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; +import { isValidEventPayload, validateContext, validateUser } from './utils/validation'; import { HawkUserManager } from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; -import { id } from './utils/id'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -109,9 +110,12 @@ export default class Catcher { private readonly breadcrumbManager: BreadcrumbManager | null; /** - * Current authenticated user manager instance + * Manages currently authenticated user identity. */ - private readonly userManager: HawkUserManager = new HawkUserManager(new HawkLocalStorage()); + private readonly userManager: HawkUserManager = new HawkUserManager( + new HawkLocalStorage(), + new BrowserRandomGenerator() + ); /** * Catcher constructor @@ -551,13 +555,7 @@ export default class Catcher { * Returns the current user if set, otherwise generates and persists an anonymous ID. */ private getUser(): AffectedUser { - const user = this.userManager.getUser(); - if (user) { - return user; - } - const generatedId = id(); - this.userManager.persistGeneratedId(generatedId); - return { id: generatedId }; + return this.userManager.getUser(); } /** diff --git a/packages/javascript/src/storages/hawk-local-storage.ts b/packages/javascript/src/storages/hawk-local-storage.ts index 68c2c201..1d7d9bad 100644 --- a/packages/javascript/src/storages/hawk-local-storage.ts +++ b/packages/javascript/src/storages/hawk-local-storage.ts @@ -11,6 +11,7 @@ export class HawkLocalStorage implements HawkStorage { return localStorage.getItem(key); } catch (e) { log('HawkLocalStorage: getItem failed', 'error', e); + return null; } } diff --git a/packages/javascript/src/utils/random.ts b/packages/javascript/src/utils/random.ts new file mode 100644 index 00000000..1907884c --- /dev/null +++ b/packages/javascript/src/utils/random.ts @@ -0,0 +1,19 @@ +import type { RandomGenerator } from '@hawk.so/core'; + +/** + * Browser implementation of {@link RandomGenerator} backed by the Web Crypto API. + */ +export class BrowserRandomGenerator implements RandomGenerator { + /** + * Generates a sequence of cryptographically secure random unsigned numbers. + * + * @param length - Length of the generated sequence. + * @returns Array filled with random unsigned numbers. + * @remarks Uses {@link Crypto.getRandomValues} under the hood. + */ + public getRandomNumbers(length: number): Uint8Array { + const array = new Uint8Array(length); + + return window.crypto.getRandomValues(array); + } +} From fe80e085b67fefbe31571b13e1f03e5dd34fc113 Mon Sep 17 00:00:00 2001 From: Reversean Date: Thu, 12 Feb 2026 21:30:14 +0300 Subject: [PATCH 3/5] refactor(core): Logger abstraction added --- packages/core/src/index.ts | 2 + packages/core/src/logger/logger.ts | 68 +++++++++++++++ packages/core/tests/logger/logger.test.ts | 86 +++++++++++++++++++ packages/javascript/src/addons/breadcrumbs.ts | 2 +- packages/javascript/src/catcher.ts | 11 ++- packages/javascript/src/logger/logger.ts | 61 +++++++++++++ packages/javascript/src/modules/fetchTimer.ts | 2 +- packages/javascript/src/modules/socket.ts | 2 +- .../src/storages/hawk-local-storage.ts | 2 +- packages/javascript/src/utils/event.ts | 2 +- packages/javascript/src/utils/log.ts | 46 ---------- packages/javascript/src/utils/validation.ts | 2 +- packages/javascript/tests/breadcrumbs.test.ts | 18 ++-- .../javascript/tests/logger/logger.test.ts | 78 +++++++++++++++++ .../javascript/tests/utils/validation.test.ts | 4 +- 15 files changed, 321 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/logger/logger.ts create mode 100644 packages/core/tests/logger/logger.test.ts create mode 100644 packages/javascript/src/logger/logger.ts delete mode 100644 packages/javascript/src/utils/log.ts create mode 100644 packages/javascript/tests/logger/logger.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d63e1f56..66f1fca2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,5 @@ export type { HawkStorage } from './storages/hawk-storage'; 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'; diff --git a/packages/core/src/logger/logger.ts b/packages/core/src/logger/logger.ts new file mode 100644 index 00000000..c45dffaf --- /dev/null +++ b/packages/core/src/logger/logger.ts @@ -0,0 +1,68 @@ +/** + * Log level type for categorizing log messages. + * + * Includes standard console methods supported in both browser and Node.js: + * - Standard levels: `log`, `warn`, `error`, `info` + * - Performance timing: `time`, `timeEnd` + */ +export type LogType = 'log' | 'warn' | 'error' | 'info' | 'time' | 'timeEnd'; + +/** + * Logger function interface for environment-specific logging implementations. + * + * Implementations should handle message formatting, output styling, + * and platform-specific logging mechanisms (e.g., console, file, network). + * + * @param msg - The message to log. + * @param type - Log level/severity (default: 'log'). + * @param args - Additional data to include with the log message. + */ +export interface Logger { + (msg: string, type?: LogType, args?: unknown): void; +} + +/** + * Global logger instance, set by environment-specific packages. + */ +let loggerInstance: Logger | null = null; + +/** + * Checks if logger instance has been registered. + */ +export function isLoggerSet(): boolean { + return loggerInstance !== null; +} + +/** + * Registers the environment-specific logger implementation. + * + * This should be called once during application initialization + * by the environment-specific package. + * + * @param logger - Logger implementation to use globally. + */ +export function setLogger(logger: Logger): void { + loggerInstance = logger; +} + +/** + * Clears the registered logger instance. + */ +export function resetLogger(): void { + loggerInstance = null; +} + +/** + * Logs a message using the registered logger implementation. + * + * If no logger has been registered via {@link setLogger}, this is a no-op. + * + * @param msg - Message to log. + * @param type - Log level (default: 'log'). + * @param args - Additional arguments to log. + */ +export function log(msg: string, type?: LogType, args?: unknown): void { + if (loggerInstance) { + loggerInstance(msg, type, args); + } +} diff --git a/packages/core/tests/logger/logger.test.ts b/packages/core/tests/logger/logger.test.ts new file mode 100644 index 00000000..0d79fdb7 --- /dev/null +++ b/packages/core/tests/logger/logger.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Each test gets a fresh module instance via vi.resetModules() so that + * the module-level loggerInstance starts as null. + */ +describe('Logger', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should return false from isLoggerSet when no logger has been registered', async () => { + const { isLoggerSet } = await import('../../src/logger/logger'); + + expect(isLoggerSet()).toBe(false); + }); + + it('should return true from isLoggerSet after setLogger is called', async () => { + const { isLoggerSet, setLogger } = await import('../../src/logger/logger'); + + setLogger(vi.fn()); + + expect(isLoggerSet()).toBe(true); + }); + + it('should not throw when log is called with no logger registered', async () => { + const { log } = await import('../../src/logger/logger'); + + expect(() => log('test message')).not.toThrow(); + }); + + it('should forward msg, type, and args to the registered logger', async () => { + const { setLogger, log } = await import('../../src/logger/logger'); + const mockLogger = vi.fn(); + + setLogger(mockLogger); + log('something went wrong', 'warn', { code: 42 }); + + expect(mockLogger).toHaveBeenCalledOnce(); + expect(mockLogger).toHaveBeenCalledWith('something went wrong', 'warn', { code: 42 }); + }); + + it('should pass undefined for omitted type and args', async () => { + const { setLogger, log } = await import('../../src/logger/logger'); + const mockLogger = vi.fn(); + + setLogger(mockLogger); + log('simple'); + + expect(mockLogger).toHaveBeenCalledWith('simple', undefined, undefined); + }); + + it('should replace a previously registered logger when setLogger is called again', async () => { + const { setLogger, log } = await import('../../src/logger/logger'); + const first = vi.fn(); + const second = vi.fn(); + + setLogger(first); + setLogger(second); + log('msg'); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('msg', undefined, undefined); + }); + + it('should clear the registered logger when resetLogger is called', async () => { + const { isLoggerSet, setLogger, resetLogger } = await import('../../src/logger/logger'); + + setLogger(vi.fn()); + expect(isLoggerSet()).toBe(true); + + resetLogger(); + expect(isLoggerSet()).toBe(false); + }); + + it('should become a no-op after resetLogger is called', async () => { + const { setLogger, resetLogger, log } = await import('../../src/logger/logger'); + const mockLogger = vi.fn(); + + setLogger(mockLogger); + resetLogger(); + log('msg'); + + expect(mockLogger).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index faa14a8e..1e4f0b9b 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -4,7 +4,7 @@ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; import { buildElementSelector } from '../utils/selector'; -import log from '../utils/log'; +import { log } from '@hawk.so/core'; import { isValidBreadcrumb } from '../utils/validation'; /** diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 936f8d1f..2e43d683 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,6 +1,5 @@ import Socket from './modules/socket'; import Sanitizer from './modules/sanitizer'; -import log from './utils/log'; import StackParser from './modules/stackParser'; import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; @@ -20,14 +19,22 @@ import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; import { isValidEventPayload, validateContext, validateUser } from './utils/validation'; -import { HawkUserManager } from '@hawk.so/core'; +import { HawkUserManager, setLogger, isLoggerSet, log } from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; +import { createBrowserLogger } from './logger/logger'; /** * Allow to use global VERSION, that will be overwritten by Webpack */ declare const VERSION: string; +/** + * Registers a global logger instance if not already done. + */ +if (!isLoggerSet()) { + setLogger(createBrowserLogger(VERSION)); +} + /** * Hawk JavaScript Catcher * Module for errors and exceptions tracking diff --git a/packages/javascript/src/logger/logger.ts b/packages/javascript/src/logger/logger.ts new file mode 100644 index 00000000..922f9aec --- /dev/null +++ b/packages/javascript/src/logger/logger.ts @@ -0,0 +1,61 @@ +import type { Logger, LogType } from '@hawk.so/core'; + +/** + * Creates a browser console logger with Hawk branding and styled output. + * + * The logger outputs to `window.console` with a dark label badge + * containing the Hawk version. Messages are formatted with CSS + * styling for better visibility in browser developer tools. + * + * @param version - Version string to display in log messages. + * @param style - Optional CSS style for the message text (default: 'color: inherit'). + * @returns {Logger} Logger function implementation for browser environments. + * + * @example + * ```TypeScript + * import { createBrowserLogger } from '@hawk.so/javascript'; + * import { setLogger } from '@hawk.so/core'; + * + * const logger = createBrowserLogger('3.2.0'); + * setLogger(logger); + * + * // Custom styling + * const styledLogger = createBrowserLogger('3.2.0', 'color: blue; font-weight: bold'); + * setLogger(styledLogger); + * ``` + */ +export function createBrowserLogger(version: string, style = 'color: inherit'): Logger { + return (msg: string, type: LogType = 'log', args?: unknown): void => { + if (!('console' in window)) { + return; + } + + const editorLabelText = `Hawk (${version})`; + const editorLabelStyle = `line-height: 1em; + color: #fff; + display: inline-block; + background-color: rgba(0,0,0,.7); + padding: 3px 5px; + border-radius: 3px; + margin-right: 2px`; + + try { + switch (type) { + case 'time': + case 'timeEnd': + console[type](`( ${editorLabelText} ) ${msg}`); + break; + case 'log': + case 'warn': + case 'error': + case 'info': + if (args !== undefined) { + console[type](`%c${editorLabelText}%c ${msg} %o`, editorLabelStyle, style, args); + } else { + console[type](`%c${editorLabelText}%c ${msg}`, editorLabelStyle, style); + } + break; + } + } catch (ignored) {} + }; +} diff --git a/packages/javascript/src/modules/fetchTimer.ts b/packages/javascript/src/modules/fetchTimer.ts index bd32bd6c..a17c1d47 100644 --- a/packages/javascript/src/modules/fetchTimer.ts +++ b/packages/javascript/src/modules/fetchTimer.ts @@ -1,4 +1,4 @@ -import log from '../utils/log'; +import { log } from '@hawk.so/core'; /** * Sends AJAX request and wait for some time. diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 290658ce..930a9e53 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -1,4 +1,4 @@ -import log from '../utils/log'; +import { log } from '@hawk.so/core'; import type { CatcherMessage } from '@/types'; import type { Transport } from '../types/transport'; diff --git a/packages/javascript/src/storages/hawk-local-storage.ts b/packages/javascript/src/storages/hawk-local-storage.ts index 1d7d9bad..1eb2496b 100644 --- a/packages/javascript/src/storages/hawk-local-storage.ts +++ b/packages/javascript/src/storages/hawk-local-storage.ts @@ -1,5 +1,5 @@ import type { HawkStorage } from '@hawk.so/core'; -import log from '../utils/log'; +import { log } from '@hawk.so/core'; /** * {@link HawkStorage} implementation backed by the browser's {@linkcode localStorage}. diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index 3882c272..63741533 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -1,4 +1,4 @@ -import log from './log'; +import { log } from '@hawk.so/core'; /** * Symbol to mark error as processed by Hawk diff --git a/packages/javascript/src/utils/log.ts b/packages/javascript/src/utils/log.ts deleted file mode 100644 index 4087d903..00000000 --- a/packages/javascript/src/utils/log.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Logger module - * - * @example - * log('We got an error', 'error') - */ - -/** - * Allow to use global VERSION, that will be overwritten by Webpack - */ -declare const VERSION: string; - -/** - * Custom logger - * - * @param {string} msg - message - * @param {string} type - logging type 'log'|'warn'|'error'|'info' - * @param {*} [args] - argument to log with a message - * @param {string} style - additional styling to message - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function log(msg: string, type = 'log', args?: any, style = 'color: inherit'): void { - if (!('console' in window) || !window.console[type]) { - return; - } - - const editorLabelText = `Hawk (${VERSION})`; - const editorLabelStyle = `line-height: 1em; - color: #fff; - display: inline-block; - line-height: 1em; - background-color: rgba(0,0,0,.7); - padding: 3px 5px; - border-radius: 3px; - margin-right: 2px`; - - try { - if (['time', 'timeEnd'].includes(type)) { - console[type](`( ${editorLabelText} ) ${msg}`); - } else if (args) { - console[type](`%c${editorLabelText}%c ${msg} %o`, editorLabelStyle, style, args); - } else { - console[type](`%c${editorLabelText}%c ${msg}`, editorLabelStyle, style); - } - } catch (ignored) {} -} diff --git a/packages/javascript/src/utils/validation.ts b/packages/javascript/src/utils/validation.ts index c0f9f664..293cafc6 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/javascript/src/utils/validation.ts @@ -1,4 +1,4 @@ -import log from './log'; +import { log } from '@hawk.so/core'; import type { AffectedUser, Breadcrumb, EventContext, EventData, JavaScriptAddons } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index f02d7dcb..7afec05c 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { BreadcrumbManager } 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 @@ -8,15 +9,15 @@ function resetManager(): void { } describe('BreadcrumbManager', () => { - let warnSpy: ReturnType; + let logSpy: ReturnType; beforeEach(() => { resetManager(); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + logSpy = vi.spyOn(core, 'log').mockImplementation(() => {}); }); afterEach(() => { - warnSpy.mockRestore(); + logSpy.mockRestore(); }); it('should return empty array when no breadcrumbs added', () => { @@ -118,15 +119,15 @@ describe('BreadcrumbManager', () => { }); describe('beforeBreadcrumb', () => { - let warnSpy: ReturnType; + let logSpy: ReturnType; beforeEach(() => { resetManager(); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + logSpy = vi.spyOn(core, 'log').mockImplementation(() => {}); }); afterEach(() => { - warnSpy.mockRestore(); + logSpy.mockRestore(); }); it('should store modified breadcrumb when hook returns changed object', () => { @@ -183,10 +184,9 @@ describe('beforeBreadcrumb', () => { // Assert expect(m.getBreadcrumbs()[0].message).toBe('original'); - expect(warnSpy).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid beforeBreadcrumb value'), - expect.anything(), - expect.anything() + 'warn' ); }); diff --git a/packages/javascript/tests/logger/logger.test.ts b/packages/javascript/tests/logger/logger.test.ts new file mode 100644 index 00000000..df4477bc --- /dev/null +++ b/packages/javascript/tests/logger/logger.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import { createBrowserLogger } from '../../src/logger/logger'; + +describe('createBrowserLogger', () => { + let consoleLogSpy: ReturnType; + let consoleWarnSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should log message with default type', () => { + const logger = createBrowserLogger('1.0.0'); + + logger('Test message'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + '%cHawk (1.0.0)%c Test message', + expect.stringContaining('background-color'), + 'color: inherit' + ); + }); + + it('should log message with specified type', () => { + const logger = createBrowserLogger('2.0.0'); + + logger('Warning message', 'warn'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '%cHawk (2.0.0)%c Warning message', + expect.stringContaining('background-color'), + 'color: inherit' + ); + }); + + it('should log error with args', () => { + const logger = createBrowserLogger('3.0.0'); + const errorObj = new Error('Test error'); + + logger('Error occurred', 'error', errorObj); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '%cHawk (3.0.0)%c Error occurred %o', + expect.stringContaining('background-color'), + 'color: inherit', + errorObj + ); + }); + + it('should handle time/timeEnd types', () => { + const consoleTimeSpy = vi.spyOn(console, 'time').mockImplementation(() => {}); + const logger = createBrowserLogger('4.0.0'); + + logger('Timer started', 'time'); + + expect(consoleTimeSpy).toHaveBeenCalledWith( + expect.stringContaining('Hawk (4.0.0)') + ); + + consoleTimeSpy.mockRestore(); + }); + + it('should not throw when console method is unavailable', () => { + const logger = createBrowserLogger('5.0.0'); + + expect(() => { + // @ts-expect-error - testing invalid type + logger('Test', 'invalidType'); + }).not.toThrow(); + }); +}); diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/javascript/tests/utils/validation.test.ts index f7cafd8f..20d02944 100644 --- a/packages/javascript/tests/utils/validation.test.ts +++ b/packages/javascript/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'; -// Suppress console output produced by log() calls inside validation failures. -vi.mock('../../src/utils/log', () => ({ default: vi.fn() })); +// 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() })); describe('validateUser', () => { it('should return false when user is null', () => { From 7698036bd91b3a0e21e2a0f8b32f366eb0afcaa6 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 19:52:18 +0300 Subject: [PATCH 4/5] refactor(core): Extract shared modules and utilities --- packages/{javascript => core}/src/errors.ts | 0 packages/core/src/index.ts | 7 +++ .../src/modules/fetch-timer.ts} | 0 .../src/modules/sanitizer.ts | 6 ++- .../src/modules/stack-parser.ts} | 10 ++-- packages/core/src/transports/transport.ts | 9 ++++ .../{javascript => core}/src/utils/event.ts | 8 +-- .../src/utils/selector.ts | 0 .../src/utils/validation.ts | 41 ++++++-------- .../tests/utils/validation.test.ts | 2 +- packages/javascript/src/addons/breadcrumbs.ts | 5 +- .../javascript/src/addons/consoleCatcher.ts | 2 +- packages/javascript/src/catcher.ts | 38 +++++++------ 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 | 2 +- packages/javascript/src/types/transport.ts | 7 ++- 18 files changed, 79 insertions(+), 133 deletions(-) rename packages/{javascript => core}/src/errors.ts (100%) rename packages/{javascript/src/modules/fetchTimer.ts => core/src/modules/fetch-timer.ts} (100%) rename packages/{javascript => core}/src/modules/sanitizer.ts (98%) 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 (67%) rename packages/{javascript => core}/tests/utils/validation.test.ts (98%) 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..b4ebb931 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,10 @@ 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 { 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 100% rename from packages/javascript/src/modules/fetchTimer.ts rename to packages/core/src/modules/fetch-timer.ts diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts similarity index 98% rename from packages/javascript/src/modules/sanitizer.ts rename to packages/core/src/modules/sanitizer.ts index a02172d0..dadd3fff 100644 --- a/packages/javascript/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { isPlainObject } from '../utils/validation'; + /** * This class provides methods for preparing data to sending to Hawk * - trim long strings @@ -6,7 +8,7 @@ * - represent big objects as "" * - represent class as or */ -export default class Sanitizer { +export class Sanitizer { /** * Maximum string length */ @@ -34,7 +36,7 @@ export default class Sanitizer { * @param target - variable to check */ public static isObject(target: any): boolean { - return Sanitizer.typeOf(target) === 'object'; + return isPlainObject(target); } /** 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..5ed26727 --- /dev/null +++ b/packages/core/src/transports/transport.ts @@ -0,0 +1,9 @@ +import type { CatcherMessage } from '@hawk.so/types'; +import { 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 67% rename from packages/javascript/src/utils/validation.ts rename to packages/core/src/utils/validation.ts index 293cafc6..7fd45185 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,6 +1,12 @@ -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'; + +/** + * Returns true if value is a plain object (not null, array, Date, Map, etc.) + */ +export function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} /** * Validates user data - basic security checks @@ -8,7 +14,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 +36,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; @@ -39,23 +45,14 @@ export function validateContext(context: EventContext | undefined): boolean { return true; } -/** - * Checks if value is a plain object (not array, Date, etc.) - * - * @param value - value to check - */ -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 +61,7 @@ export function isValidEventPayload(payload: unknown): payload is EventData ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index 1e4f0b9b..972ba205 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 { Sanitizer, buildElementSelector, log, isValidBreadcrumb } 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 2e43d683..9c3e8ade 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,6 +1,4 @@ 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 +10,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, setLogger, isLoggerSet, log } 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 +63,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 +516,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 +526,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 +579,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 +619,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } try { @@ -619,7 +627,7 @@ export default class Catcher { } catch (e) { log('Can not parse stack:', 'warn', e); - return null; + return undefined; } } @@ -686,6 +694,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/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..96b7fc75 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 { 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'> { } From bf2444c53c450deae1a2e08a5cbcfe8d71041154 Mon Sep 17 00:00:00 2001 From: Reversean Date: Fri, 6 Mar 2026 16:38:00 +0300 Subject: [PATCH 5/5] refactor(core): Fix sanitizer --- packages/core/src/index.ts | 1 + packages/core/src/modules/fetch-timer.ts | 2 +- packages/core/src/modules/sanitizer.ts | 105 +++++++-------- packages/core/tests/modules/sanitizer.test.ts | 118 +++++++++++++++++ packages/core/tests/utils/validation.test.ts | 4 +- packages/javascript/src/catcher.ts | 1 + packages/javascript/src/modules/sanitizer.ts | 20 +++ .../tests/modules/sanitizer.test.ts | 122 ++---------------- 8 files changed, 208 insertions(+), 165 deletions(-) create mode 100644 packages/core/tests/modules/sanitizer.test.ts create mode 100644 packages/javascript/src/modules/sanitizer.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4ebb931..0a869e00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ 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'; diff --git a/packages/core/src/modules/fetch-timer.ts b/packages/core/src/modules/fetch-timer.ts index a17c1d47..1405bfd2 100644 --- a/packages/core/src/modules/fetch-timer.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/core/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts index dadd3fff..08472a13 100644 --- a/packages/core/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -1,10 +1,28 @@ /* 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 */ @@ -30,6 +48,13 @@ export 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 * @@ -39,6 +64,17 @@ export class Sanitizer { 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); + } + /** * Apply sanitizing for array/object/primitives * @@ -62,19 +98,21 @@ export 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); /** @@ -133,7 +171,9 @@ export 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 */ @@ -207,24 +247,6 @@ export 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 * @@ -250,31 +272,12 @@ export 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/core/tests/modules/sanitizer.test.ts b/packages/core/tests/modules/sanitizer.test.ts new file mode 100644 index 00000000..d7055aeb --- /dev/null +++ b/packages/core/tests/modules/sanitizer.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { Sanitizer } from '../../src'; + +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 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/core/tests/utils/validation.test.ts b/packages/core/tests/utils/validation.test.ts index e8ac47c2..ab08ff25 100644 --- a/packages/core/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 '@hawk.so/core'; +import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } 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/catcher.ts b/packages/javascript/src/catcher.ts index 9c3e8ade..c4741303 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,3 +1,4 @@ +import './modules/sanitizer'; import Socket from './modules/socket'; import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/javascript/src/modules/sanitizer.ts new file mode 100644 index 00000000..071275a1 --- /dev/null +++ b/packages/javascript/src/modules/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/tests/modules/sanitizer.test.ts b/packages/javascript/tests/modules/sanitizer.test.ts index 7b79c6be..b563f787 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 { Sanitizer } from '@hawk.so/core'; +import '../../src/modules/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'); }); }); });