From f5a369f5a372e5f4a3c5e19556fdc0e9d81b0aa5 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:20:49 +0300 Subject: [PATCH 01/14] feat: add main-thread blocking detection (Long Tasks + LoAF) --- packages/javascript/README.md | 45 +++++ packages/javascript/package.json | 2 +- packages/javascript/src/addons/longTasks.ts | 175 ++++++++++++++++++ packages/javascript/src/catcher.ts | 12 ++ .../src/types/hawk-initial-settings.ts | 16 ++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 packages/javascript/src/addons/longTasks.ts diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 8cd86fc4..a39dd853 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -11,6 +11,7 @@ Error tracking for JavaScript/TypeScript applications. - 🛡️ Sensitive data filtering - 🌟 Source maps consuming - 💬 Console logs tracking +- 🧊 Main-thread blocking detection (Chromium-only) -  Vue support -  React support @@ -90,6 +91,7 @@ Initialization settings: | `consoleTracking` | boolean | optional | Initialize console logs tracking | | `breadcrumbs` | false or BreadcrumbsOptions object | optional | Configure breadcrumbs tracking (see below) | | `beforeSend` | function(event) => event \| false \| void | optional | Filter data before sending. Return modified event, `false` to drop the event. | +| `mainThreadBlocking` | false or MainThreadBlockingOptions object | optional | Main-thread blocking detection (see below) | Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition. @@ -232,6 +234,49 @@ const breadcrumbs = hawk.breadcrumbs.get(); hawk.breadcrumbs.clear(); ``` +## Main-Thread Blocking Detection + +> **Chromium-only** (Chrome, Edge). On unsupported browsers the feature is silently skipped — no errors, no overhead. + +Hawk can detect tasks that block the browser's main thread for too long and send them as dedicated events. Two complementary APIs are used under the hood: + +- **Long Tasks API** — reports any task taking longer than 50 ms. +- **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+). + +Both are enabled by default. When a blocking entry is detected, Hawk immediately sends a separate event with details in the context (duration, blocking time, scripts involved, etc.). + +### Disabling + +Disable the feature entirely: + +```js +const hawk = new HawkCatcher({ + token: 'INTEGRATION_TOKEN', + mainThreadBlocking: false +}); +``` + +### Selective Configuration + +Enable only one of the two observers: + +```js +const hawk = new HawkCatcher({ + token: 'INTEGRATION_TOKEN', + mainThreadBlocking: { + longTasks: true, // Long Tasks API (default: true) + longAnimationFrames: false // LoAF (default: true) + } +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `longTasks` | `boolean` | `true` | Observe Long Tasks (tasks blocking the main thread for >50 ms). | +| `longAnimationFrames` | `boolean` | `true` | Observe Long Animation Frames — provides script-level attribution for slow frames. Requires Chrome 123+ / Edge 123+. | + ## Source maps consuming If your bundle is minified, it is useful to pass source-map files to the Hawk. After that you will see beautiful diff --git a/packages/javascript/package.json b/packages/javascript/package.json index bb4d8480..e9993076 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.18", + "version": "3.3.0", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts new file mode 100644 index 00000000..b8be1329 --- /dev/null +++ b/packages/javascript/src/addons/longTasks.ts @@ -0,0 +1,175 @@ +/** + * @file Long Task & Long Animation Frame (LoAF) tracking via PerformanceObserver + * + * Both APIs are Chromium-only (Chrome, Edge). + * - Long Tasks: tasks blocking the main thread for >50 ms + * - LoAF (Chrome 123+): richer attribution per long animation frame + * + * Sets up observers and fires `onEntry` per detected entry — fire and forget. + */ + +import type { EventContext, Json } from '@hawk.so/types'; +import log from '../utils/log'; + +/** + * Configuration for main-thread blocking detection + * + * Both features are Chromium-only (Chrome, Edge). + * Feature detection is performed automatically — on unsupported browsers + * the observers simply won't start. + */ +export interface MainThreadBlockingOptions { + /** + * Track Long Tasks (tasks blocking the main thread for >50 ms). + * Uses PerformanceObserver with `longtask` entry type. + * + * Chromium-only (Chrome, Edge) + * + * @default true + */ + longTasks?: boolean; + + /** + * Track Long Animation Frames (LoAF) — frames taking >50 ms. + * Provides richer attribution data than Long Tasks. + * Uses PerformanceObserver with `long-animation-frame` entry type. + * + * Chromium-only (Chrome 123+, Edge 123+) + * + * @default true + */ + longAnimationFrames?: boolean; +} + +/** + * Payload passed to the callback when a long task / LoAF is detected + */ +export interface LongTaskEvent { + title: string; + context: EventContext; +} + +/** + * LoAF entry shape (spec is still evolving) + */ +interface LoAFEntry extends PerformanceEntry { + blockingDuration?: number; + scripts?: { + name: string; + invoker?: string; + invokerType?: string; + sourceURL?: string; + duration: number; + }[]; +} + +function supportsEntryType(type: string): boolean { + try { + return ( + typeof PerformanceObserver !== 'undefined' && + typeof PerformanceObserver.supportedEntryTypes !== 'undefined' && + PerformanceObserver.supportedEntryTypes.includes(type) + ); + } catch { + return false; + } +} + +function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { + if (!supportsEntryType('longtask')) { + log('Long Tasks API is not supported in this browser', 'info'); + + return; + } + + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const durationMs = Math.round(entry.duration); + + onEntry({ + title: `Long Task ${durationMs} ms`, + context: { + kind: 'longtask', + startTime: Math.round(entry.startTime), + durationMs, + }, + }); + } + }).observe({ type: 'longtask', buffered: true }); + } catch { /* unsupported — ignore */ } +} + +function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { + if (!supportsEntryType('long-animation-frame')) { + log('Long Animation Frames (LoAF) API is not supported in this browser', 'info'); + + return; + } + + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const loaf = entry as LoAFEntry; + const durationMs = Math.round(loaf.duration); + const blockingDurationMs = loaf.blockingDuration != null + ? Math.round(loaf.blockingDuration) + : undefined; + + const scripts = loaf.scripts + ?.filter((s) => s.sourceURL) + .reduce>((acc, s, i) => { + acc[`script_${i}`] = { + name: s.name, + invoker: s.invoker ?? '', + invokerType: s.invokerType ?? '', + sourceURL: s.sourceURL ?? '', + duration: Math.round(s.duration), + }; + + return acc; + }, {}); + + const blockingNote = blockingDurationMs != null + ? ` (blocking ${blockingDurationMs} ms)` + : ''; + + const context: EventContext = { + kind: 'loaf', + startTime: Math.round(loaf.startTime), + durationMs, + }; + + if (blockingDurationMs != null) { + context.blockingDurationMs = blockingDurationMs; + } + + if (scripts && Object.keys(scripts).length > 0) { + context.scripts = scripts; + } + + onEntry({ + title: `Long Animation Frame ${durationMs} ms${blockingNote}`, + context, + }); + } + }).observe({ type: 'long-animation-frame', buffered: true }); + } catch { /* unsupported — ignore */ } +} + +/** + * Set up observers for main-thread blocking detection. + * Each detected entry fires `onEntry` immediately. + */ +export function observeMainThreadBlocking( + options: MainThreadBlockingOptions, + onEntry: (e: LongTaskEvent) => void +): void { + if (options.longTasks ?? true) { + observeLongTasks(onEntry); + } + + if (options.longAnimationFrames ?? true) { + observeLoAF(onEntry); + } +} diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b18d8683..4d5649af 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -18,6 +18,7 @@ import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; +import { observeMainThreadBlocking } from './addons/longTasks'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; /** @@ -177,6 +178,17 @@ export default class Catcher { this.breadcrumbManager = null; } + /** + * Main-thread blocking detection (Long Tasks + LoAF) + * Chromium-only — on unsupported browsers this is a no-op + */ + if (settings.mainThreadBlocking !== false) { + observeMainThreadBlocking( + settings.mainThreadBlocking || {}, + (entry) => this.send(entry.title, entry.context) + ); + } + /** * Set global handlers */ diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 987cdf4c..e5a59c50 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -2,6 +2,7 @@ import type { EventContext, AffectedUser } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; import type { Transport } from './transport'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; +import type { MainThreadBlockingOptions } from '../addons/longTasks'; /** * JS Catcher initial settings @@ -98,4 +99,19 @@ export interface HawkInitialSettings { * If not provided, default WebSocket transport is used. */ transport?: Transport; + + /** + * Main-thread blocking detection. + * Observes Long Tasks and Long Animation Frames (LoAF) via PerformanceObserver + * and sends a dedicated event when blocking is detected. + * + * Chromium-only (Chrome, Edge). On unsupported browsers the observers + * simply won't start — no errors, no overhead. + * + * Pass `false` to disable entirely. + * Pass an options object to toggle individual observers. + * + * @default enabled with default options (both longTasks and longAnimationFrames on) + */ + mainThreadBlocking?: false | MainThreadBlockingOptions; } From db69d935d4d844b9719e019eab16df2ee5d4635b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:31:10 +0300 Subject: [PATCH 02/14] chore: add JSDoc comments --- packages/javascript/src/addons/longTasks.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index b8be1329..d9745118 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -63,6 +63,11 @@ interface LoAFEntry extends PerformanceEntry { }[]; } +/** + * Check whether the browser supports a given PerformanceObserver entry type + * + * @param type - entry type name, e.g. `'longtask'` or `'long-animation-frame'` + */ function supportsEntryType(type: string): boolean { try { return ( @@ -75,6 +80,11 @@ function supportsEntryType(type: string): boolean { } } +/** + * Subscribe to Long Tasks (>50 ms) via PerformanceObserver + * + * @param onEntry - callback fired for each detected long task + */ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { if (!supportsEntryType('longtask')) { log('Long Tasks API is not supported in this browser', 'info'); @@ -100,6 +110,12 @@ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { } catch { /* unsupported — ignore */ } } +/** + * Subscribe to Long Animation Frames (>50 ms) via PerformanceObserver. + * Provides script-level attribution (Chrome 123+, Edge 123+). + * + * @param onEntry - callback fired for each detected LoAF entry + */ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { if (!supportsEntryType('long-animation-frame')) { log('Long Animation Frames (LoAF) API is not supported in this browser', 'info'); From 442da72b37ac866ab32595bec57b0a504dbc46de Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:58:25 +0300 Subject: [PATCH 03/14] feat: enhance Long Tasks and LoAF handling with detailed attribution and serialization --- packages/javascript/src/addons/longTasks.ts | 163 ++++++++++++++------ 1 file changed, 118 insertions(+), 45 deletions(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index d9745118..dbdfc4ce 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -8,7 +8,7 @@ * Sets up observers and fires `onEntry` per detected entry — fire and forget. */ -import type { EventContext, Json } from '@hawk.so/types'; +import type { EventContext, Json, JsonNode } from '@hawk.so/types'; import log from '../utils/log'; /** @@ -49,18 +49,67 @@ export interface LongTaskEvent { context: EventContext; } +/** + * Long Task attribution (container-level info only) + */ +interface LongTaskAttribution { + name: string; + entryType: string; + containerType?: string; + containerSrc?: string; + containerId?: string; + containerName?: string; +} + +/** + * Long Task entry with attribution + */ +interface LongTaskPerformanceEntry extends PerformanceEntry { + attribution?: LongTaskAttribution[]; +} + +/** + * LoAF script timing (PerformanceScriptTiming) + */ +interface LoAFScript { + name: string; + invoker?: string; + invokerType?: string; + sourceURL?: string; + sourceFunctionName?: string; + sourceCharPosition?: number; + duration: number; + startTime: number; + executionStart?: number; + forcedStyleAndLayoutDuration?: number; + pauseDuration?: number; + windowAttribution?: string; +} + /** * LoAF entry shape (spec is still evolving) */ interface LoAFEntry extends PerformanceEntry { blockingDuration?: number; - scripts?: { - name: string; - invoker?: string; - invokerType?: string; - sourceURL?: string; - duration: number; - }[]; + renderStart?: number; + styleAndLayoutStart?: number; + firstUIEventTimestamp?: number; + scripts?: LoAFScript[]; +} + +/** + * Build a Json object from entries, dropping null / undefined / empty-string values + */ +function compact(entries: [string, JsonNode | null | undefined][]): Json { + const result: Json = {}; + + for (const [key, value] of entries) { + if (value != null && value !== '') { + result[key] = value; + } + } + + return result; } /** @@ -80,6 +129,24 @@ function supportsEntryType(type: string): boolean { } } +/** + * Serialize a LoAF script entry into a Json-compatible object + */ +function serializeScript(s: LoAFScript): Json { + return compact([ + ['invoker', s.invoker], + ['invokerType', s.invokerType], + ['sourceURL', s.sourceURL], + ['sourceFunctionName', s.sourceFunctionName], + ['sourceCharPosition', s.sourceCharPosition != null && s.sourceCharPosition >= 0 ? s.sourceCharPosition : null], + ['duration', Math.round(s.duration)], + ['executionStart', s.executionStart != null ? Math.round(s.executionStart) : null], + ['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration ? Math.round(s.forcedStyleAndLayoutDuration) : null], + ['pauseDuration', s.pauseDuration ? Math.round(s.pauseDuration) : null], + ['windowAttribution', s.windowAttribution], + ]); +} + /** * Subscribe to Long Tasks (>50 ms) via PerformanceObserver * @@ -95,16 +162,22 @@ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { - const durationMs = Math.round(entry.duration); + const task = entry as LongTaskPerformanceEntry; + const durationMs = Math.round(task.duration); + const attr = task.attribution?.[0]; - onEntry({ - title: `Long Task ${durationMs} ms`, - context: { - kind: 'longtask', - startTime: Math.round(entry.startTime), - durationMs, - }, - }); + const details = compact([ + ['kind', 'longtask'], + ['entryName', task.name], + ['startTime', Math.round(task.startTime)], + ['durationMs', durationMs], + ['containerType', attr?.containerType], + ['containerSrc', attr?.containerSrc], + ['containerId', attr?.containerId], + ['containerName', attr?.containerName], + ]); + + onEntry({ title: `Long Task ${durationMs} ms`, context: { details } }); } }).observe({ type: 'longtask', buffered: true }); } catch { /* unsupported — ignore */ } @@ -130,43 +203,43 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { const durationMs = Math.round(loaf.duration); const blockingDurationMs = loaf.blockingDuration != null ? Math.round(loaf.blockingDuration) - : undefined; - - const scripts = loaf.scripts - ?.filter((s) => s.sourceURL) - .reduce>((acc, s, i) => { - acc[`script_${i}`] = { - name: s.name, - invoker: s.invoker ?? '', - invokerType: s.invokerType ?? '', - sourceURL: s.sourceURL ?? '', - duration: Math.round(s.duration), - }; + : null; + + const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName); + + const scripts = relevantScripts?.length + ? relevantScripts.reduce((acc, s, i) => { + acc[`script_${i}`] = serializeScript(s); return acc; - }, {}); + }, {}) + : null; + + const details = compact([ + ['kind', 'loaf'], + ['startTime', Math.round(loaf.startTime)], + ['durationMs', durationMs], + ['blockingDurationMs', blockingDurationMs], + ['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null], + ['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null], + ['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null], + ['scripts', scripts], + ]); const blockingNote = blockingDurationMs != null ? ` (blocking ${blockingDurationMs} ms)` : ''; - const context: EventContext = { - kind: 'loaf', - startTime: Math.round(loaf.startTime), - durationMs, - }; - - if (blockingDurationMs != null) { - context.blockingDurationMs = blockingDurationMs; - } - - if (scripts && Object.keys(scripts).length > 0) { - context.scripts = scripts; - } + const topScript = relevantScripts?.[0]; + const culprit = topScript?.sourceFunctionName + || topScript?.invoker + || topScript?.sourceURL + || ''; + const culpritNote = culprit ? ` — ${culprit}` : ''; onEntry({ - title: `Long Animation Frame ${durationMs} ms${blockingNote}`, - context, + title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`, + context: { details }, }); } }).observe({ type: 'long-animation-frame', buffered: true }); From 33810392c59121f7f8e48a43dc2447104345f58c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:48 +0300 Subject: [PATCH 04/14] refactor: update context structure in Long Tasks and LoAF to use mainThreadBlocking for clarity --- packages/javascript/src/addons/longTasks.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index dbdfc4ce..7849dc5b 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -177,7 +177,10 @@ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { ['containerName', attr?.containerName], ]); - onEntry({ title: `Long Task ${durationMs} ms`, context: { details } }); + onEntry({ + title: `Long Task ${durationMs} ms`, + context: { mainThreadBlocking: details }, + }); } }).observe({ type: 'longtask', buffered: true }); } catch { /* unsupported — ignore */ } @@ -239,7 +242,7 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { onEntry({ title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`, - context: { details }, + context: { mainThreadBlocking: details }, }); } }).observe({ type: 'long-animation-frame', buffered: true }); From 0eef530adc025684bcb743ead5952a6410c25c8b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:00:30 +0300 Subject: [PATCH 05/14] Update packages/javascript/src/addons/longTasks.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/javascript/src/addons/longTasks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index 7849dc5b..d2d8e85d 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -141,8 +141,8 @@ function serializeScript(s: LoAFScript): Json { ['sourceCharPosition', s.sourceCharPosition != null && s.sourceCharPosition >= 0 ? s.sourceCharPosition : null], ['duration', Math.round(s.duration)], ['executionStart', s.executionStart != null ? Math.round(s.executionStart) : null], - ['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration ? Math.round(s.forcedStyleAndLayoutDuration) : null], - ['pauseDuration', s.pauseDuration ? Math.round(s.pauseDuration) : null], + ['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration != null ? Math.round(s.forcedStyleAndLayoutDuration) : null], + ['pauseDuration', s.pauseDuration != null ? Math.round(s.pauseDuration) : null], ['windowAttribution', s.windowAttribution], ]); } From fa6cb0f24ea95ca6601d636296594c8e246aa492 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:00:40 +0300 Subject: [PATCH 06/14] Update packages/javascript/src/addons/longTasks.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/javascript/src/addons/longTasks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index d2d8e85d..4891bf2f 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -223,9 +223,9 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { ['startTime', Math.round(loaf.startTime)], ['durationMs', durationMs], ['blockingDurationMs', blockingDurationMs], - ['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null], - ['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null], - ['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null], + ['renderStart', loaf.renderStart != null ? Math.round(loaf.renderStart) : null], + ['styleAndLayoutStart', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null], + ['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null], ['scripts', scripts], ]); From 54a6fd377924b954d16216f3ca689e8317c99a29 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:05:20 +0300 Subject: [PATCH 07/14] Update packages/javascript/src/addons/longTasks.ts Co-authored-by: Peter --- packages/javascript/src/addons/longTasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index 4891bf2f..15a47a65 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -204,7 +204,7 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { for (const entry of list.getEntries()) { const loaf = entry as LoAFEntry; const durationMs = Math.round(loaf.duration); - const blockingDurationMs = loaf.blockingDuration != null + const blockingDurationMs = loaf.blockingDuration !== null ? Math.round(loaf.blockingDuration) : null; From 203f1b7f7fad67622ab082b155c5a338fc2f09d4 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:05:49 +0300 Subject: [PATCH 08/14] Update packages/javascript/src/addons/longTasks.ts Co-authored-by: Peter --- packages/javascript/src/addons/longTasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index 15a47a65..191c20f0 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -229,7 +229,7 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { ['scripts', scripts], ]); - const blockingNote = blockingDurationMs != null + const blockingNote = blockingDurationMs !== null ? ` (blocking ${blockingDurationMs} ms)` : ''; From 068db0b8e22b710e8f8dcf8065074b2dc21b3748 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:17:02 +0300 Subject: [PATCH 09/14] refactor: streamline Long Tasks and LoAF handling by removing unused interfaces and improving script serialization --- packages/javascript/src/addons/longTasks.ts | 94 ++++--------------- .../src/types/hawk-initial-settings.ts | 3 + packages/javascript/src/types/long-tasks.ts | 72 ++++++++++++++ packages/javascript/src/utils/compactJson.ts | 19 ++++ .../javascript/tests/compact-json.test.ts | 43 +++++++++ 5 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 packages/javascript/src/types/long-tasks.ts create mode 100644 packages/javascript/src/utils/compactJson.ts create mode 100644 packages/javascript/tests/compact-json.test.ts diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts index 191c20f0..b195e8f0 100644 --- a/packages/javascript/src/addons/longTasks.ts +++ b/packages/javascript/src/addons/longTasks.ts @@ -8,8 +8,9 @@ * Sets up observers and fires `onEntry` per detected entry — fire and forget. */ -import type { EventContext, Json, JsonNode } from '@hawk.so/types'; -import log from '../utils/log'; +import type { EventContext, Json } from '@hawk.so/types'; +import type { LoAFEntry, LoAFScript, LongTaskPerformanceEntry } from '../types/long-tasks'; +import { compactJson } from '../utils/compactJson'; /** * Configuration for main-thread blocking detection @@ -49,69 +50,6 @@ export interface LongTaskEvent { context: EventContext; } -/** - * Long Task attribution (container-level info only) - */ -interface LongTaskAttribution { - name: string; - entryType: string; - containerType?: string; - containerSrc?: string; - containerId?: string; - containerName?: string; -} - -/** - * Long Task entry with attribution - */ -interface LongTaskPerformanceEntry extends PerformanceEntry { - attribution?: LongTaskAttribution[]; -} - -/** - * LoAF script timing (PerformanceScriptTiming) - */ -interface LoAFScript { - name: string; - invoker?: string; - invokerType?: string; - sourceURL?: string; - sourceFunctionName?: string; - sourceCharPosition?: number; - duration: number; - startTime: number; - executionStart?: number; - forcedStyleAndLayoutDuration?: number; - pauseDuration?: number; - windowAttribution?: string; -} - -/** - * LoAF entry shape (spec is still evolving) - */ -interface LoAFEntry extends PerformanceEntry { - blockingDuration?: number; - renderStart?: number; - styleAndLayoutStart?: number; - firstUIEventTimestamp?: number; - scripts?: LoAFScript[]; -} - -/** - * Build a Json object from entries, dropping null / undefined / empty-string values - */ -function compact(entries: [string, JsonNode | null | undefined][]): Json { - const result: Json = {}; - - for (const [key, value] of entries) { - if (value != null && value !== '') { - result[key] = value; - } - } - - return result; -} - /** * Check whether the browser supports a given PerformanceObserver entry type * @@ -133,7 +71,7 @@ function supportsEntryType(type: string): boolean { * Serialize a LoAF script entry into a Json-compatible object */ function serializeScript(s: LoAFScript): Json { - return compact([ + return compactJson([ ['invoker', s.invoker], ['invokerType', s.invokerType], ['sourceURL', s.sourceURL], @@ -147,6 +85,14 @@ function serializeScript(s: LoAFScript): Json { ]); } +/** + * Return LoAF scripts that contain useful source attribution for debugging. + * We keep only scripts that have at least function name or source URL. + */ +function getRelevantLoAFScripts(loaf: LoAFEntry): LoAFScript[] { + return loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName) ?? []; +} + /** * Subscribe to Long Tasks (>50 ms) via PerformanceObserver * @@ -154,8 +100,6 @@ function serializeScript(s: LoAFScript): Json { */ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { if (!supportsEntryType('longtask')) { - log('Long Tasks API is not supported in this browser', 'info'); - return; } @@ -166,7 +110,7 @@ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { const durationMs = Math.round(task.duration); const attr = task.attribution?.[0]; - const details = compact([ + const details = compactJson([ ['kind', 'longtask'], ['entryName', task.name], ['startTime', Math.round(task.startTime)], @@ -194,8 +138,6 @@ function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { */ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { if (!supportsEntryType('long-animation-frame')) { - log('Long Animation Frames (LoAF) API is not supported in this browser', 'info'); - return; } @@ -204,13 +146,13 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { for (const entry of list.getEntries()) { const loaf = entry as LoAFEntry; const durationMs = Math.round(loaf.duration); - const blockingDurationMs = loaf.blockingDuration !== null + const blockingDurationMs = loaf.blockingDuration !== undefined && loaf.blockingDuration !== null ? Math.round(loaf.blockingDuration) : null; - const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName); + const relevantScripts = getRelevantLoAFScripts(loaf); - const scripts = relevantScripts?.length + const scripts = relevantScripts.length ? relevantScripts.reduce((acc, s, i) => { acc[`script_${i}`] = serializeScript(s); @@ -218,7 +160,7 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { }, {}) : null; - const details = compact([ + const details = compactJson([ ['kind', 'loaf'], ['startTime', Math.round(loaf.startTime)], ['durationMs', durationMs], @@ -233,7 +175,7 @@ function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { ? ` (blocking ${blockingDurationMs} ms)` : ''; - const topScript = relevantScripts?.[0]; + const topScript = relevantScripts[0]; const culprit = topScript?.sourceFunctionName || topScript?.invoker || topScript?.sourceURL diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index e5a59c50..bdc904c8 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -105,6 +105,9 @@ export interface HawkInitialSettings { * Observes Long Tasks and Long Animation Frames (LoAF) via PerformanceObserver * and sends a dedicated event when blocking is detected. * + * This is an umbrella option by design: Long Tasks and LoAF describe the same + * domain (main-thread blocking), so both toggles live under one config key. + * * Chromium-only (Chrome, Edge). On unsupported browsers the observers * simply won't start — no errors, no overhead. * diff --git a/packages/javascript/src/types/long-tasks.ts b/packages/javascript/src/types/long-tasks.ts new file mode 100644 index 00000000..a78ffef5 --- /dev/null +++ b/packages/javascript/src/types/long-tasks.ts @@ -0,0 +1,72 @@ +/** + * Long Task attribution information from Performance API. + * Describes the container associated with the long task. + */ +export interface LongTaskAttribution { + /** Attribution source name (`self`, `same-origin-ancestor`, etc.) */ + name: string; + /** Entry type name from the attribution object */ + entryType: string; + /** Container type (`iframe`, `embed`, `object`) */ + containerType?: string; + /** Source URL of the container */ + containerSrc?: string; + /** DOM id of the container element */ + containerId?: string; + /** DOM name of the container element */ + containerName?: string; +} + +/** + * Long Task entry with attribution details. + */ +export interface LongTaskPerformanceEntry extends PerformanceEntry { + /** Attribution list for the long task */ + attribution?: LongTaskAttribution[]; +} + +/** + * LoAF script timing information (PerformanceScriptTiming). + */ +export interface LoAFScript { + /** Script display name */ + name: string; + /** Script invoker (e.g. `TimerHandler:setTimeout`) */ + invoker?: string; + /** Invoker type (`event-listener`, `user-callback`, etc.) */ + invokerType?: string; + /** Source URL of the script */ + sourceURL?: string; + /** Function name associated with the script execution */ + sourceFunctionName?: string; + /** Character position in source */ + sourceCharPosition?: number; + /** Script duration in milliseconds */ + duration: number; + /** Start time in milliseconds from navigation start */ + startTime: number; + /** Execution start timestamp */ + executionStart?: number; + /** Forced style/layout duration in milliseconds */ + forcedStyleAndLayoutDuration?: number; + /** Paused time in milliseconds */ + pauseDuration?: number; + /** Window attribution (`self`, `ancestor`, `descendant`) */ + windowAttribution?: string; +} + +/** + * Long Animation Frame entry shape. + */ +export interface LoAFEntry extends PerformanceEntry { + /** Blocking duration in milliseconds */ + blockingDuration?: number; + /** Render start timestamp */ + renderStart?: number; + /** Style/layout start timestamp */ + styleAndLayoutStart?: number; + /** First UI event timestamp */ + firstUIEventTimestamp?: number; + /** Script timing records for the frame */ + scripts?: LoAFScript[]; +} diff --git a/packages/javascript/src/utils/compactJson.ts b/packages/javascript/src/utils/compactJson.ts new file mode 100644 index 00000000..58f17941 --- /dev/null +++ b/packages/javascript/src/utils/compactJson.ts @@ -0,0 +1,19 @@ +import type { Json, JsonNode } from '@hawk.so/types'; + +/** + * Build a JSON object from key-value pairs. + * Drops `null`, `undefined`, and empty strings. + * + * Useful for compact event payload construction without repetitive `if` chains. + */ +export function compactJson(entries: [string, JsonNode | null | undefined][]): Json { + const result: Json = {}; + + for (const [key, value] of entries) { + if (value != null && value !== '') { + result[key] = value; + } + } + + return result; +} diff --git a/packages/javascript/tests/compact-json.test.ts b/packages/javascript/tests/compact-json.test.ts new file mode 100644 index 00000000..76bb47f1 --- /dev/null +++ b/packages/javascript/tests/compact-json.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { compactJson } from '../src/utils/compactJson'; + +describe('compactJson', () => { + it('should keep non-empty primitive values', () => { + const result = compactJson([ + ['name', 'hawk'], + ['count', 0], + ['enabled', false], + ]); + + expect(result).toEqual({ + name: 'hawk', + count: 0, + enabled: false, + }); + }); + + it('should drop null, undefined and empty string values', () => { + const result = compactJson([ + ['a', null], + ['b', undefined], + ['c', ''], + ['d', 'ok'], + ]); + + expect(result).toEqual({ + d: 'ok', + }); + }); + + it('should keep nested json objects', () => { + const result = compactJson([ + ['meta', { source: 'test' }], + ['duration', 123], + ]); + + expect(result).toEqual({ + meta: { source: 'test' }, + duration: 123, + }); + }); +}); From 7355ddad158f21f4feb812418ab109f48d449169 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:18:44 +0300 Subject: [PATCH 10/14] feat: add web-vitals support and refactor issues handling - Introduced `web-vitals` as a peer dependency and added it to the package.json. - Updated README to reflect new issues detection configuration, including `issues.webVitals`. - Refactored the catcher to utilize an `IssuesMonitor` for handling global errors and performance issues. - Removed the Long Tasks and LoAF tracking functionality, consolidating it under the new issues detection system. --- packages/javascript/README.md | 60 +++- packages/javascript/package.json | 8 + packages/javascript/src/addons/issues.ts | 314 ++++++++++++++++++ packages/javascript/src/addons/longTasks.ts | 209 ------------ packages/javascript/src/catcher.ts | 44 ++- .../src/types/hawk-initial-settings.ts | 22 +- packages/javascript/src/types/index.ts | 25 +- .../src/types/{long-tasks.ts => issues.ts} | 80 +++++ yarn.lock | 10 + 9 files changed, 514 insertions(+), 258 deletions(-) create mode 100644 packages/javascript/src/addons/issues.ts delete mode 100644 packages/javascript/src/addons/longTasks.ts rename packages/javascript/src/types/{long-tasks.ts => issues.ts} (58%) diff --git a/packages/javascript/README.md b/packages/javascript/README.md index a39dd853..5953647b 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -86,12 +86,12 @@ Initialization settings: | `user` | {id: string, name?: string, image?: string, url?: string} | optional | Current authenticated user | | `context` | object | optional | Any data you want to pass with every message. Has limitation of length. | | `vue` | Vue constructor | optional | Pass Vue constructor to set up the [Vue integration](#integrate-to-vue-application) | -| `disableGlobalErrorsHandling` | boolean | optional | Do not initialize global errors handling | +| `disableGlobalErrorsHandling` | boolean | optional | Deprecated. Use `issues.errors: false` instead. | | `disableVueErrorHandler` | boolean | optional | Do not initialize Vue errors handling | | `consoleTracking` | boolean | optional | Initialize console logs tracking | | `breadcrumbs` | false or BreadcrumbsOptions object | optional | Configure breadcrumbs tracking (see below) | | `beforeSend` | function(event) => event \| false \| void | optional | Filter data before sending. Return modified event, `false` to drop the event. | -| `mainThreadBlocking` | false or MainThreadBlockingOptions object | optional | Main-thread blocking detection (see below) | +| `issues` | IssuesOptions object | optional | Issues config: `errors`, `webVitals`, `longTasks.thresholdMs`, `longAnimationFrames.thresholdMs` | Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition. @@ -234,38 +234,68 @@ const breadcrumbs = hawk.breadcrumbs.get(); hawk.breadcrumbs.clear(); ``` -## Main-Thread Blocking Detection +## Issues Detection -> **Chromium-only** (Chrome, Edge). On unsupported browsers the feature is silently skipped — no errors, no overhead. +`issues` is an umbrella option for problems detected by the catcher. +Browser support depends on the specific detector: +- `errors` — works in all supported browsers +- `webVitals` — via `web-vitals` package +- `longTasks` / `longAnimationFrames` — Chromium-only (`long-animation-frame` requires Chrome/Edge 123+) -Hawk can detect tasks that block the browser's main thread for too long and send them as dedicated events. Two complementary APIs are used under the hood: +It currently includes three groups: + +- `issues.errors` — global runtime errors handling +- `issues.webVitals` — aggregated Core Web Vitals report +- `issues.longTasks` and `issues.longAnimationFrames` — freeze-related detectors + +Freeze detectors use two complementary APIs: - **Long Tasks API** — reports any task taking longer than 50 ms. - **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+). -Both are enabled by default. When a blocking entry is detected, Hawk immediately sends a separate event with details in the context (duration, blocking time, scripts involved, etc.). +Both freeze detectors are enabled by default. If one API is unsupported, the other still works. +Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.). + +### Web Vitals (Aggregated) + +When `issues.webVitals` is enabled, Hawk collects all Core Web Vitals (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) and sends a single issue event when at least one metric is rated `poor`. + +The event context contains all metrics with: +- `value` +- `rating` +- `delta` + +`web-vitals` is an optional peer dependency and is loaded only when `issues.webVitals: true`. ### Disabling -Disable the feature entirely: +Disable global errors handling: ```js const hawk = new HawkCatcher({ token: 'INTEGRATION_TOKEN', - mainThreadBlocking: false + issues: { + errors: false + } }); ``` ### Selective Configuration -Enable only one of the two observers: +Configure all issue detectors: ```js const hawk = new HawkCatcher({ token: 'INTEGRATION_TOKEN', - mainThreadBlocking: { - longTasks: true, // Long Tasks API (default: true) - longAnimationFrames: false // LoAF (default: true) + issues: { + errors: true, + webVitals: true, + longTasks: { + thresholdMs: 100 + }, + longAnimationFrames: { + thresholdMs: 500 + } } }); ``` @@ -274,8 +304,10 @@ const hawk = new HawkCatcher({ | Option | Type | Default | Description | |--------|------|---------|-------------| -| `longTasks` | `boolean` | `true` | Observe Long Tasks (tasks blocking the main thread for >50 ms). | -| `longAnimationFrames` | `boolean` | `true` | Observe Long Animation Frames — provides script-level attribution for slow frames. Requires Chrome 123+ / Edge 123+. | +| `errors` | `boolean` | `true` | Enable global errors handling (`window.onerror` and `unhandledrejection`). | +| `webVitals` | `boolean` | `false` | Collect all Core Web Vitals and send one issue event when at least one metric is rated `poor`. Requires optional `web-vitals` dependency. | +| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 100 }` | Detect long tasks and emit issue events when duration is greater than threshold. | +| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 500 }` | Detect LoAF events and emit issue events when duration is greater than threshold. Requires Chrome 123+ / Edge 123+. | ## Source maps consuming diff --git a/packages/javascript/package.json b/packages/javascript/package.json index e9993076..1baad3a6 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -41,6 +41,14 @@ "dependencies": { "error-stack-parser": "^2.1.4" }, + "peerDependencies": { + "web-vitals": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "web-vitals": { + "optional": true + } + }, "devDependencies": { "@hawk.so/types": "0.5.8", "jsdom": "^28.0.0", diff --git a/packages/javascript/src/addons/issues.ts b/packages/javascript/src/addons/issues.ts new file mode 100644 index 00000000..41cc32ac --- /dev/null +++ b/packages/javascript/src/addons/issues.ts @@ -0,0 +1,314 @@ +import type { Json } from '@hawk.so/types'; +import type { + IssueEvent, + IssuesOptions, + LoAFEntry, + LoAFScript, + LongTaskPerformanceEntry, + WebVitalMetric, + WebVitalRating, + WebVitalsReport, +} from '../types/issues'; +import { compactJson } from '../utils/compactJson'; +import log from '../utils/log'; + +const DEFAULT_LONG_TASK_THRESHOLD_MS = 100; +const DEFAULT_LOAF_THRESHOLD_MS = 500; + +const METRIC_THRESHOLDS: Record = { + LCP: [2500, 4000], + FCP: [1800, 3000], + TTFB: [800, 1800], + INP: [200, 500], + CLS: [0.1, 0.25], +}; + +const TOTAL_WEB_VITALS = 5; + +/** + * Issues monitor handles: + * - Long Tasks + * - Long Animation Frames (LoAF) + * - Aggregated Web Vitals report + */ +export class IssuesMonitor { + private longTaskObserver: PerformanceObserver | null = null; + private loafObserver: PerformanceObserver | null = null; + private destroyed = false; + + /** + * Initialize selected issue detectors. + */ + public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void { + if (options.longTasks !== false) { + this.observeLongTasks( + resolveThreshold(options.longTasks?.thresholdMs, DEFAULT_LONG_TASK_THRESHOLD_MS), + onIssue + ); + } + + if (options.longAnimationFrames !== false) { + this.observeLoAF( + resolveThreshold(options.longAnimationFrames?.thresholdMs, DEFAULT_LOAF_THRESHOLD_MS), + onIssue + ); + } + + if (options.webVitals === true) { + this.observeWebVitals(onIssue); + } + } + + /** + * Cleanup active observers. + */ + public destroy(): void { + this.destroyed = true; + this.longTaskObserver?.disconnect(); + this.loafObserver?.disconnect(); + this.longTaskObserver = null; + this.loafObserver = null; + } + + private observeLongTasks(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { + if (!supportsEntryType('longtask')) { + return; + } + + try { + this.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (this.destroyed) { + return; + } + + const task = entry as LongTaskPerformanceEntry; + const durationMs = Math.round(task.duration); + const attr = task.attribution?.[0]; + + if (durationMs < thresholdMs) { + continue; + } + + const details = compactJson([ + ['kind', 'longtask'], + ['entryName', task.name], + ['startTime', Math.round(task.startTime)], + ['durationMs', durationMs], + ['containerType', attr?.containerType], + ['containerSrc', attr?.containerSrc], + ['containerId', attr?.containerId], + ['containerName', attr?.containerName], + ]); + + onIssue({ + title: `Long Task ${durationMs} ms`, + context: { freezeDetection: details }, + }); + } + }); + + this.longTaskObserver.observe({ type: 'longtask', buffered: true }); + } catch { + this.longTaskObserver = null; + } + } + + private observeLoAF(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { + if (!supportsEntryType('long-animation-frame')) { + return; + } + + try { + this.loafObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (this.destroyed) { + return; + } + + const loaf = entry as LoAFEntry; + const durationMs = Math.round(loaf.duration); + + if (durationMs < thresholdMs) { + continue; + } + + const blockingDurationMs = loaf.blockingDuration !== undefined && loaf.blockingDuration !== null + ? Math.round(loaf.blockingDuration) + : null; + + const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName) ?? []; + const scripts = relevantScripts.length + ? relevantScripts.reduce((acc, script, i) => { + acc[`script_${i}`] = serializeScript(script); + + return acc; + }, {}) + : null; + + const details = compactJson([ + ['kind', 'loaf'], + ['startTime', Math.round(loaf.startTime)], + ['durationMs', durationMs], + ['blockingDurationMs', blockingDurationMs], + ['renderStart', loaf.renderStart != null ? Math.round(loaf.renderStart) : null], + ['styleAndLayoutStart', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null], + ['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null], + ['scripts', scripts], + ]); + + const blockingNote = blockingDurationMs !== null + ? ` (blocking ${blockingDurationMs} ms)` + : ''; + + const topScript = relevantScripts[0]; + const culprit = topScript?.sourceFunctionName + || topScript?.invoker + || topScript?.sourceURL + || ''; + const culpritNote = culprit ? ` — ${culprit}` : ''; + + onIssue({ + title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`, + context: { freezeDetection: details }, + }); + } + }); + + this.loafObserver.observe({ type: 'long-animation-frame', buffered: true }); + } catch { + this.loafObserver = null; + } + } + + private observeWebVitals(onIssue: (event: IssueEvent) => void): void { + void import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => { + const collected: WebVitalMetric[] = []; + let reported = false; + + const tryReport = (): void => { + if (this.destroyed || reported || collected.length < TOTAL_WEB_VITALS) { + return; + } + + reported = true; + + const poor = collected.filter((metric) => metric.rating === 'poor'); + + if (poor.length === 0) { + return; + } + + const summary = poor + .map((metric) => { + const thresholds = METRIC_THRESHOLDS[metric.name]; + const threshold = thresholds ? ` (poor > ${formatValue(metric.name, thresholds[1])})` : ''; + + return `${metric.name} = ${formatValue(metric.name, metric.value)}${threshold}`; + }) + .join(', '); + + const report: WebVitalsReport = { + summary, + poorCount: poor.length, + metrics: collected.reduce>((acc, metric) => { + acc[metric.name] = metric; + + return acc; + }, {}), + }; + + onIssue({ + title: `Poor Web Vitals: ${summary}`, + context: { + webVitals: serializeWebVitalsReport(report), + }, + }); + }; + + const collect = (metric: { name: string; value: number; rating: WebVitalRating; delta: number }): void => { + if (this.destroyed || reported) { + return; + } + + collected.push({ + name: metric.name, + value: metric.value, + rating: metric.rating, + delta: metric.delta, + }); + + tryReport(); + }; + + onCLS(collect); + onINP(collect); + onLCP(collect); + onFCP(collect); + onTTFB(collect); + }).catch(() => { + log( + 'web-vitals package is required for Web Vitals tracking. Install it with: npm i web-vitals', + 'warn' + ); + }); + } +} + +function supportsEntryType(type: string): boolean { + try { + return ( + typeof PerformanceObserver !== 'undefined' && + typeof PerformanceObserver.supportedEntryTypes !== 'undefined' && + PerformanceObserver.supportedEntryTypes.includes(type) + ); + } catch { + return false; + } +} + +function resolveThreshold(value: number | undefined, fallback: number): number { + if (typeof value !== 'number' || Number.isNaN(value)) { + return fallback; + } + + return Math.max(0, Math.round(value)); +} + +function formatValue(name: string, value: number): string { + return name === 'CLS' ? value.toFixed(3) : `${Math.round(value)}ms`; +} + +function serializeScript(script: LoAFScript): Json { + return compactJson([ + ['invoker', script.invoker], + ['invokerType', script.invokerType], + ['sourceURL', script.sourceURL], + ['sourceFunctionName', script.sourceFunctionName], + ['sourceCharPosition', script.sourceCharPosition != null && script.sourceCharPosition >= 0 ? script.sourceCharPosition : null], + ['duration', Math.round(script.duration)], + ['executionStart', script.executionStart != null ? Math.round(script.executionStart) : null], + ['forcedStyleAndLayoutDuration', script.forcedStyleAndLayoutDuration != null ? Math.round(script.forcedStyleAndLayoutDuration) : null], + ['pauseDuration', script.pauseDuration != null ? Math.round(script.pauseDuration) : null], + ['windowAttribution', script.windowAttribution], + ]); +} + +function serializeWebVitalsReport(report: WebVitalsReport): Json { + const metrics = Object.entries(report.metrics).reduce((acc, [name, metric]) => { + acc[name] = compactJson([ + ['name', metric.name], + ['value', metric.value], + ['rating', metric.rating], + ['delta', metric.delta], + ]); + + return acc; + }, {}); + + return compactJson([ + ['summary', report.summary], + ['poorCount', report.poorCount], + ['metrics', metrics], + ]); +} diff --git a/packages/javascript/src/addons/longTasks.ts b/packages/javascript/src/addons/longTasks.ts deleted file mode 100644 index b195e8f0..00000000 --- a/packages/javascript/src/addons/longTasks.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * @file Long Task & Long Animation Frame (LoAF) tracking via PerformanceObserver - * - * Both APIs are Chromium-only (Chrome, Edge). - * - Long Tasks: tasks blocking the main thread for >50 ms - * - LoAF (Chrome 123+): richer attribution per long animation frame - * - * Sets up observers and fires `onEntry` per detected entry — fire and forget. - */ - -import type { EventContext, Json } from '@hawk.so/types'; -import type { LoAFEntry, LoAFScript, LongTaskPerformanceEntry } from '../types/long-tasks'; -import { compactJson } from '../utils/compactJson'; - -/** - * Configuration for main-thread blocking detection - * - * Both features are Chromium-only (Chrome, Edge). - * Feature detection is performed automatically — on unsupported browsers - * the observers simply won't start. - */ -export interface MainThreadBlockingOptions { - /** - * Track Long Tasks (tasks blocking the main thread for >50 ms). - * Uses PerformanceObserver with `longtask` entry type. - * - * Chromium-only (Chrome, Edge) - * - * @default true - */ - longTasks?: boolean; - - /** - * Track Long Animation Frames (LoAF) — frames taking >50 ms. - * Provides richer attribution data than Long Tasks. - * Uses PerformanceObserver with `long-animation-frame` entry type. - * - * Chromium-only (Chrome 123+, Edge 123+) - * - * @default true - */ - longAnimationFrames?: boolean; -} - -/** - * Payload passed to the callback when a long task / LoAF is detected - */ -export interface LongTaskEvent { - title: string; - context: EventContext; -} - -/** - * Check whether the browser supports a given PerformanceObserver entry type - * - * @param type - entry type name, e.g. `'longtask'` or `'long-animation-frame'` - */ -function supportsEntryType(type: string): boolean { - try { - return ( - typeof PerformanceObserver !== 'undefined' && - typeof PerformanceObserver.supportedEntryTypes !== 'undefined' && - PerformanceObserver.supportedEntryTypes.includes(type) - ); - } catch { - return false; - } -} - -/** - * Serialize a LoAF script entry into a Json-compatible object - */ -function serializeScript(s: LoAFScript): Json { - return compactJson([ - ['invoker', s.invoker], - ['invokerType', s.invokerType], - ['sourceURL', s.sourceURL], - ['sourceFunctionName', s.sourceFunctionName], - ['sourceCharPosition', s.sourceCharPosition != null && s.sourceCharPosition >= 0 ? s.sourceCharPosition : null], - ['duration', Math.round(s.duration)], - ['executionStart', s.executionStart != null ? Math.round(s.executionStart) : null], - ['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration != null ? Math.round(s.forcedStyleAndLayoutDuration) : null], - ['pauseDuration', s.pauseDuration != null ? Math.round(s.pauseDuration) : null], - ['windowAttribution', s.windowAttribution], - ]); -} - -/** - * Return LoAF scripts that contain useful source attribution for debugging. - * We keep only scripts that have at least function name or source URL. - */ -function getRelevantLoAFScripts(loaf: LoAFEntry): LoAFScript[] { - return loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName) ?? []; -} - -/** - * Subscribe to Long Tasks (>50 ms) via PerformanceObserver - * - * @param onEntry - callback fired for each detected long task - */ -function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void { - if (!supportsEntryType('longtask')) { - return; - } - - try { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - const task = entry as LongTaskPerformanceEntry; - const durationMs = Math.round(task.duration); - const attr = task.attribution?.[0]; - - const details = compactJson([ - ['kind', 'longtask'], - ['entryName', task.name], - ['startTime', Math.round(task.startTime)], - ['durationMs', durationMs], - ['containerType', attr?.containerType], - ['containerSrc', attr?.containerSrc], - ['containerId', attr?.containerId], - ['containerName', attr?.containerName], - ]); - - onEntry({ - title: `Long Task ${durationMs} ms`, - context: { mainThreadBlocking: details }, - }); - } - }).observe({ type: 'longtask', buffered: true }); - } catch { /* unsupported — ignore */ } -} - -/** - * Subscribe to Long Animation Frames (>50 ms) via PerformanceObserver. - * Provides script-level attribution (Chrome 123+, Edge 123+). - * - * @param onEntry - callback fired for each detected LoAF entry - */ -function observeLoAF(onEntry: (e: LongTaskEvent) => void): void { - if (!supportsEntryType('long-animation-frame')) { - return; - } - - try { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - const loaf = entry as LoAFEntry; - const durationMs = Math.round(loaf.duration); - const blockingDurationMs = loaf.blockingDuration !== undefined && loaf.blockingDuration !== null - ? Math.round(loaf.blockingDuration) - : null; - - const relevantScripts = getRelevantLoAFScripts(loaf); - - const scripts = relevantScripts.length - ? relevantScripts.reduce((acc, s, i) => { - acc[`script_${i}`] = serializeScript(s); - - return acc; - }, {}) - : null; - - const details = compactJson([ - ['kind', 'loaf'], - ['startTime', Math.round(loaf.startTime)], - ['durationMs', durationMs], - ['blockingDurationMs', blockingDurationMs], - ['renderStart', loaf.renderStart != null ? Math.round(loaf.renderStart) : null], - ['styleAndLayoutStart', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null], - ['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null], - ['scripts', scripts], - ]); - - const blockingNote = blockingDurationMs !== null - ? ` (blocking ${blockingDurationMs} ms)` - : ''; - - const topScript = relevantScripts[0]; - const culprit = topScript?.sourceFunctionName - || topScript?.invoker - || topScript?.sourceURL - || ''; - const culpritNote = culprit ? ` — ${culprit}` : ''; - - onEntry({ - title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`, - context: { mainThreadBlocking: details }, - }); - } - }).observe({ type: 'long-animation-frame', buffered: true }); - } catch { /* unsupported — ignore */ } -} - -/** - * Set up observers for main-thread blocking detection. - * Each detected entry fires `onEntry` immediately. - */ -export function observeMainThreadBlocking( - options: MainThreadBlockingOptions, - onEntry: (e: LongTaskEvent) => void -): void { - if (options.longTasks ?? true) { - observeLongTasks(onEntry); - } - - if (options.longAnimationFrames ?? true) { - observeLoAF(onEntry); - } -} diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 4d5649af..6c8744f3 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -18,7 +18,7 @@ import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { observeMainThreadBlocking } from './addons/longTasks'; +import { IssuesMonitor } from './addons/issues'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; /** @@ -112,6 +112,11 @@ export default class Catcher { */ private readonly breadcrumbManager: BreadcrumbManager | null; + /** + * Issues monitor instance + */ + private readonly issuesMonitor = new IssuesMonitor(); + /** * Catcher constructor * @@ -178,26 +183,31 @@ export default class Catcher { this.breadcrumbManager = null; } - /** - * Main-thread blocking detection (Long Tasks + LoAF) - * Chromium-only — on unsupported browsers this is a no-op - */ - if (settings.mainThreadBlocking !== false) { - observeMainThreadBlocking( - settings.mainThreadBlocking || {}, - (entry) => this.send(entry.title, entry.context) - ); + this.configureIssues(settings); + + if (settings.vue) { + this.connectVue(settings.vue); } + } - /** - * Set global handlers - */ - if (!settings.disableGlobalErrorsHandling) { + /** + * Configure issues-related features: + * - global errors handling + * - performance issue detectors (Long Tasks / LoAF) + */ + private configureIssues(settings: HawkInitialSettings): void { + const issues = settings.issues ?? {}; + const shouldHandleErrors = issues.errors ?? !settings.disableGlobalErrorsHandling; + const shouldDetectPerformanceIssues = issues.longTasks !== false + || issues.longAnimationFrames !== false + || issues.webVitals === true; + + if (shouldHandleErrors) { this.initGlobalHandlers(); - } - if (settings.vue) { - this.connectVue(settings.vue); + if (shouldDetectPerformanceIssues) { + this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context)); + } } } diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index bdc904c8..3428d208 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -2,7 +2,7 @@ import type { EventContext, AffectedUser } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; import type { Transport } from './transport'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; -import type { MainThreadBlockingOptions } from '../addons/longTasks'; +import type { IssuesOptions } from './issues'; /** * JS Catcher initial settings @@ -62,6 +62,8 @@ export interface HawkInitialSettings { /** * Do not initialize global errors handling * This options still allow you send events manually + * + * @deprecated Use `issues.errors` instead. */ disableGlobalErrorsHandling?: boolean; @@ -101,20 +103,8 @@ export interface HawkInitialSettings { transport?: Transport; /** - * Main-thread blocking detection. - * Observes Long Tasks and Long Animation Frames (LoAF) via PerformanceObserver - * and sends a dedicated event when blocking is detected. - * - * This is an umbrella option by design: Long Tasks and LoAF describe the same - * domain (main-thread blocking), so both toggles live under one config key. - * - * Chromium-only (Chrome, Edge). On unsupported browsers the observers - * simply won't start — no errors, no overhead. - * - * Pass `false` to disable entirely. - * Pass an options object to toggle individual observers. - * - * @default enabled with default options (both longTasks and longAnimationFrames on) + * Issues configuration: + * `errors`, `webVitals`, `longTasks`, `longAnimationFrames`. */ - mainThreadBlocking?: false | MainThreadBlockingOptions; + issues?: IssuesOptions; } diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index f3354c30..3f828640 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -1,18 +1,39 @@ import type { CatcherMessage } from './catcher-message'; import type { HawkInitialSettings } from './hawk-initial-settings'; +import type { + IssuesOptions, + IssueThresholdOptions, + IssueEvent, + LongTaskAttribution, + LongTaskPerformanceEntry, + LoAFScript, + LoAFEntry, + WebVitalMetric, + WebVitalRating, + WebVitalsReport, +} from './issues'; import type { Transport } from './transport'; import type { HawkJavaScriptEvent } from './event'; import type { VueIntegrationData, NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations } from './integrations'; import type { BreadcrumbsAPI } from './breadcrumbs-api'; - export type { CatcherMessage, HawkInitialSettings, + IssuesOptions, + IssueThresholdOptions, + IssueEvent, + LongTaskAttribution, + LongTaskPerformanceEntry, + LoAFScript, + LoAFEntry, Transport, HawkJavaScriptEvent, VueIntegrationData, NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations, - BreadcrumbsAPI + BreadcrumbsAPI, + WebVitalMetric, + WebVitalRating, + WebVitalsReport }; diff --git a/packages/javascript/src/types/long-tasks.ts b/packages/javascript/src/types/issues.ts similarity index 58% rename from packages/javascript/src/types/long-tasks.ts rename to packages/javascript/src/types/issues.ts index a78ffef5..d9c9d687 100644 --- a/packages/javascript/src/types/long-tasks.ts +++ b/packages/javascript/src/types/issues.ts @@ -1,3 +1,44 @@ +import type { EventContext } from '@hawk.so/types'; + +/** + * Per-issue threshold configuration. + */ +export interface IssueThresholdOptions { + /** + * Minimum duration (ms) required to emit an issue event. + */ + thresholdMs?: number; +} + +/** + * Issues configuration. + */ +export interface IssuesOptions { + /** + * Long Tasks options. Set `false` to disable. + */ + longTasks?: false | IssueThresholdOptions; + + /** + * Long Animation Frames options. Set `false` to disable. + */ + longAnimationFrames?: false | IssueThresholdOptions; + + /** + * Enable automatic global errors handling. + * + * @default true + */ + errors?: boolean; + + /** + * Enable aggregated Web Vitals monitoring. + * + * @default false + */ + webVitals?: boolean; +} + /** * Long Task attribution information from Performance API. * Describes the container associated with the long task. @@ -70,3 +111,42 @@ export interface LoAFEntry extends PerformanceEntry { /** Script timing records for the frame */ scripts?: LoAFScript[]; } + +/** + * Web Vitals rating level. + */ +export type WebVitalRating = 'good' | 'needs-improvement' | 'poor'; + +/** + * Single Web Vital metric. + */ +export interface WebVitalMetric { + /** Metric name (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) */ + name: string; + /** Current metric value */ + value: number; + /** Computed rating for the metric */ + rating: WebVitalRating; + /** Delta from the previous reported value */ + delta: number; +} + +/** + * Aggregated Web Vitals report. + */ +export interface WebVitalsReport { + /** Human-readable summary of poor metrics */ + summary: string; + /** Number of poor metrics in this report */ + poorCount: number; + /** Full metrics map by metric name */ + metrics: Record; +} + +/** + * Payload sent by issues monitor to the catcher. + */ +export interface IssueEvent { + title: string; + context: EventContext; +} diff --git a/yarn.lock b/yarn.lock index 317217ec..cd73c862 100644 --- a/yarn.lock +++ b/yarn.lock @@ -587,6 +587,9 @@ __metadata: vite-plugin-dts: "npm:^4.2.4" vitest: "npm:^4.0.18" vue: "npm:^2" + web-vitals: "npm:^5.1.0" + peerDependencies: + web-vitals: ^5.1.0 languageName: unknown linkType: soft @@ -6193,6 +6196,13 @@ __metadata: languageName: node linkType: hard +"web-vitals@npm:^5.1.0": + version: 5.1.0 + resolution: "web-vitals@npm:5.1.0" + checksum: 10c0/1af22ddbe2836ba880fcb492cfba24c3349f4760ebb5e92f38324ea67bca3c4dbb9c86f1a32af4795b6115cdaf98b90000cf3a7402bffef6e8c503f0d1b2e706 + languageName: node + linkType: hard + "webidl-conversions@npm:^8.0.1": version: 8.0.1 resolution: "webidl-conversions@npm:8.0.1" From b4755a18d59ff7844867568d5b8c22a1f5729c86 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:40:14 +0300 Subject: [PATCH 11/14] refactor: update web-vitals handling and improve documentation. --- packages/javascript/README.md | 5 +- packages/javascript/src/addons/issues.ts | 52 ++++++++++++++++++-- packages/javascript/src/catcher.ts | 44 +++++++++-------- packages/javascript/src/types/index.ts | 2 +- packages/javascript/src/types/issues.ts | 3 +- packages/javascript/src/utils/compactJson.ts | 2 + yarn.lock | 13 ++--- 7 files changed, 82 insertions(+), 39 deletions(-) diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 5953647b..8f4e722b 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -255,6 +255,7 @@ Freeze detectors use two complementary APIs: Both freeze detectors are enabled by default. If one API is unsupported, the other still works. Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.). +`thresholdMs` is the max allowed duration budget. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`. ### Web Vitals (Aggregated) @@ -306,8 +307,8 @@ const hawk = new HawkCatcher({ |--------|------|---------|-------------| | `errors` | `boolean` | `true` | Enable global errors handling (`window.onerror` and `unhandledrejection`). | | `webVitals` | `boolean` | `false` | Collect all Core Web Vitals and send one issue event when at least one metric is rated `poor`. Requires optional `web-vitals` dependency. | -| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 100 }` | Detect long tasks and emit issue events when duration is greater than threshold. | -| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 500 }` | Detect LoAF events and emit issue events when duration is greater than threshold. Requires Chrome 123+ / Edge 123+. | +| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 100 }` | Detect long tasks and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). | +| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 500 }` | Detect LoAF events and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). Requires Chrome 123+ / Edge 123+. | ## Source maps consuming diff --git a/packages/javascript/src/addons/issues.ts b/packages/javascript/src/addons/issues.ts index 41cc32ac..a189fef1 100644 --- a/packages/javascript/src/addons/issues.ts +++ b/packages/javascript/src/addons/issues.ts @@ -7,13 +7,14 @@ import type { LongTaskPerformanceEntry, WebVitalMetric, WebVitalRating, - WebVitalsReport, + WebVitalsReport } from '../types/issues'; import { compactJson } from '../utils/compactJson'; import log from '../utils/log'; const DEFAULT_LONG_TASK_THRESHOLD_MS = 100; const DEFAULT_LOAF_THRESHOLD_MS = 500; +const MIN_ISSUE_THRESHOLD_MS = 50; const METRIC_THRESHOLDS: Record = { LCP: [2500, 4000], @@ -38,6 +39,9 @@ export class IssuesMonitor { /** * Initialize selected issue detectors. + * + * @param options detectors config + * @param onIssue issue callback */ public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void { if (options.longTasks !== false) { @@ -70,6 +74,11 @@ export class IssuesMonitor { this.loafObserver = null; } + /** + * + * @param thresholdMs max allowed duration + * @param onIssue issue callback + */ private observeLongTasks(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { if (!supportsEntryType('longtask')) { return; @@ -108,12 +117,18 @@ export class IssuesMonitor { } }); - this.longTaskObserver.observe({ type: 'longtask', buffered: true }); + this.longTaskObserver.observe({ type: 'longtask', + buffered: true }); } catch { this.longTaskObserver = null; } } + /** + * + * @param thresholdMs max allowed duration + * @param onIssue issue callback + */ private observeLoAF(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { if (!supportsEntryType('long-animation-frame')) { return; @@ -175,12 +190,17 @@ export class IssuesMonitor { } }); - this.loafObserver.observe({ type: 'long-animation-frame', buffered: true }); + this.loafObserver.observe({ type: 'long-animation-frame', + buffered: true }); } catch { this.loafObserver = null; } } + /** + * + * @param onIssue issue callback + */ private observeWebVitals(onIssue: (event: IssueEvent) => void): void { void import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => { const collected: WebVitalMetric[] = []; @@ -255,6 +275,10 @@ export class IssuesMonitor { } } +/** + * + * @param type performance entry type + */ function supportsEntryType(type: string): boolean { try { return ( @@ -267,18 +291,32 @@ function supportsEntryType(type: string): boolean { } } +/** + * + * @param value custom threshold + * @param fallback default threshold + */ function resolveThreshold(value: number | undefined, fallback: number): number { if (typeof value !== 'number' || Number.isNaN(value)) { - return fallback; + return Math.max(MIN_ISSUE_THRESHOLD_MS, fallback); } - return Math.max(0, Math.round(value)); + return Math.max(MIN_ISSUE_THRESHOLD_MS, Math.round(value)); } +/** + * + * @param name metric name + * @param value metric value + */ function formatValue(name: string, value: number): string { return name === 'CLS' ? value.toFixed(3) : `${Math.round(value)}ms`; } +/** + * + * @param script loaf script entry + */ function serializeScript(script: LoAFScript): Json { return compactJson([ ['invoker', script.invoker], @@ -294,6 +332,10 @@ function serializeScript(script: LoAFScript): Json { ]); } +/** + * + * @param report aggregated vitals report + */ function serializeWebVitalsReport(report: WebVitalsReport): Json { const metrics = Object.entries(report.metrics).reduce((acc, [name, metric]) => { acc[name] = compactJson([ diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 6c8744f3..0264e0dd 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -190,27 +190,6 @@ export default class Catcher { } } - /** - * Configure issues-related features: - * - global errors handling - * - performance issue detectors (Long Tasks / LoAF) - */ - private configureIssues(settings: HawkInitialSettings): void { - const issues = settings.issues ?? {}; - const shouldHandleErrors = issues.errors ?? !settings.disableGlobalErrorsHandling; - const shouldDetectPerformanceIssues = issues.longTasks !== false - || issues.longAnimationFrames !== false - || issues.webVitals === true; - - if (shouldHandleErrors) { - this.initGlobalHandlers(); - - if (shouldDetectPerformanceIssues) { - this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context)); - } - } - } - /** * Generates user if no one provided via HawkCatcher settings * After generating, stores user for feature requests @@ -337,6 +316,29 @@ export default class Catcher { this.context = context; } + /** + * Configure issues-related features: + * - global errors handling + * - performance issue detectors (Long Tasks / LoAF) + * + * @param settings + */ + private configureIssues(settings: HawkInitialSettings): void { + const issues = settings.issues ?? {}; + const shouldHandleErrors = issues.errors ?? !settings.disableGlobalErrorsHandling; + const shouldDetectPerformanceIssues = issues.longTasks !== false + || issues.longAnimationFrames !== false + || issues.webVitals === true; + + if (shouldHandleErrors) { + this.initGlobalHandlers(); + + if (shouldDetectPerformanceIssues) { + this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context)); + } + } + } + /** * Init global errors handler */ diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index 3f828640..e43ad0c3 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -10,7 +10,7 @@ import type { LoAFEntry, WebVitalMetric, WebVitalRating, - WebVitalsReport, + WebVitalsReport } from './issues'; import type { Transport } from './transport'; import type { HawkJavaScriptEvent } from './event'; diff --git a/packages/javascript/src/types/issues.ts b/packages/javascript/src/types/issues.ts index d9c9d687..c12952b9 100644 --- a/packages/javascript/src/types/issues.ts +++ b/packages/javascript/src/types/issues.ts @@ -5,7 +5,8 @@ import type { EventContext } from '@hawk.so/types'; */ export interface IssueThresholdOptions { /** - * Minimum duration (ms) required to emit an issue event. + * Max allowed duration (ms). Emit issue when entry duration is >= this value. + * Values below 50ms are clamped to 50ms. */ thresholdMs?: number; } diff --git a/packages/javascript/src/utils/compactJson.ts b/packages/javascript/src/utils/compactJson.ts index 58f17941..50055b62 100644 --- a/packages/javascript/src/utils/compactJson.ts +++ b/packages/javascript/src/utils/compactJson.ts @@ -5,6 +5,8 @@ import type { Json, JsonNode } from '@hawk.so/types'; * Drops `null`, `undefined`, and empty strings. * * Useful for compact event payload construction without repetitive `if` chains. + * + * @param entries */ export function compactJson(entries: [string, JsonNode | null | undefined][]): Json { const result: Json = {}; diff --git a/yarn.lock b/yarn.lock index cd73c862..0d56ce53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -587,9 +587,11 @@ __metadata: vite-plugin-dts: "npm:^4.2.4" vitest: "npm:^4.0.18" vue: "npm:^2" - web-vitals: "npm:^5.1.0" peerDependencies: - web-vitals: ^5.1.0 + web-vitals: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + web-vitals: + optional: true languageName: unknown linkType: soft @@ -6196,13 +6198,6 @@ __metadata: languageName: node linkType: hard -"web-vitals@npm:^5.1.0": - version: 5.1.0 - resolution: "web-vitals@npm:5.1.0" - checksum: 10c0/1af22ddbe2836ba880fcb492cfba24c3349f4760ebb5e92f38324ea67bca3c4dbb9c86f1a32af4795b6115cdaf98b90000cf3a7402bffef6e8c503f0d1b2e706 - languageName: node - linkType: hard - "webidl-conversions@npm:^8.0.1": version: 8.0.1 resolution: "webidl-conversions@npm:8.0.1" From d905d8bc3ccd7c35ddf02d6502675e6cc169d636 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:39:02 +0300 Subject: [PATCH 12/14] chore: tests --- packages/javascript/README.md | 17 +- packages/javascript/src/addons/issues.ts | 81 ++++++-- packages/javascript/src/catcher.ts | 10 +- packages/javascript/src/types/issues.ts | 22 ++- packages/javascript/src/types/web-vitals.d.ts | 16 ++ .../javascript/tests/issues-monitor.test.ts | 186 ++++++++++++++++++ 6 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 packages/javascript/src/types/web-vitals.d.ts create mode 100644 packages/javascript/tests/issues-monitor.test.ts diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 8f4e722b..f03ced2e 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -250,16 +250,17 @@ It currently includes three groups: Freeze detectors use two complementary APIs: -- **Long Tasks API** — reports any task taking longer than 50 ms. -- **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+). +- **Long Tasks API** — browser reports tasks taking longer than 50 ms. +- **Long Animation Frames (LoAF)** — browser reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+). Both freeze detectors are enabled by default. If one API is unsupported, the other still works. Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.). -`thresholdMs` is the max allowed duration budget. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`. +`thresholdMs` is an additional Hawk filter on top of browser reporting. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`. ### Web Vitals (Aggregated) -When `issues.webVitals` is enabled, Hawk collects all Core Web Vitals (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) and sends a single issue event when at least one metric is rated `poor`. +When `issues.webVitals` is enabled, Hawk collects Core Web Vitals (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) and sends a single issue event when at least one metric is rated `poor`. +Reporting happens when all five metrics are collected, or earlier on timeout/page unload to avoid waiting indefinitely on pages where some metrics never fire. The event context contains all metrics with: - `value` @@ -292,10 +293,10 @@ const hawk = new HawkCatcher({ errors: true, webVitals: true, longTasks: { - thresholdMs: 100 + thresholdMs: 70 }, longAnimationFrames: { - thresholdMs: 500 + thresholdMs: 200 } } }); @@ -307,8 +308,8 @@ const hawk = new HawkCatcher({ |--------|------|---------|-------------| | `errors` | `boolean` | `true` | Enable global errors handling (`window.onerror` and `unhandledrejection`). | | `webVitals` | `boolean` | `false` | Collect all Core Web Vitals and send one issue event when at least one metric is rated `poor`. Requires optional `web-vitals` dependency. | -| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 100 }` | Detect long tasks and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). | -| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 500 }` | Detect LoAF events and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). Requires Chrome 123+ / Edge 123+. | +| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 70 }` | Detect long tasks and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). | +| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 200 }` | Detect LoAF events and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). Requires Chrome 123+ / Edge 123+. | ## Source maps consuming diff --git a/packages/javascript/src/addons/issues.ts b/packages/javascript/src/addons/issues.ts index a189fef1..86114bc0 100644 --- a/packages/javascript/src/addons/issues.ts +++ b/packages/javascript/src/addons/issues.ts @@ -12,9 +12,10 @@ import type { import { compactJson } from '../utils/compactJson'; import log from '../utils/log'; -const DEFAULT_LONG_TASK_THRESHOLD_MS = 100; -const DEFAULT_LOAF_THRESHOLD_MS = 500; +const DEFAULT_LONG_TASK_THRESHOLD_MS = 70; +const DEFAULT_LOAF_THRESHOLD_MS = 200; const MIN_ISSUE_THRESHOLD_MS = 50; +const WEB_VITALS_REPORT_TIMEOUT_MS = 10000; const METRIC_THRESHOLDS: Record = { LCP: [2500, 4000], @@ -35,6 +36,8 @@ const TOTAL_WEB_VITALS = 5; export class IssuesMonitor { private longTaskObserver: PerformanceObserver | null = null; private loafObserver: PerformanceObserver | null = null; + private webVitalsCleanup: (() => void) | null = null; + private isInitialized = false; private destroyed = false; /** @@ -44,6 +47,13 @@ export class IssuesMonitor { * @param onIssue issue callback */ public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void { + if (this.isInitialized) { + return; + } + + this.isInitialized = true; + this.destroyed = false; + if (options.longTasks !== false) { this.observeLongTasks( resolveThreshold(options.longTasks?.thresholdMs, DEFAULT_LONG_TASK_THRESHOLD_MS), @@ -68,10 +78,13 @@ export class IssuesMonitor { */ public destroy(): void { this.destroyed = true; + this.isInitialized = false; this.longTaskObserver?.disconnect(); this.loafObserver?.disconnect(); + this.webVitalsCleanup?.(); this.longTaskObserver = null; this.loafObserver = null; + this.webVitalsCleanup = null; } /** @@ -203,22 +216,50 @@ export class IssuesMonitor { */ private observeWebVitals(onIssue: (event: IssueEvent) => void): void { void import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => { - const collected: WebVitalMetric[] = []; + if (this.destroyed) { + return; + } + + const collected: Record = {}; let reported = false; + let timeoutId: ReturnType | null = null; + let handlePageHide: (() => void) | null = null; + + const cleanup = (): void => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } - const tryReport = (): void => { - if (this.destroyed || reported || collected.length < TOTAL_WEB_VITALS) { + if (typeof window !== 'undefined' && handlePageHide !== null) { + window.removeEventListener('pagehide', handlePageHide); + } + }; + + const tryReport = (force: boolean): void => { + if (this.destroyed || reported) { return; } - reported = true; + const metrics = Object.values(collected); + + if (metrics.length === 0) { + return; + } + + if (!force && metrics.length < TOTAL_WEB_VITALS) { + return; + } - const poor = collected.filter((metric) => metric.rating === 'poor'); + const poor = metrics.filter((metric) => metric.rating === 'poor'); if (poor.length === 0) { return; } + reported = true; + cleanup(); + const summary = poor .map((metric) => { const thresholds = METRIC_THRESHOLDS[metric.name]; @@ -231,11 +272,7 @@ export class IssuesMonitor { const report: WebVitalsReport = { summary, poorCount: poor.length, - metrics: collected.reduce>((acc, metric) => { - acc[metric.name] = metric; - - return acc; - }, {}), + metrics: { ...collected }, }; onIssue({ @@ -251,16 +288,30 @@ export class IssuesMonitor { return; } - collected.push({ + collected[metric.name] = { name: metric.name, value: metric.value, rating: metric.rating, delta: metric.delta, - }); + }; + + tryReport(false); + }; - tryReport(); + handlePageHide = (): void => { + tryReport(true); }; + timeoutId = setTimeout(() => { + tryReport(true); + }, WEB_VITALS_REPORT_TIMEOUT_MS); + + if (typeof window !== 'undefined' && handlePageHide !== null) { + window.addEventListener('pagehide', handlePageHide); + } + + this.webVitalsCleanup = cleanup; + onCLS(collect); onINP(collect); onLCP(collect); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 0264e0dd..6b4fbcd9 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -325,17 +325,17 @@ export default class Catcher { */ private configureIssues(settings: HawkInitialSettings): void { const issues = settings.issues ?? {}; - const shouldHandleErrors = issues.errors ?? !settings.disableGlobalErrorsHandling; + const shouldHandleGlobalErrors = settings.disableGlobalErrorsHandling !== true && issues.errors !== false; const shouldDetectPerformanceIssues = issues.longTasks !== false || issues.longAnimationFrames !== false || issues.webVitals === true; - if (shouldHandleErrors) { + if (shouldHandleGlobalErrors) { this.initGlobalHandlers(); + } - if (shouldDetectPerformanceIssues) { - this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context)); - } + if (shouldDetectPerformanceIssues) { + this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context)); } } diff --git a/packages/javascript/src/types/issues.ts b/packages/javascript/src/types/issues.ts index c12952b9..d2613f92 100644 --- a/packages/javascript/src/types/issues.ts +++ b/packages/javascript/src/types/issues.ts @@ -16,28 +16,32 @@ export interface IssueThresholdOptions { */ export interface IssuesOptions { /** - * Long Tasks options. Set `false` to disable. + * Enable automatic global errors handling. + * + * @default true */ - longTasks?: false | IssueThresholdOptions; + errors?: boolean; /** - * Long Animation Frames options. Set `false` to disable. + * Enable aggregated Web Vitals monitoring. + * + * @default false */ - longAnimationFrames?: false | IssueThresholdOptions; + webVitals?: boolean; /** - * Enable automatic global errors handling. + * Long Tasks options. Set `false` to disable. * - * @default true + * @default false */ - errors?: boolean; + longTasks?: false | IssueThresholdOptions; /** - * Enable aggregated Web Vitals monitoring. + * Long Animation Frames options. Set `false` to disable. * * @default false */ - webVitals?: boolean; + longAnimationFrames?: false | IssueThresholdOptions; } /** diff --git a/packages/javascript/src/types/web-vitals.d.ts b/packages/javascript/src/types/web-vitals.d.ts new file mode 100644 index 00000000..347daf63 --- /dev/null +++ b/packages/javascript/src/types/web-vitals.d.ts @@ -0,0 +1,16 @@ +declare module 'web-vitals' { + export interface Metric { + name: string; + value: number; + rating: 'good' | 'needs-improvement' | 'poor'; + delta: number; + } + + export type ReportCallback = (metric: Metric) => void; + + export function onCLS(callback: ReportCallback): void; + export function onINP(callback: ReportCallback): void; + export function onLCP(callback: ReportCallback): void; + export function onFCP(callback: ReportCallback): void; + export function onTTFB(callback: ReportCallback): void; +} diff --git a/packages/javascript/tests/issues-monitor.test.ts b/packages/javascript/tests/issues-monitor.test.ts new file mode 100644 index 00000000..4abf8c5d --- /dev/null +++ b/packages/javascript/tests/issues-monitor.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Metric, ReportCallback } from 'web-vitals'; + +class MockPerformanceObserver { + public static supportedEntryTypes: string[] = ['longtask', 'long-animation-frame']; + public static instances: MockPerformanceObserver[] = []; + + public disconnected = false; + public observedType: string | null = null; + + private readonly callback: PerformanceObserverCallback; + + public constructor(callback: PerformanceObserverCallback) { + this.callback = callback; + MockPerformanceObserver.instances.push(this); + } + + public observe(options: PerformanceObserverInit & { type?: string }): void { + this.observedType = options.type ?? null; + } + + public disconnect(): void { + this.disconnected = true; + } + + public emit(entries: PerformanceEntry[]): void { + this.callback( + { getEntries: () => entries } as PerformanceObserverEntryList, + this as unknown as PerformanceObserver + ); + } + + public static byType(type: string): MockPerformanceObserver | undefined { + return MockPerformanceObserver.instances.find((instance) => instance.observedType === type); + } + + public static reset(): void { + MockPerformanceObserver.instances = []; + MockPerformanceObserver.supportedEntryTypes = ['longtask', 'long-animation-frame']; + } +} + +function entry(type: string, duration: number, extra: Record = {}): PerformanceEntry { + return { + entryType: type, + name: type, + startTime: 0, + duration, + toJSON: () => ({}), + ...extra, + } as PerformanceEntry; +} + +function mockWebVitals() { + const callbacks: Record = {}; + + vi.doMock('web-vitals', () => ({ + onCLS: (cb: ReportCallback) => { callbacks.CLS = cb; }, + onINP: (cb: ReportCallback) => { callbacks.INP = cb; }, + onLCP: (cb: ReportCallback) => { callbacks.LCP = cb; }, + onFCP: (cb: ReportCallback) => { callbacks.FCP = cb; }, + onTTFB: (cb: ReportCallback) => { callbacks.TTFB = cb; }, + })); + + return { + emit(metric: Metric): void { + callbacks[metric.name]?.(metric); + }, + }; +} + +describe('IssuesMonitor', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + MockPerformanceObserver.reset(); + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver as unknown as typeof PerformanceObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should clamp long task threshold to 50ms minimum', async () => { + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const onIssue = vi.fn(); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: { thresholdMs: 1 }, longAnimationFrames: false, webVitals: false }, onIssue); + const observer = MockPerformanceObserver.byType('longtask'); + + expect(observer).toBeDefined(); + + observer!.emit([entry('longtask', 49)]); + expect(onIssue).not.toHaveBeenCalled(); + + observer!.emit([entry('longtask', 50)]); + expect(onIssue).toHaveBeenCalledTimes(1); + }); + + it('should emit only entries that are >= configured threshold', async () => { + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const onIssue = vi.fn(); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: { thresholdMs: 70 }, longAnimationFrames: false, webVitals: false }, onIssue); + const observer = MockPerformanceObserver.byType('longtask'); + + expect(observer).toBeDefined(); + + observer!.emit([entry('longtask', 55)]); + expect(onIssue).not.toHaveBeenCalled(); + + observer!.emit([entry('longtask', 75)]); + expect(onIssue).toHaveBeenCalledTimes(1); + expect(onIssue.mock.calls[0][0].title).toContain('75 ms'); + }); + + it('should ignore second init call and avoid duplicate observers', async () => { + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const monitor = new IssuesMonitor(); + const onIssue = vi.fn(); + + monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, onIssue); + monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, onIssue); + + expect(MockPerformanceObserver.instances).toHaveLength(2); + }); + + it('should disconnect and stop reporting after destroy', async () => { + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const onIssue = vi.fn(); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: {}, longAnimationFrames: false, webVitals: false }, onIssue); + const observer = MockPerformanceObserver.byType('longtask'); + + expect(observer).toBeDefined(); + + observer!.emit([entry('longtask', 120)]); + expect(onIssue).toHaveBeenCalledTimes(1); + + monitor.destroy(); + expect(observer!.disconnected).toBe(true); + + observer!.emit([entry('longtask', 130)]); + expect(onIssue).toHaveBeenCalledTimes(1); + }); + + it('should skip observers when performance entry types are unsupported', async () => { + MockPerformanceObserver.supportedEntryTypes = []; + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, vi.fn()); + + expect(MockPerformanceObserver.instances).toHaveLength(0); + }); + + it('should report poor web vitals on timeout even when not all 5 metrics fired', async () => { + vi.useFakeTimers(); + const webVitals = mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const onIssue = vi.fn(); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue); + await vi.dynamicImportSettled(); + + webVitals.emit({ name: 'LCP', value: 5000, rating: 'poor', delta: 5000 }); + vi.advanceTimersByTime(10000); + + expect(onIssue).toHaveBeenCalledTimes(1); + expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vitals'); + expect(onIssue.mock.calls[0][0].context).toHaveProperty('webVitals'); + }); +}); From 6ec3d8393f1357d7f9ad071f2ca126e6c7a6b0b7 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:00:34 +0300 Subject: [PATCH 13/14] fix: readme & tests --- packages/javascript/README.md | 6 +-- packages/javascript/src/addons/issues.ts | 28 +++++++++---- packages/javascript/src/catcher.ts | 4 +- packages/javascript/src/types/issues.ts | 14 +++++-- .../javascript/tests/issues-monitor.test.ts | 39 +++++++++++++++---- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/javascript/README.md b/packages/javascript/README.md index f03ced2e..00e0c2e5 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -253,7 +253,7 @@ Freeze detectors use two complementary APIs: - **Long Tasks API** — browser reports tasks taking longer than 50 ms. - **Long Animation Frames (LoAF)** — browser reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+). -Both freeze detectors are enabled by default. If one API is unsupported, the other still works. +Both freeze detectors are disabled by default. If enabled and one API is unsupported, the other still works. Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.). `thresholdMs` is an additional Hawk filter on top of browser reporting. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`. @@ -308,8 +308,8 @@ const hawk = new HawkCatcher({ |--------|------|---------|-------------| | `errors` | `boolean` | `true` | Enable global errors handling (`window.onerror` and `unhandledrejection`). | | `webVitals` | `boolean` | `false` | Collect all Core Web Vitals and send one issue event when at least one metric is rated `poor`. Requires optional `web-vitals` dependency. | -| `longTasks` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 70 }` | Detect long tasks and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). | -| `longAnimationFrames` | `false` or `{ thresholdMs?: number }` | `{ thresholdMs: 200 }` | Detect LoAF events and emit issue events when duration is equal to or greater than the max allowed duration (`thresholdMs`, minimum effective value is `50ms`). Requires Chrome 123+ / Edge 123+. | +| `longTasks` | `boolean` or `{ thresholdMs?: number }` | `false` | `false` disables. `true` enables with default threshold. Object enables and uses `thresholdMs` when valid; otherwise fallback threshold `70ms` is used (minimum effective value `50ms`). | +| `longAnimationFrames` | `boolean` or `{ thresholdMs?: number }` | `false` | `false` disables. `true` enables with default threshold. Object enables and uses `thresholdMs` when valid; otherwise fallback threshold `200ms` is used (minimum effective value `50ms`). Requires Chrome 123+ / Edge 123+. | ## Source maps consuming diff --git a/packages/javascript/src/addons/issues.ts b/packages/javascript/src/addons/issues.ts index 86114bc0..ef56733a 100644 --- a/packages/javascript/src/addons/issues.ts +++ b/packages/javascript/src/addons/issues.ts @@ -12,10 +12,10 @@ import type { import { compactJson } from '../utils/compactJson'; import log from '../utils/log'; -const DEFAULT_LONG_TASK_THRESHOLD_MS = 70; -const DEFAULT_LOAF_THRESHOLD_MS = 200; -const MIN_ISSUE_THRESHOLD_MS = 50; -const WEB_VITALS_REPORT_TIMEOUT_MS = 10000; +export const DEFAULT_LONG_TASK_THRESHOLD_MS = 70; +export const DEFAULT_LOAF_THRESHOLD_MS = 200; +export const MIN_ISSUE_THRESHOLD_MS = 50; +export const WEB_VITALS_REPORT_TIMEOUT_MS = 10000; const METRIC_THRESHOLDS: Record = { LCP: [2500, 4000], @@ -54,16 +54,16 @@ export class IssuesMonitor { this.isInitialized = true; this.destroyed = false; - if (options.longTasks !== false) { + if (options.longTasks !== undefined && options.longTasks !== false) { this.observeLongTasks( - resolveThreshold(options.longTasks?.thresholdMs, DEFAULT_LONG_TASK_THRESHOLD_MS), + resolveThreshold(resolveThresholdOption(options.longTasks), DEFAULT_LONG_TASK_THRESHOLD_MS), onIssue ); } - if (options.longAnimationFrames !== false) { + if (options.longAnimationFrames !== undefined && options.longAnimationFrames !== false) { this.observeLoAF( - resolveThreshold(options.longAnimationFrames?.thresholdMs, DEFAULT_LOAF_THRESHOLD_MS), + resolveThreshold(resolveThresholdOption(options.longAnimationFrames), DEFAULT_LOAF_THRESHOLD_MS), onIssue ); } @@ -355,6 +355,18 @@ function resolveThreshold(value: number | undefined, fallback: number): number { return Math.max(MIN_ISSUE_THRESHOLD_MS, Math.round(value)); } +/** + * + * @param value + */ +function resolveThresholdOption(value: boolean | { thresholdMs?: number }): number | undefined { + if (typeof value === 'object' && value !== null) { + return value.thresholdMs; + } + + return undefined; +} + /** * * @param name metric name diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 6b4fbcd9..c625a5c4 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -326,8 +326,8 @@ export default class Catcher { private configureIssues(settings: HawkInitialSettings): void { const issues = settings.issues ?? {}; const shouldHandleGlobalErrors = settings.disableGlobalErrorsHandling !== true && issues.errors !== false; - const shouldDetectPerformanceIssues = issues.longTasks !== false - || issues.longAnimationFrames !== false + const shouldDetectPerformanceIssues = (issues.longTasks !== undefined && issues.longTasks !== false) + || (issues.longAnimationFrames !== undefined && issues.longAnimationFrames !== false) || issues.webVitals === true; if (shouldHandleGlobalErrors) { diff --git a/packages/javascript/src/types/issues.ts b/packages/javascript/src/types/issues.ts index d2613f92..9e4371ac 100644 --- a/packages/javascript/src/types/issues.ts +++ b/packages/javascript/src/types/issues.ts @@ -30,18 +30,24 @@ export interface IssuesOptions { webVitals?: boolean; /** - * Long Tasks options. Set `false` to disable. + * Long Tasks options. + * `false` disables the feature. + * Any other value enables it with default threshold. + * If `thresholdMs` is a valid number greater than or equal to 50, it is used. * * @default false */ - longTasks?: false | IssueThresholdOptions; + longTasks?: boolean | IssueThresholdOptions; /** - * Long Animation Frames options. Set `false` to disable. + * Long Animation Frames options. + * `false` disables the feature. + * Any other value enables it with default threshold. + * If `thresholdMs` is a valid number greater than or equal to 50, it is used. * * @default false */ - longAnimationFrames?: false | IssueThresholdOptions; + longAnimationFrames?: boolean | IssueThresholdOptions; } /** diff --git a/packages/javascript/tests/issues-monitor.test.ts b/packages/javascript/tests/issues-monitor.test.ts index 4abf8c5d..a760e0aa 100644 --- a/packages/javascript/tests/issues-monitor.test.ts +++ b/packages/javascript/tests/issues-monitor.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Metric, ReportCallback } from 'web-vitals'; +import { + DEFAULT_LONG_TASK_THRESHOLD_MS, + MIN_ISSUE_THRESHOLD_MS, + WEB_VITALS_REPORT_TIMEOUT_MS, +} from '../src/addons/issues'; class MockPerformanceObserver { public static supportedEntryTypes: string[] = ['longtask', 'long-animation-frame']; @@ -96,10 +101,10 @@ describe('IssuesMonitor', () => { expect(observer).toBeDefined(); - observer!.emit([entry('longtask', 49)]); + observer!.emit([entry('longtask', MIN_ISSUE_THRESHOLD_MS - 1)]); expect(onIssue).not.toHaveBeenCalled(); - observer!.emit([entry('longtask', 50)]); + observer!.emit([entry('longtask', MIN_ISSUE_THRESHOLD_MS)]); expect(onIssue).toHaveBeenCalledTimes(1); }); @@ -109,17 +114,37 @@ describe('IssuesMonitor', () => { const onIssue = vi.fn(); const monitor = new IssuesMonitor(); - monitor.init({ longTasks: { thresholdMs: 70 }, longAnimationFrames: false, webVitals: false }, onIssue); + const customThresholdMs = 75; + + monitor.init({ longTasks: { thresholdMs: customThresholdMs }, longAnimationFrames: false, webVitals: false }, onIssue); + const observer = MockPerformanceObserver.byType('longtask'); + + expect(observer).toBeDefined(); + + observer!.emit([entry('longtask', customThresholdMs - 1)]); + expect(onIssue).not.toHaveBeenCalled(); + + observer!.emit([entry('longtask', customThresholdMs)]); + expect(onIssue).toHaveBeenCalledTimes(1); + expect(onIssue.mock.calls[0][0].title).toContain(`${customThresholdMs} ms`); + }); + + it('should use default threshold when longTasks is true', async () => { + mockWebVitals(); + const { IssuesMonitor } = await import('../src/addons/issues'); + const onIssue = vi.fn(); + const monitor = new IssuesMonitor(); + + monitor.init({ longTasks: true, longAnimationFrames: false, webVitals: false }, onIssue); const observer = MockPerformanceObserver.byType('longtask'); expect(observer).toBeDefined(); - observer!.emit([entry('longtask', 55)]); + observer!.emit([entry('longtask', DEFAULT_LONG_TASK_THRESHOLD_MS - 1)]); expect(onIssue).not.toHaveBeenCalled(); - observer!.emit([entry('longtask', 75)]); + observer!.emit([entry('longtask', DEFAULT_LONG_TASK_THRESHOLD_MS)]); expect(onIssue).toHaveBeenCalledTimes(1); - expect(onIssue.mock.calls[0][0].title).toContain('75 ms'); }); it('should ignore second init call and avoid duplicate observers', async () => { @@ -177,7 +202,7 @@ describe('IssuesMonitor', () => { await vi.dynamicImportSettled(); webVitals.emit({ name: 'LCP', value: 5000, rating: 'poor', delta: 5000 }); - vi.advanceTimersByTime(10000); + vi.advanceTimersByTime(WEB_VITALS_REPORT_TIMEOUT_MS); expect(onIssue).toHaveBeenCalledTimes(1); expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vitals'); From 2619d36b2574853c6c586a09a1d058087e27f4f7 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:24:49 +0300 Subject: [PATCH 14/14] fix: naming --- .../{issues.ts => performance-issues.ts} | 16 +++++----- packages/javascript/src/catcher.ts | 4 +-- packages/javascript/src/types/index.ts | 10 +++--- packages/javascript/src/types/issues.ts | 31 ++++++++++-------- ...tor.test.ts => performance-issues.test.ts} | 32 +++++++++---------- 5 files changed, 50 insertions(+), 43 deletions(-) rename packages/javascript/src/addons/{issues.ts => performance-issues.ts} (96%) rename packages/javascript/tests/{issues-monitor.test.ts => performance-issues.test.ts} (85%) diff --git a/packages/javascript/src/addons/issues.ts b/packages/javascript/src/addons/performance-issues.ts similarity index 96% rename from packages/javascript/src/addons/issues.ts rename to packages/javascript/src/addons/performance-issues.ts index ef56733a..56ce54f5 100644 --- a/packages/javascript/src/addons/issues.ts +++ b/packages/javascript/src/addons/performance-issues.ts @@ -1,7 +1,7 @@ import type { Json } from '@hawk.so/types'; import type { - IssueEvent, - IssuesOptions, + PerformanceIssueEvent, + PerformanceIssuesOptions, LoAFEntry, LoAFScript, LongTaskPerformanceEntry, @@ -28,12 +28,12 @@ const METRIC_THRESHOLDS: Record = { const TOTAL_WEB_VITALS = 5; /** - * Issues monitor handles: + * Performance issues monitor handles: * - Long Tasks * - Long Animation Frames (LoAF) * - Aggregated Web Vitals report */ -export class IssuesMonitor { +export class PerformanceIssuesMonitor { private longTaskObserver: PerformanceObserver | null = null; private loafObserver: PerformanceObserver | null = null; private webVitalsCleanup: (() => void) | null = null; @@ -46,7 +46,7 @@ export class IssuesMonitor { * @param options detectors config * @param onIssue issue callback */ - public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void { + public init(options: PerformanceIssuesOptions, onIssue: (event: PerformanceIssueEvent) => void): void { if (this.isInitialized) { return; } @@ -92,7 +92,7 @@ export class IssuesMonitor { * @param thresholdMs max allowed duration * @param onIssue issue callback */ - private observeLongTasks(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { + private observeLongTasks(thresholdMs: number, onIssue: (event: PerformanceIssueEvent) => void): void { if (!supportsEntryType('longtask')) { return; } @@ -142,7 +142,7 @@ export class IssuesMonitor { * @param thresholdMs max allowed duration * @param onIssue issue callback */ - private observeLoAF(thresholdMs: number, onIssue: (event: IssueEvent) => void): void { + private observeLoAF(thresholdMs: number, onIssue: (event: PerformanceIssueEvent) => void): void { if (!supportsEntryType('long-animation-frame')) { return; } @@ -214,7 +214,7 @@ export class IssuesMonitor { * * @param onIssue issue callback */ - private observeWebVitals(onIssue: (event: IssueEvent) => void): void { + private observeWebVitals(onIssue: (event: PerformanceIssueEvent) => void): void { void import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => { if (this.destroyed) { return; diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index c625a5c4..474b6e33 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -18,7 +18,7 @@ import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { IssuesMonitor } from './addons/issues'; +import { PerformanceIssuesMonitor } from './addons/performance-issues'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; /** @@ -115,7 +115,7 @@ export default class Catcher { /** * Issues monitor instance */ - private readonly issuesMonitor = new IssuesMonitor(); + private readonly issuesMonitor = new PerformanceIssuesMonitor(); /** * Catcher constructor diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index e43ad0c3..a7e6e7a5 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -2,8 +2,9 @@ import type { CatcherMessage } from './catcher-message'; import type { HawkInitialSettings } from './hawk-initial-settings'; import type { IssuesOptions, - IssueThresholdOptions, - IssueEvent, + PerformanceIssuesOptions, + PerformanceIssueThresholdOptions, + PerformanceIssueEvent, LongTaskAttribution, LongTaskPerformanceEntry, LoAFScript, @@ -20,8 +21,9 @@ export type { CatcherMessage, HawkInitialSettings, IssuesOptions, - IssueThresholdOptions, - IssueEvent, + PerformanceIssuesOptions, + PerformanceIssueThresholdOptions, + PerformanceIssueEvent, LongTaskAttribution, LongTaskPerformanceEntry, LoAFScript, diff --git a/packages/javascript/src/types/issues.ts b/packages/javascript/src/types/issues.ts index 9e4371ac..c4b5b6b0 100644 --- a/packages/javascript/src/types/issues.ts +++ b/packages/javascript/src/types/issues.ts @@ -3,7 +3,7 @@ import type { EventContext } from '@hawk.so/types'; /** * Per-issue threshold configuration. */ -export interface IssueThresholdOptions { +export interface PerformanceIssueThresholdOptions { /** * Max allowed duration (ms). Emit issue when entry duration is >= this value. * Values below 50ms are clamped to 50ms. @@ -12,16 +12,9 @@ export interface IssueThresholdOptions { } /** - * Issues configuration. + * Performance issues configuration. */ -export interface IssuesOptions { - /** - * Enable automatic global errors handling. - * - * @default true - */ - errors?: boolean; - +export interface PerformanceIssuesOptions { /** * Enable aggregated Web Vitals monitoring. * @@ -37,7 +30,7 @@ export interface IssuesOptions { * * @default false */ - longTasks?: boolean | IssueThresholdOptions; + longTasks?: boolean | PerformanceIssueThresholdOptions; /** * Long Animation Frames options. @@ -47,7 +40,19 @@ export interface IssuesOptions { * * @default false */ - longAnimationFrames?: boolean | IssueThresholdOptions; + longAnimationFrames?: boolean | PerformanceIssueThresholdOptions; +} + +/** + * Full issues configuration. + */ +export interface IssuesOptions extends PerformanceIssuesOptions { + /** + * Enable automatic global errors handling. + * + * @default true + */ + errors?: boolean; } /** @@ -157,7 +162,7 @@ export interface WebVitalsReport { /** * Payload sent by issues monitor to the catcher. */ -export interface IssueEvent { +export interface PerformanceIssueEvent { title: string; context: EventContext; } diff --git a/packages/javascript/tests/issues-monitor.test.ts b/packages/javascript/tests/performance-issues.test.ts similarity index 85% rename from packages/javascript/tests/issues-monitor.test.ts rename to packages/javascript/tests/performance-issues.test.ts index a760e0aa..c34d697e 100644 --- a/packages/javascript/tests/issues-monitor.test.ts +++ b/packages/javascript/tests/performance-issues.test.ts @@ -4,7 +4,7 @@ import { DEFAULT_LONG_TASK_THRESHOLD_MS, MIN_ISSUE_THRESHOLD_MS, WEB_VITALS_REPORT_TIMEOUT_MS, -} from '../src/addons/issues'; +} from '../src/addons/performance-issues'; class MockPerformanceObserver { public static supportedEntryTypes: string[] = ['longtask', 'long-animation-frame']; @@ -74,7 +74,7 @@ function mockWebVitals() { }; } -describe('IssuesMonitor', () => { +describe('PerformanceIssuesMonitor', () => { beforeEach(() => { vi.resetModules(); vi.unstubAllGlobals(); @@ -92,9 +92,9 @@ describe('IssuesMonitor', () => { it('should clamp long task threshold to 50ms minimum', async () => { mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); const onIssue = vi.fn(); - const monitor = new IssuesMonitor(); + const monitor = new PerformanceIssuesMonitor(); monitor.init({ longTasks: { thresholdMs: 1 }, longAnimationFrames: false, webVitals: false }, onIssue); const observer = MockPerformanceObserver.byType('longtask'); @@ -110,9 +110,9 @@ describe('IssuesMonitor', () => { it('should emit only entries that are >= configured threshold', async () => { mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); const onIssue = vi.fn(); - const monitor = new IssuesMonitor(); + const monitor = new PerformanceIssuesMonitor(); const customThresholdMs = 75; @@ -131,9 +131,9 @@ describe('IssuesMonitor', () => { it('should use default threshold when longTasks is true', async () => { mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); const onIssue = vi.fn(); - const monitor = new IssuesMonitor(); + const monitor = new PerformanceIssuesMonitor(); monitor.init({ longTasks: true, longAnimationFrames: false, webVitals: false }, onIssue); const observer = MockPerformanceObserver.byType('longtask'); @@ -149,8 +149,8 @@ describe('IssuesMonitor', () => { it('should ignore second init call and avoid duplicate observers', async () => { mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); - const monitor = new IssuesMonitor(); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); + const monitor = new PerformanceIssuesMonitor(); const onIssue = vi.fn(); monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, onIssue); @@ -161,9 +161,9 @@ describe('IssuesMonitor', () => { it('should disconnect and stop reporting after destroy', async () => { mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); const onIssue = vi.fn(); - const monitor = new IssuesMonitor(); + const monitor = new PerformanceIssuesMonitor(); monitor.init({ longTasks: {}, longAnimationFrames: false, webVitals: false }, onIssue); const observer = MockPerformanceObserver.byType('longtask'); @@ -183,8 +183,8 @@ describe('IssuesMonitor', () => { it('should skip observers when performance entry types are unsupported', async () => { MockPerformanceObserver.supportedEntryTypes = []; mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); - const monitor = new IssuesMonitor(); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); + const monitor = new PerformanceIssuesMonitor(); monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, vi.fn()); @@ -194,9 +194,9 @@ describe('IssuesMonitor', () => { it('should report poor web vitals on timeout even when not all 5 metrics fired', async () => { vi.useFakeTimers(); const webVitals = mockWebVitals(); - const { IssuesMonitor } = await import('../src/addons/issues'); + const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues'); const onIssue = vi.fn(); - const monitor = new IssuesMonitor(); + const monitor = new PerformanceIssuesMonitor(); monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue); await vi.dynamicImportSettled();