From 223001dd5122b967cea4c588d06bf9da0543d5c4 Mon Sep 17 00:00:00 2001 From: Reversean Date: Mon, 16 Feb 2026 22:28:52 +0300 Subject: [PATCH 1/3] refactor(core): HawkStorage abstraction added --- packages/core/src/index.ts | 1 + packages/core/src/storages/hawk-storage.ts | 26 +++++ packages/javascript/package.json | 2 + .../src/storages/hawk-local-storage.ts | 21 ++++ .../tests/storages/hawk-local-storage.test.ts | 56 +++++++++ packages/javascript/tsconfig.test.json | 3 +- yarn.lock | 109 +++++++++++++++++- 7 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/storages/hawk-storage.ts create mode 100644 packages/javascript/src/storages/hawk-local-storage.ts create mode 100644 packages/javascript/tests/storages/hawk-local-storage.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e69de29..6a701eb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export type { HawkStorage } from './storages/hawk-storage'; diff --git a/packages/core/src/storages/hawk-storage.ts b/packages/core/src/storages/hawk-storage.ts new file mode 100644 index 0000000..a3f2092 --- /dev/null +++ b/packages/core/src/storages/hawk-storage.ts @@ -0,0 +1,26 @@ +/** + * Abstract key–value storage contract used by Hawk internals to persist data across sessions. + */ +export interface HawkStorage { + /** + * Returns the value associated with the given key, or `null` if none exists. + * + * @param key - Storage key to look up. + */ + getItem(key: string): string | null + + /** + * Persists a value under the given key. + * + * @param key - Storage key. + * @param value - Value to store. + */ + setItem(key: string, value: string): void + + /** + * Removes the entry for the given key. + * + * @param key - Storage key to remove. + */ + removeItem(key: string): void +} diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 22e9827..66543c2 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -21,6 +21,7 @@ "build": "vite build", "stats": "size-limit > stats.txt", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, @@ -43,6 +44,7 @@ }, "devDependencies": { "@hawk.so/types": "0.5.8", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.0.0", "vite": "^7.3.1", "vite-plugin-dts": "^4.2.4", diff --git a/packages/javascript/src/storages/hawk-local-storage.ts b/packages/javascript/src/storages/hawk-local-storage.ts new file mode 100644 index 0000000..79fb2f1 --- /dev/null +++ b/packages/javascript/src/storages/hawk-local-storage.ts @@ -0,0 +1,21 @@ +import type { HawkStorage } from '@hawk.so/core'; + +/** + * {@link HawkStorage} implementation backed by the browser's {@linkcode localStorage}. + */ +export class HawkLocalStorage implements HawkStorage { + /** @inheritDoc */ + public getItem(key: string): string | null { + return localStorage.getItem(key); + } + + /** @inheritDoc */ + public setItem(key: string, value: string): void { + localStorage.setItem(key, value); + } + + /** @inheritDoc */ + public removeItem(key: string): void { + localStorage.removeItem(key); + } +} diff --git a/packages/javascript/tests/storages/hawk-local-storage.test.ts b/packages/javascript/tests/storages/hawk-local-storage.test.ts new file mode 100644 index 0000000..3417017 --- /dev/null +++ b/packages/javascript/tests/storages/hawk-local-storage.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, afterEach, describe, it, expect, vi, Mock } from "vitest"; +import { HawkLocalStorage } from "../../src/storages/hawk-local-storage"; + +describe('HawkLocalStorage', () => { + let getItemSpy: Mock<(key: string) => string | null>; + let setItemSpy: Mock<(key: string, value: string) => void>; + let removeItemSpy: Mock<(key: string) => void>; + let storage: HawkLocalStorage; + + beforeEach(() => { + localStorage.clear(); + storage = new HawkLocalStorage(); + getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null when key does not exist', () => { + expect(storage.getItem('foo')).toBeNull(); + expect(getItemSpy).toHaveBeenCalledOnce(); + }); + + it('should return value when key exists in storage', () => { + localStorage.setItem('foo', 'bar'); + + expect(storage.getItem('foo')).toEqual('bar'); + expect(getItemSpy).toHaveBeenCalledWith('foo'); + }); + + it('should persist item via setItem()', () => { + storage.setItem('foo', 'bar'); + + expect(setItemSpy).toHaveBeenCalledWith('foo', 'bar'); + expect(localStorage.getItem('foo')).toEqual('bar'); + }); + + it('should remove item via removeItem()', () => { + localStorage.setItem('foo', 'bar'); + storage.removeItem('foo'); + + expect(removeItemSpy).toHaveBeenCalledWith('foo'); + expect(localStorage.getItem('foo')).toBeNull(); + }); + + it('should not affect other keys when removing', () => { + localStorage.setItem('foo', 'bar'); + storage.removeItem('baz'); + + expect(removeItemSpy).toHaveBeenCalledWith('baz'); + expect(localStorage.getItem('foo')).toEqual('bar'); + }); +}); diff --git a/packages/javascript/tsconfig.test.json b/packages/javascript/tsconfig.test.json index 166c447..38db19a 100644 --- a/packages/javascript/tsconfig.test.json +++ b/packages/javascript/tsconfig.test.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": null, - "declaration": false + "declaration": false, + "types": ["vitest/globals"] }, "include": [ "src/**/*", diff --git a/yarn.lock b/yarn.lock index b4ec9e5..eacf26a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,7 +80,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.23.5, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.23.5, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.29.0": version: 7.29.0 resolution: "@babel/parser@npm:7.29.0" dependencies: @@ -101,6 +101,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@csstools/color-helpers@npm:^6.0.1": version: 6.0.1 resolution: "@csstools/color-helpers@npm:6.0.1" @@ -590,6 +597,7 @@ __metadata: resolution: "@hawk.so/javascript@workspace:packages/javascript" dependencies: "@hawk.so/types": "npm:0.5.8" + "@vitest/coverage-v8": "npm:^4.0.18" error-stack-parser: "npm:^2.1.4" jsdom: "npm:^28.0.0" vite: "npm:^7.3.1" @@ -708,7 +716,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24": +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -1385,6 +1393,30 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" + peerDependencies: + "@vitest/browser": 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.18": version: 4.0.18 resolution: "@vitest/expect@npm:4.0.18" @@ -1846,6 +1878,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^0.3.10": + version: 0.3.11 + resolution: "ast-v8-to-istanbul@npm:0.3.11" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/0667dcb5f42bd16f5d50b8687f3471f9b9d000ea7f8808c3cd0ddabc1ef7d5b1a61e19f498d5ca7b1285e6c185e11d0ae724c4f9291491b50b6340110ce63108 + languageName: node + linkType: hard + "astral-regex@npm:^2.0.0": version: 2.0.0 resolution: "astral-regex@npm:2.0.0" @@ -3602,6 +3645,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -3997,6 +4047,34 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "jiti@npm:^2.4.2": version: 2.6.1 resolution: "jiti@npm:2.6.1" @@ -4013,6 +4091,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4249,6 +4334,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.5.1": + version: 0.5.2 + resolution: "magicast@npm:0.5.2" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^15.0.0": version: 15.0.3 resolution: "make-fetch-happen@npm:15.0.3" From 7d5e8e40386c711348e6a9f01ada33f8ab3ba294 Mon Sep 17 00:00:00 2001 From: Reversean Date: Thu, 12 Feb 2026 20:59:50 +0300 Subject: [PATCH 2/3] refactor(core): UserManager abstraction added --- .github/workflows/main.yml | 4 +- package.json | 2 + packages/core/package.json | 9 ++- packages/core/src/index.ts | 2 + .../src/users/hawk-storage-user-manager.ts | 49 ++++++++++++++ packages/core/src/users/user-manager.ts | 26 ++++++++ .../users/hawk-storage-user-manager.test.ts | 41 ++++++++++++ packages/core/tsconfig.test.json | 13 ++++ packages/core/vitest.config.ts | 15 +++++ packages/javascript/package.json | 1 + packages/javascript/src/catcher.ts | 65 +++++++++---------- packages/javascript/vite.config.ts | 1 + packages/javascript/vitest.config.ts | 1 + yarn.lock | 5 +- 14 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/users/hawk-storage-user-manager.ts create mode 100644 packages/core/src/users/user-manager.ts create mode 100644 packages/core/tests/users/hawk-storage-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 fade885..1881dea 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 01c1ec1..d59466e 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 e06be1d..60734a6 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", @@ -34,7 +37,9 @@ }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", "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 6a701eb..00d172a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,3 @@ export type { HawkStorage } from './storages/hawk-storage'; +export type { UserManager } from './users/user-manager'; +export { HawkStorageUserManager } from './users/hawk-storage-user-manager'; diff --git a/packages/core/src/users/hawk-storage-user-manager.ts b/packages/core/src/users/hawk-storage-user-manager.ts new file mode 100644 index 0000000..4392f91 --- /dev/null +++ b/packages/core/src/users/hawk-storage-user-manager.ts @@ -0,0 +1,49 @@ +import type { AffectedUser } from '@hawk.so/types'; +import type { HawkStorage } from '../storages/hawk-storage'; +import type { UserManager } from './user-manager'; + +/** + * Storage key used to persist the user identifier. + */ +const HAWK_USER_STORAGE_KEY = 'hawk-user-id'; + +/** + * {@link UserManager} implementation that persists the affected user + * via an injected {@link HawkStorage} backend. + */ +export class HawkStorageUserManager implements UserManager { + /** + * Underlying storage used to read and write the user identifier. + */ + private readonly storage: HawkStorage; + + /** + * @param storage - Storage backend to use for persistence. + */ + constructor(storage: HawkStorage) { + this.storage = storage; + } + + /** @inheritDoc */ + public getUser(): AffectedUser | null { + const storedId = this.storage.getItem(HAWK_USER_STORAGE_KEY); + + if (storedId) { + return { + id: storedId, + }; + } + + return null; + } + + /** @inheritDoc */ + public setUser(user: AffectedUser): void { + this.storage.setItem(HAWK_USER_STORAGE_KEY, user.id); + } + + /** @inheritDoc */ + public clear(): void { + this.storage.removeItem(HAWK_USER_STORAGE_KEY); + } +} diff --git a/packages/core/src/users/user-manager.ts b/packages/core/src/users/user-manager.ts new file mode 100644 index 0000000..f3a1f5b --- /dev/null +++ b/packages/core/src/users/user-manager.ts @@ -0,0 +1,26 @@ +import type { AffectedUser } from '@hawk.so/types'; + +/** + * Contract for user identity managers. + * + * Implementations are responsible for persisting and retrieving the + * {@link AffectedUser} that is attached to every error report sent by the catcher. + */ +export interface UserManager { + /** + * Returns the current affected user, or `null` if none has been set. + */ + getUser(): AffectedUser | null + + /** + * Replaces the stored user with the provided one. + * + * @param user - The affected user to persist. + */ + setUser(user: AffectedUser): void + + /** + * Removes any previously stored user data. + */ + clear(): void +} diff --git a/packages/core/tests/users/hawk-storage-user-manager.test.ts b/packages/core/tests/users/hawk-storage-user-manager.test.ts new file mode 100644 index 0000000..8710f39 --- /dev/null +++ b/packages/core/tests/users/hawk-storage-user-manager.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HawkStorageUserManager } from '../../src'; +import type { HawkStorage } from '../../src'; + +describe('StorageUserManager', () => { + let storage: HawkStorage; + let manager: HawkStorageUserManager; + + beforeEach(() => { + storage = { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + manager = new HawkStorageUserManager(storage); + }); + + it('should return null when storage is empty', () => { + expect(manager.getUser()).toBeNull(); + expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id'); + }); + + it('should return user when ID exists in storage', () => { + vi.mocked(storage.getItem).mockReturnValue('test-user-123'); + + expect(manager.getUser()).toEqual({id: 'test-user-123'}); + expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id'); + }); + + it('should persist user ID via setUser()', () => { + manager.setUser({id: 'user-abc'}); + + expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', 'user-abc'); + }); + + it('should remove user ID via clear()', () => { + manager.clear(); + + expect(storage.removeItem).toHaveBeenCalledWith('hawk-user-id'); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..38db19a --- /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 0000000..aee2668 --- /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/package.json b/packages/javascript/package.json index 66543c2..75db15a 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", "dependencies": { + "@hawk.so/core": "workspace:^", "error-stack-parser": "^2.1.4" }, "devDependencies": { diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b18d868..4b66a65 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,10 @@ import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; +import type { UserManager } from '@hawk.so/core'; +import { HawkStorageUserManager } 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 +65,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 +109,11 @@ export default class Catcher { */ private readonly breadcrumbManager: BreadcrumbManager | null; + /** + * Current authenticated user manager instance + */ + private readonly userManager: UserManager = new HawkStorageUserManager(new HawkLocalStorage()); + /** * Catcher constructor * @@ -126,7 +129,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 +194,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 +256,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(); } /** @@ -533,7 +517,7 @@ export default class Catcher { private getIntegrationId(): string { try { const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(this.token)); - const { integrationId } = decodedIntegrationToken; + const {integrationId} = decodedIntegrationToken; if (!integrationId || integrationId === '') { throw new Error(); @@ -565,10 +549,19 @@ export default class Catcher { } /** - * Current authenticated user + * Returns the current user if exists, otherwise creates and persists a new one. */ - private getUser(): HawkJavaScriptEvent['user'] { - return this.user || null; + private getUser(): AffectedUser { + const user = this.userManager.getUser(); + + if (user) { + return user; + } + const newUser: AffectedUser = {id: id()}; + + this.userManager.setUser(newUser); + + return newUser; } /** @@ -635,7 +628,7 @@ export default class Catcher { * @param {Error|string} error — caught error */ private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] { - const { innerWidth, innerHeight } = window; + const {innerWidth, innerHeight} = window; const userAgent = window.navigator.userAgent; const location = window.location.href; const getParams = this.getGetParams(); diff --git a/packages/javascript/vite.config.ts b/packages/javascript/vite.config.ts index 47fe52e..65b622e 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 47ad6a2..68e2cda 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 eacf26a..927e467 100644 --- a/yarn.lock +++ b/yarn.lock @@ -583,12 +583,14 @@ __metadata: languageName: node linkType: hard -"@hawk.so/core@workspace:packages/core": +"@hawk.so/core@workspace:^, @hawk.so/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@hawk.so/core@workspace:packages/core" dependencies: + "@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 @@ -596,6 +598,7 @@ __metadata: version: 0.0.0-use.local resolution: "@hawk.so/javascript@workspace:packages/javascript" dependencies: + "@hawk.so/core": "workspace:^" "@hawk.so/types": "npm:0.5.8" "@vitest/coverage-v8": "npm:^4.0.18" error-stack-parser: "npm:^2.1.4" From 202e102c05b8813dc86f4da4c279ee10567a17cd Mon Sep 17 00:00:00 2001 From: Reversean Date: Thu, 12 Feb 2026 21:30:14 +0300 Subject: [PATCH 3/3] refactor(core): Logger abstraction added --- packages/core/src/index.ts | 2 + packages/core/src/logger/logger.ts | 54 +++++++++++++ packages/javascript/src/addons/breadcrumbs.ts | 2 +- packages/javascript/src/catcher.ts | 12 +-- packages/javascript/src/logger/logger.ts | 61 +++++++++++++++ packages/javascript/src/modules/fetchTimer.ts | 2 +- packages/javascript/src/modules/socket.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 +++++++++++++++++++ 12 files changed, 216 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/logger/logger.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 00d172a..dfd6478 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 { UserManager } from './users/user-manager'; export { HawkStorageUserManager } from './users/hawk-storage-user-manager'; +export type { Logger, LogType } from './logger/logger'; +export { setLogger, 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 0000000..852a903 --- /dev/null +++ b/packages/core/src/logger/logger.ts @@ -0,0 +1,54 @@ +/** + * 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; + +/** + * 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; +} + +/** + * 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/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index faa14a8..1e4f0b9 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 4b66a65..d43912b 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 { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; import { VueIntegration } from './integrations/vue'; @@ -19,8 +18,9 @@ import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; import type { UserManager } from '@hawk.so/core'; -import { HawkStorageUserManager } from '@hawk.so/core'; +import { HawkStorageUserManager, setLogger, log } from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; +import { createBrowserLogger } from './logger/logger'; import { id } from './utils/id'; /** @@ -120,6 +120,8 @@ export default class Catcher { * @param {HawkInitialSettings|string} settings - If settings is a string, it means an Integration Token */ constructor(settings: HawkInitialSettings | string) { + setLogger(createBrowserLogger(VERSION)); + if (typeof settings === 'string') { settings = { token: settings, @@ -517,7 +519,7 @@ export default class Catcher { private getIntegrationId(): string { try { const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(this.token)); - const {integrationId} = decodedIntegrationToken; + const { integrationId } = decodedIntegrationToken; if (!integrationId || integrationId === '') { throw new Error(); @@ -557,7 +559,7 @@ export default class Catcher { if (user) { return user; } - const newUser: AffectedUser = {id: id()}; + const newUser: AffectedUser = { id: id() }; this.userManager.setUser(newUser); @@ -628,7 +630,7 @@ export default class Catcher { * @param {Error|string} error — caught error */ private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] { - const {innerWidth, innerHeight} = window; + const { innerWidth, innerHeight } = window; const userAgent = window.navigator.userAgent; const location = window.location.href; const getParams = this.getGetParams(); diff --git a/packages/javascript/src/logger/logger.ts b/packages/javascript/src/logger/logger.ts new file mode 100644 index 0000000..9c39261 --- /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/browser'; + * 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'); + * ``` + */ +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; + line-height: 1em; + 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 bd32bd6..a17c1d4 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 eb64b59..cbc662a 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/utils/event.ts b/packages/javascript/src/utils/event.ts index 3882c27..6374153 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 4087d90..0000000 --- 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 c0f9f66..293cafc 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 f02d7dc..7afec05 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 0000000..df4477b --- /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(); + }); +});