diff --git a/packages/javascript/README.md b/packages/javascript/README.md
index 8cd86fc4..941a640d 100644
--- a/packages/javascript/README.md
+++ b/packages/javascript/README.md
@@ -11,6 +11,9 @@ Error tracking for JavaScript/TypeScript applications.
- 🛡️ Sensitive data filtering
- 🌟 Source maps consuming
- 💬 Console logs tracking
+- 🧊 Main-thread blocking detection (Long Tasks + LoAF, Chromium-only)
+- 📊 Web Vitals issues monitoring
+- ⚙️ Unified `issues` configuration (errors + performance detectors)
-
Vue support
-
React support
@@ -85,11 +88,11 @@ 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 |
| `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. |
+| `issues` | IssuesOptions object | optional | Issues config. See [Issues configuration](#issues-configuration). |
Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition.
@@ -232,6 +235,71 @@ const breadcrumbs = hawk.breadcrumbs.get();
hawk.breadcrumbs.clear();
```
+## Issues configuration
+
+The `issues` option configures automatic performance and error tracking.
+
+| detector | key | default threshold | what it reports |
+|---|---|---|---|
+| **Errors** | `issues.errors` | — | Global runtime errors (`window.onerror`, `unhandledrejection`) |
+| **Web Vitals** | `issues.webVitals` | — | Poor-rated Core Web Vitals (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) |
+| **Long Tasks** | `issues.longTasks` | `70 ms` | Cross-origin / iframe tasks with identifiable container (`containerSrc`, `containerId`, or `containerName`). Tasks attributed to `"self"` are skipped |
+| **Long Animation Frames** | `issues.longAnimationFrames` | `200 ms` | Frames where at least one script attribution has `sourceURL`, `sourceFunctionName`, or `invoker` |
+
+All detectors are enabled by default.
+If the browser does not support a specific Performance API (`longtask`, `long-animation-frame`), the corresponding detector is silently skipped.
+
+Performance data is transmitted in the event **addons** (keys: `Long Task`, `Long Animation Frame`, `Web Vitals`).
+
+### Web Vitals
+
+When `issues.webVitals` is enabled, Hawk subscribes to Core Web Vitals via the `web-vitals` library and sends a dedicated issue event for each metric rated as `poor`. Each metric name is reported at most once per page load.
+
+`web-vitals` is included in the SDK dependencies — no extra installation required.
+
+### Disabling
+
+Disable **all** automatic issue tracking (errors, Web Vitals, Long Tasks, LoAF).
+Manual sending via `hawk.send()` still works:
+
+```js
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ issues: false
+});
+```
+
+Disable only global errors handling:
+
+```js
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ issues: {
+ errors: false
+ }
+});
+```
+
+### Selective Configuration
+
+Enable or disable individual detectors, optionally overriding thresholds (minimum `50 ms`):
+
+```js
+const hawk = new HawkCatcher({
+ token: 'INTEGRATION_TOKEN',
+ issues: {
+ errors: true,
+ webVitals: true,
+ longTasks: {
+ thresholdMs: 70
+ },
+ longAnimationFrames: {
+ thresholdMs: 200
+ }
+ }
+});
+```
+
## 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..6da6183d 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"
@@ -39,7 +39,8 @@
},
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
"dependencies": {
- "error-stack-parser": "^2.1.4"
+ "error-stack-parser": "^2.1.4",
+ "web-vitals": "^5.1.0"
},
"devDependencies": {
"@hawk.so/types": "0.5.8",
diff --git a/packages/javascript/src/addons/performance-issues.ts b/packages/javascript/src/addons/performance-issues.ts
new file mode 100644
index 00000000..8d9115ef
--- /dev/null
+++ b/packages/javascript/src/addons/performance-issues.ts
@@ -0,0 +1,393 @@
+import type { Json } from '@hawk.so/types';
+import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
+import type {
+ PerformanceIssueEvent,
+ PerformanceIssuesOptions,
+ LoAFEntry,
+ LoAFScript,
+ LongTaskAttribution,
+ LongTaskPerformanceEntry,
+ WebVitalRating
+} from '../types/issues';
+import { compactJson } from '../utils/compactJson';
+
+/** Default threshold for Long Tasks detector (ms). */
+export const DEFAULT_LONG_TASK_THRESHOLD_MS = 70;
+
+/** Default threshold for Long Animation Frames detector (ms). */
+export const DEFAULT_LOAF_THRESHOLD_MS = 200;
+
+/** Global minimum threshold guard — prevents overly aggressive configuration and event spam. */
+export const MIN_ISSUE_THRESHOLD_MS = 50;
+
+/**
+ * Extracted and validated data from a PerformanceLongTaskTiming entry.
+ * Passed from {@link validateLongTask} to {@link serializeLongTaskEvent}.
+ */
+interface ValidatedLongTask {
+ task: LongTaskPerformanceEntry;
+ durationMs: number;
+ primary: LongTaskAttribution;
+ containerIdentifier: string;
+}
+
+/**
+ * Extracted and validated data from a PerformanceLongAnimationFrameTiming entry.
+ * Passed from {@link validateLoAF} to {@link serializeLoAFEvent}.
+ */
+interface ValidatedLoAF {
+ loaf: LoAFEntry;
+ durationMs: number;
+ relevantScripts: LoAFScript[];
+}
+
+/**
+ * Validates a Long Task entry and extracts reportable data.
+ *
+ * A task is reportable when:
+ * - duration >= threshold
+ * - primary attribution name is not "self" (cross-origin / iframe task)
+ * - at least one of containerSrc / containerId / containerName is present
+ *
+ * @param task - PerformanceLongTaskTiming entry from the observer
+ * @param thresholdMs - minimum duration to consider the task reportable
+ * @returns validated data bundle or null if the entry should be skipped
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming
+ */
+function validateLongTask(task: LongTaskPerformanceEntry, thresholdMs: number): ValidatedLongTask | null {
+ const durationMs = Math.round(task.duration);
+
+ if (durationMs < thresholdMs) {
+ return null;
+ }
+
+ const primary = (task.attribution ?? [])[0];
+
+ if (!primary || primary.name === 'self') {
+ return null;
+ }
+
+ const containerIdentifier = primary.containerSrc || primary.containerId || primary.containerName;
+
+ if (!containerIdentifier) {
+ return null;
+ }
+
+ return { task, durationMs, primary, containerIdentifier };
+}
+
+/**
+ * Validates a Long Animation Frame entry and extracts reportable data.
+ *
+ * A frame is reportable when:
+ * - duration >= threshold
+ * - at least one script has an identifiable source (sourceURL, sourceFunctionName, or invoker)
+ *
+ * @param loaf - PerformanceLongAnimationFrameTiming entry from the observer
+ * @param thresholdMs - minimum duration to consider the frame reportable
+ * @returns validated data bundle or null if the entry should be skipped
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming
+ */
+function validateLoAF(loaf: LoAFEntry, thresholdMs: number): ValidatedLoAF | null {
+ const durationMs = Math.round(loaf.duration);
+
+ if (durationMs < thresholdMs) {
+ return null;
+ }
+
+ const relevantScripts = loaf.scripts?.filter(
+ (s) => s.sourceURL || s.sourceFunctionName || s.invoker
+ ) ?? [];
+
+ if (relevantScripts.length === 0) {
+ return null;
+ }
+
+ return { loaf, durationMs, relevantScripts };
+}
+
+/**
+ * Checks whether a Web Vital metric should be reported.
+ * Only poor-rated metrics are reported, and each metric name is reported at most once.
+ *
+ * @param metric - metric object from the web-vitals library
+ * @param reported - set of already reported metric names (dedup guard)
+ * @param metricName - metric name to check
+ */
+function isReportableWebVital(
+ metric: { rating: string },
+ reported: Set,
+ metricName: string
+): boolean {
+ return metric.rating === 'poor' && !reported.has(metricName);
+}
+
+/**
+ * Builds a {@link PerformanceIssueEvent} from a validated Long Task.
+ * Addon key: "Long Task".
+ *
+ * @param data - validated Long Task data
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming
+ */
+function serializeLongTaskEvent({ task, durationMs, primary, containerIdentifier }: ValidatedLongTask): PerformanceIssueEvent {
+ return {
+ title: 'Long Task — ' + containerIdentifier,
+ addons: {
+ 'Long Task': compactJson([
+ ['taskStartTimeMs', Math.round(task.startTime)],
+ ['taskDurationMs', durationMs],
+ ['attributionSourceType', primary.name],
+ ['containerElementType', primary.containerType],
+ ['containerSourceUrl', primary.containerSrc],
+ ['containerElementId', primary.containerId],
+ ['containerElementName', primary.containerName],
+ ]),
+ },
+ };
+}
+
+/**
+ * Builds a {@link PerformanceIssueEvent} from a validated Long Animation Frame.
+ * Addon key: "Long Animation Frame".
+ *
+ * @param data - validated LoAF data
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming
+ */
+function serializeLoAFEvent({ loaf, durationMs, relevantScripts }: ValidatedLoAF): PerformanceIssueEvent {
+ const topScript = relevantScripts[0];
+ const culprit = topScript?.sourceFunctionName
+ || topScript?.invoker
+ || topScript?.sourceURL
+ || '';
+
+ return {
+ title: 'Long Animation Frame' + (culprit ? ` — ${culprit}` : ''),
+ addons: {
+ 'Long Animation Frame': compactJson([
+ ['frameStartTimeMs', Math.round(loaf.startTime)],
+ ['frameDurationMs', durationMs],
+ ['frameBlockingDurationMs', loaf.blockingDuration != null ? Math.round(loaf.blockingDuration) : null],
+ ['renderStartTimeMs', loaf.renderStart != null ? Math.round(loaf.renderStart) : null],
+ ['styleAndLayoutStartTimeMs', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null],
+ ['firstUIEventTimeMs', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null],
+ ['scripts', relevantScripts.reduce((acc, s, i) => {
+ acc[`script_${i}`] = serializeScriptTiming(s);
+
+ return acc;
+ }, {})],
+ ]),
+ },
+ };
+}
+
+/**
+ * Builds a {@link PerformanceIssueEvent} from a poor Web Vital metric.
+ * Addon key: "Web Vitals".
+ *
+ * @param metric - metric object from the web-vitals library
+ */
+function serializeWebVitalEvent(metric: { name: string; value: number; rating: string; delta: number }): PerformanceIssueEvent {
+ return {
+ title: `Poor Web Vital: ${metric.name}`,
+ addons: {
+ 'Web Vitals': compactJson([
+ ['metricName', metric.name],
+ ['metricValue', metric.value],
+ ['metricRating', metric.rating],
+ ['metricDelta', metric.delta],
+ ]),
+ },
+ };
+}
+
+/**
+ * Serializes a single PerformanceScriptTiming entry into a compact JSON payload.
+ *
+ * @param script - LoAF script timing entry
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceScriptTiming
+ */
+function serializeScriptTiming(script: LoAFScript): Json {
+ return compactJson([
+ ['invokerName', script.invoker],
+ ['invokerType', script.invokerType],
+ ['sourceUrl', script.sourceURL],
+ ['sourceFunctionName', script.sourceFunctionName],
+ ['sourceCharPosition', script.sourceCharPosition != null && script.sourceCharPosition >= 0 ? script.sourceCharPosition : null],
+ ['scriptStartTimeMs', Math.round(script.startTime)],
+ ['scriptDurationMs', Math.round(script.duration)],
+ ['executionStartTimeMs', script.executionStart != null ? Math.round(script.executionStart) : null],
+ ['forcedStyleAndLayoutDurationMs', script.forcedStyleAndLayoutDuration != null ? Math.round(script.forcedStyleAndLayoutDuration) : null],
+ ['pauseDurationMs', script.pauseDuration != null ? Math.round(script.pauseDuration) : null],
+ ['windowAttribution', script.windowAttribution],
+ ]);
+}
+
+/**
+ * Performance issues monitor.
+ *
+ * Observes browser Performance API entries and Web Vitals metrics,
+ * validates them against configured thresholds and filtering rules,
+ * and emits structured {@link PerformanceIssueEvent} payloads.
+ *
+ * Supported detectors:
+ * - **Long Tasks** — cross-origin / iframe tasks with identifiable container
+ * - **Long Animation Frames** — frames with at least one identifiable script
+ * - **Web Vitals** — poor-rated Core Web Vitals (LCP, FCP, TTFB, INP, CLS)
+ */
+export class PerformanceIssuesMonitor {
+ private longTaskObserver: PerformanceObserver | null = null;
+ private loafObserver: PerformanceObserver | null = null;
+ private isInitialized = false;
+ private destroyed = false;
+
+ /**
+ * Initializes enabled detectors based on the provided options.
+ * Safe to call only once — subsequent calls are ignored until {@link destroy} resets the state.
+ *
+ * @param options - detector configuration (longTasks, longAnimationFrames, webVitals)
+ * @param onIssue - callback invoked for each detected performance issue
+ */
+ public init(options: PerformanceIssuesOptions, onIssue: (event: PerformanceIssueEvent) => void): void {
+ if (this.isInitialized) {
+ return;
+ }
+
+ this.isInitialized = true;
+ this.destroyed = false;
+
+ const detectors = [
+ {
+ option: options.longTasks,
+ type: 'longtask',
+ defaultMs: DEFAULT_LONG_TASK_THRESHOLD_MS,
+ process(entry: PerformanceEntry, ms: number): PerformanceIssueEvent | null {
+ const data = validateLongTask(entry as LongTaskPerformanceEntry, ms);
+
+ return data ? serializeLongTaskEvent(data) : null;
+ },
+ },
+ {
+ option: options.longAnimationFrames,
+ type: 'long-animation-frame',
+ defaultMs: DEFAULT_LOAF_THRESHOLD_MS,
+ process(entry: PerformanceEntry, ms: number): PerformanceIssueEvent | null {
+ const data = validateLoAF(entry as LoAFEntry, ms);
+
+ return data ? serializeLoAFEvent(data) : null;
+ },
+ },
+ ];
+
+ [this.longTaskObserver, this.loafObserver] = detectors.map(({ option, type, defaultMs, process }) => {
+ if (option === false) {
+ return null;
+ }
+
+ const custom = typeof option === 'object' ? option?.thresholdMs : undefined;
+ const thresholdMs = Math.max(MIN_ISSUE_THRESHOLD_MS,
+ typeof custom === 'number' && !Number.isNaN(custom) ? Math.round(custom) : defaultMs);
+
+ return this.observe(type, (entry) => {
+ const event = process(entry, thresholdMs);
+
+ if (event) {
+ onIssue(event);
+ }
+ });
+ });
+
+ if (options.webVitals !== false) {
+ this.observeWebVitals(onIssue);
+ }
+ }
+
+ /**
+ * Disconnects all active observers and resets the monitor state.
+ * After calling destroy, the monitor can be re-initialized via {@link init}.
+ */
+ public destroy(): void {
+ this.destroyed = true;
+ this.isInitialized = false;
+ this.longTaskObserver?.disconnect();
+ this.loafObserver?.disconnect();
+ this.longTaskObserver = null;
+ this.loafObserver = null;
+ }
+
+ /**
+ * Creates a PerformanceObserver for the given entry type.
+ * Returns null if the browser does not support the entry type or the observer fails.
+ *
+ * @param type - performance entry type (e.g. "longtask", "long-animation-frame")
+ * @param onEntry - callback invoked for each observed entry
+ */
+ private observe(type: string, onEntry: (entry: PerformanceEntry) => void): PerformanceObserver | null {
+ if (!supportsEntryType(type)) {
+ return null;
+ }
+
+ try {
+ const observer = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (this.destroyed) {
+ return;
+ }
+
+ onEntry(entry);
+ }
+ });
+
+ observer.observe({ type, buffered: true });
+
+ return observer;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Subscribes to Core Web Vitals via the web-vitals library.
+ * Emits one issue per poor-rated metric; each metric name is reported at most once.
+ *
+ * @param onIssue - callback invoked for each poor metric
+ */
+ private observeWebVitals(onIssue: (event: PerformanceIssueEvent) => void): void {
+ if (this.destroyed) {
+ return;
+ }
+
+ const reported = new Set();
+
+ const report = (metric: { name: string; value: number; rating: WebVitalRating; delta: number }): void => {
+ if (this.destroyed || !isReportableWebVital(metric, reported, metric.name)) {
+ return;
+ }
+
+ reported.add(metric.name);
+ onIssue(serializeWebVitalEvent(metric));
+ };
+
+ onCLS(report);
+ onINP(report);
+ onLCP(report);
+ onFCP(report);
+ onTTFB(report);
+ }
+}
+
+/**
+ * Checks whether the browser supports a given performance entry type.
+ *
+ * @param type - entry type to check (e.g. "longtask", "long-animation-frame")
+ */
+function supportsEntryType(type: string): boolean {
+ try {
+ return (
+ typeof PerformanceObserver !== 'undefined' &&
+ typeof PerformanceObserver.supportedEntryTypes !== 'undefined' &&
+ PerformanceObserver.supportedEntryTypes.includes(type)
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts
index b18d8683..14f4ef91 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 { PerformanceIssuesMonitor } from './addons/performance-issues';
import { validateUser, validateContext, isValidEventPayload } from './utils/validation';
/**
@@ -111,6 +112,11 @@ export default class Catcher {
*/
private readonly breadcrumbManager: BreadcrumbManager | null;
+ /**
+ * Issues monitor instance
+ */
+ private readonly issuesMonitor = new PerformanceIssuesMonitor();
+
/**
* Catcher constructor
*
@@ -177,12 +183,7 @@ export default class Catcher {
this.breadcrumbManager = null;
}
- /**
- * Set global handlers
- */
- if (!settings.disableGlobalErrorsHandling) {
- this.initGlobalHandlers();
- }
+ this.configureIssues(settings);
if (settings.vue) {
this.connectVue(settings.vue);
@@ -315,6 +316,35 @@ 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 {
+ if (settings.issues === false) {
+ return;
+ }
+
+ const issues = settings.issues ?? {};
+ const shouldHandleGlobalErrors = settings.disableGlobalErrorsHandling !== true && issues.errors !== false;
+ const shouldDetectPerformanceIssues = issues.longTasks !== false
+ || issues.longAnimationFrames !== false
+ || issues.webVitals === true;
+
+ if (shouldHandleGlobalErrors) {
+ this.initGlobalHandlers();
+ }
+
+ if (shouldDetectPerformanceIssues) {
+ this.issuesMonitor.init(issues, (entry) => {
+ void this.formatAndSend(entry.title, entry.addons);
+ });
+ }
+ }
+
/**
* Init global errors handler
*/
diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts
index 987cdf4c..a3276a57 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 { IssuesOptions } from './issues';
/**
* JS Catcher initial settings
@@ -61,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;
@@ -98,4 +101,13 @@ export interface HawkInitialSettings {
* If not provided, default WebSocket transport is used.
*/
transport?: Transport;
+
+ /**
+ * Issues configuration:
+ * `errors`, `webVitals`, `longTasks`, `longAnimationFrames`.
+ *
+ * Pass `false` to disable all automatic issue tracking.
+ * Manual sending via `.send()` still works.
+ */
+ issues?: false | IssuesOptions;
}
diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts
index f3354c30..13a2a5a6 100644
--- a/packages/javascript/src/types/index.ts
+++ b/packages/javascript/src/types/index.ts
@@ -1,18 +1,23 @@
import type { CatcherMessage } from './catcher-message';
import type { HawkInitialSettings } from './hawk-initial-settings';
+import type {
+ IssuesOptions,
+ PerformanceIssueThresholdOptions,
+} 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,
+ PerformanceIssueThresholdOptions,
Transport,
HawkJavaScriptEvent,
VueIntegrationData,
NuxtIntegrationData,
NuxtIntegrationAddons,
JavaScriptCatcherIntegrations,
- BreadcrumbsAPI
+ BreadcrumbsAPI,
};
diff --git a/packages/javascript/src/types/integrations.ts b/packages/javascript/src/types/integrations.ts
index ceb886da..b20eea5d 100644
--- a/packages/javascript/src/types/integrations.ts
+++ b/packages/javascript/src/types/integrations.ts
@@ -1,4 +1,5 @@
import type { VueIntegrationAddons } from '@hawk.so/types';
+import type { PerformanceIssueAddons } from './issues';
/**
* The Vue integration will append this data to the addons
@@ -39,4 +40,5 @@ export type NuxtIntegrationData = {
export type JavaScriptCatcherIntegrations =
| VueIntegrationData
| NuxtIntegrationData
+ | PerformanceIssueAddons
;
diff --git a/packages/javascript/src/types/issues.ts b/packages/javascript/src/types/issues.ts
new file mode 100644
index 00000000..19efd07a
--- /dev/null
+++ b/packages/javascript/src/types/issues.ts
@@ -0,0 +1,160 @@
+import type { Json } from '@hawk.so/types';
+
+/**
+ * Per-issue threshold configuration.
+ */
+export interface PerformanceIssueThresholdOptions {
+ /**
+ * Max allowed duration (ms). Emit issue when entry duration is >= this value.
+ * Values below 50ms are clamped to 50ms.
+ */
+ thresholdMs?: number;
+}
+
+/**
+ * Performance issues configuration.
+ */
+export interface PerformanceIssuesOptions {
+ /**
+ * Enable aggregated Web Vitals monitoring.
+ *
+ * @default true
+ */
+ webVitals?: boolean;
+
+ /**
+ * 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 true
+ */
+ longTasks?: boolean | PerformanceIssueThresholdOptions;
+
+ /**
+ * 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 true
+ */
+ longAnimationFrames?: boolean | PerformanceIssueThresholdOptions;
+}
+
+/**
+ * Full issues configuration.
+ */
+export interface IssuesOptions extends PerformanceIssuesOptions {
+ /**
+ * Enable automatic global errors handling.
+ *
+ * @default true
+ */
+ errors?: boolean;
+}
+
+/**
+ * Long Task attribution from the Performance API (TaskAttributionTiming).
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming
+ */
+export interface LongTaskAttribution {
+ /** Attribution source type (`self`, `same-origin-ancestor`, `cross-origin-descendant`, etc.) */
+ name: string;
+ /** Container type (`iframe`, `embed`, `object`) */
+ containerType?: string;
+ /** Source URL of the container element */
+ containerSrc?: string;
+ /** DOM id of the container element */
+ containerId?: string;
+ /** DOM name attribute of the container element */
+ containerName?: string;
+}
+
+/**
+ * PerformanceLongTaskTiming entry with attribution details.
+ */
+export interface LongTaskPerformanceEntry extends PerformanceEntry {
+ attribution?: LongTaskAttribution[];
+}
+
+/**
+ * PerformanceScriptTiming — script that contributed to a Long Animation Frame.
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceScriptTiming
+ */
+export interface LoAFScript {
+ /** How the script was called (e.g. `DOMWindow.onclick`, `TimerHandler:setTimeout`) */
+ invoker?: string;
+ /** Script entry point type (`event-listener`, `user-callback`, `resolve-promise`, etc.) */
+ invokerType?: string;
+ /** Source URL of the script */
+ sourceURL?: string;
+ /** Top-level function name at the entry point of the script execution */
+ sourceFunctionName?: string;
+ /** Character position in the source file */
+ sourceCharPosition?: number;
+ /** Script duration in milliseconds */
+ duration: number;
+ /** Start time relative to navigation start (ms) */
+ startTime: number;
+ /** When script compilation finished and execution began (ms) */
+ executionStart?: number;
+ /** Time spent in forced synchronous style/layout recalculations (ms) */
+ forcedStyleAndLayoutDuration?: number;
+ /** Time spent on synchronous pausing operations like alert() or sync XHR (ms) */
+ pauseDuration?: number;
+ /** Relationship of the script's container to the top-level document (`self`, `ancestor`, `descendant`) */
+ windowAttribution?: string;
+}
+
+/**
+ * PerformanceLongAnimationFrameTiming entry.
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming
+ */
+export interface LoAFEntry extends PerformanceEntry {
+ /** Total time the main thread was blocked from responding to high-priority tasks (ms) */
+ blockingDuration?: number;
+ /** Start time of the rendering cycle (ms) */
+ renderStart?: number;
+ /** When style and layout calculations began (ms) */
+ styleAndLayoutStart?: number;
+ /** Timestamp of the first UI event (click, keypress) queued during this frame (ms) */
+ firstUIEventTimestamp?: number;
+ /** Script timing entries that contributed to this frame */
+ scripts?: LoAFScript[];
+}
+
+/**
+ * Web Vitals rating level.
+ */
+export type WebVitalRating = 'good' | 'needs-improvement' | 'poor';
+
+/**
+ * Single Web Vital metric.
+ */
+export interface WebVitalMetric {
+ name: string;
+ value: number;
+ rating: WebVitalRating;
+ delta: number;
+}
+
+/**
+ * Addons payload shape for performance issue events.
+ */
+export interface PerformanceIssueAddons {
+ 'Long Task'?: Json;
+ 'Long Animation Frame'?: Json;
+ 'Web Vitals'?: Json;
+}
+
+/**
+ * Payload sent by issues monitor to the catcher.
+ */
+export interface PerformanceIssueEvent {
+ /** Human-readable issue title shown in Hawk event list. */
+ title: string;
+ /** Structured addons payload attached to this issue event. */
+ addons: PerformanceIssueAddons;
+}
diff --git a/packages/javascript/src/utils/compactJson.ts b/packages/javascript/src/utils/compactJson.ts
new file mode 100644
index 00000000..50055b62
--- /dev/null
+++ b/packages/javascript/src/utils/compactJson.ts
@@ -0,0 +1,21 @@
+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.
+ *
+ * @param entries
+ */
+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,
+ });
+ });
+});
diff --git a/packages/javascript/tests/performance-issues.test.ts b/packages/javascript/tests/performance-issues.test.ts
new file mode 100644
index 00000000..b8686a2c
--- /dev/null
+++ b/packages/javascript/tests/performance-issues.test.ts
@@ -0,0 +1,320 @@
+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,
+} from '../src/addons/performance-issues';
+
+const webVitalsCallbacks: Record = {};
+
+vi.mock('web-vitals', () => ({
+ onCLS: (cb: ReportCallback) => { webVitalsCallbacks.CLS = cb; },
+ onINP: (cb: ReportCallback) => { webVitalsCallbacks.INP = cb; },
+ onLCP: (cb: ReportCallback) => { webVitalsCallbacks.LCP = cb; },
+ onFCP: (cb: ReportCallback) => { webVitalsCallbacks.FCP = cb; },
+ onTTFB: (cb: ReportCallback) => { webVitalsCallbacks.TTFB = cb; },
+}));
+
+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'];
+ }
+}
+
+/**
+ * Creates a long task entry with cross-origin attribution (passes new filter).
+ */
+function longTaskEntry(duration: number, attribution?: { name?: string; containerSrc?: string; containerId?: string; containerName?: string }): PerformanceEntry {
+ const attr = {
+ name: attribution?.name ?? 'same-origin-ancestor',
+ containerSrc: attribution?.containerSrc ?? 'https://example.com/frame.js',
+ containerId: attribution?.containerId,
+ containerName: attribution?.containerName,
+ };
+
+ return {
+ startTime: 100,
+ duration,
+ attribution: [attr],
+ } as unknown as PerformanceEntry;
+}
+
+/**
+ * Creates a LoAF entry with at least one identifiable script (passes new filter).
+ */
+function loafEntry(duration: number, scripts?: Array<{ sourceURL?: string; sourceFunctionName?: string; invoker?: string; duration?: number; startTime?: number }>): PerformanceEntry {
+ const defaultScripts = scripts ?? [{
+ sourceURL: 'https://example.com/app.js',
+ sourceFunctionName: 'handleClick',
+ invoker: 'DOMWindow.onclick',
+ duration: duration * 0.8,
+ startTime: 100,
+ }];
+
+ return {
+ startTime: 100,
+ duration,
+ blockingDuration: 0,
+ renderStart: 120,
+ styleAndLayoutStart: 130,
+ firstUIEventTimestamp: 0,
+ scripts: defaultScripts,
+ } as unknown as PerformanceEntry;
+}
+
+function mockWebVitals() {
+ return {
+ emit(metric: Metric): void {
+ webVitalsCallbacks[metric.name]?.(metric);
+ },
+ };
+}
+
+describe('PerformanceIssuesMonitor', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ webVitalsCallbacks.CLS = undefined;
+ webVitalsCallbacks.INP = undefined;
+ webVitalsCallbacks.LCP = undefined;
+ webVitalsCallbacks.FCP = undefined;
+ webVitalsCallbacks.TTFB = undefined;
+ 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 { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: { thresholdMs: 1 }, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ expect(observer).toBeDefined();
+
+ observer!.emit([longTaskEntry(MIN_ISSUE_THRESHOLD_MS - 1)]);
+ expect(onIssue).not.toHaveBeenCalled();
+
+ observer!.emit([longTaskEntry(MIN_ISSUE_THRESHOLD_MS)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit only entries that are >= configured threshold', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ const customThresholdMs = 75;
+
+ monitor.init({ longTasks: { thresholdMs: customThresholdMs }, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ expect(observer).toBeDefined();
+
+ observer!.emit([longTaskEntry(customThresholdMs - 1)]);
+ expect(onIssue).not.toHaveBeenCalled();
+
+ observer!.emit([longTaskEntry(customThresholdMs)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ expect(onIssue.mock.calls[0][0].title).toContain('Long Task');
+ expect(onIssue.mock.calls[0][0].addons).toHaveProperty('Long Task');
+ expect(onIssue.mock.calls[0][0].addons['Long Task']).toHaveProperty('taskDurationMs', customThresholdMs);
+ expect(onIssue.mock.calls[0][0].addons['Long Task']).toHaveProperty('attributionSourceType', 'same-origin-ancestor');
+ });
+
+ it('should skip long tasks with name=self (no container info)', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: { thresholdMs: 50 }, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ observer!.emit([longTaskEntry(120, { name: 'self', containerSrc: '' })]);
+ expect(onIssue).not.toHaveBeenCalled();
+ });
+
+ it('should skip long tasks without container identifier', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: { thresholdMs: 50 }, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ observer!.emit([longTaskEntry(120, { name: 'cross-origin-ancestor' })]);
+ expect(onIssue).not.toHaveBeenCalled();
+ });
+
+ it('should use default threshold when longTasks is true', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: true, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ expect(observer).toBeDefined();
+
+ observer!.emit([longTaskEntry(DEFAULT_LONG_TASK_THRESHOLD_MS - 1)]);
+ expect(onIssue).not.toHaveBeenCalled();
+
+ observer!.emit([longTaskEntry(DEFAULT_LONG_TASK_THRESHOLD_MS)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should ignore second init call and avoid duplicate observers', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const monitor = new PerformanceIssuesMonitor();
+ 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 { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: {}, longAnimationFrames: false, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('longtask');
+
+ expect(observer).toBeDefined();
+
+ observer!.emit([longTaskEntry(120)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+
+ monitor.destroy();
+ expect(observer!.disconnected).toBe(true);
+
+ observer!.emit([longTaskEntry(130)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should skip observers when performance entry types are unsupported', async () => {
+ MockPerformanceObserver.supportedEntryTypes = [];
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: {}, longAnimationFrames: {}, webVitals: false }, vi.fn());
+
+ expect(MockPerformanceObserver.instances).toHaveLength(0);
+ });
+
+ it('should emit LoAF issue only when scripts have identifiable source', async () => {
+ mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: false, longAnimationFrames: { thresholdMs: 50 }, webVitals: false }, onIssue);
+ const observer = MockPerformanceObserver.byType('long-animation-frame');
+
+ observer!.emit([loafEntry(250, [{ duration: 200, startTime: 100 }])]);
+ expect(onIssue).not.toHaveBeenCalled();
+
+ observer!.emit([loafEntry(250)]);
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ expect(onIssue.mock.calls[0][0].addons).toHaveProperty('Long Animation Frame');
+ expect(onIssue.mock.calls[0][0].addons['Long Animation Frame']).toHaveProperty('frameDurationMs', 250);
+ });
+
+ it('should report poor web vital metric in addons', async () => {
+ const webVitals = mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue);
+ await vi.dynamicImportSettled();
+
+ webVitals.emit({ name: 'LCP', value: 5000, rating: 'poor', delta: 5000 });
+
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vital');
+ expect(onIssue.mock.calls[0][0].addons).toHaveProperty('Web Vitals');
+ expect(onIssue.mock.calls[0][0].addons['Web Vitals']).toHaveProperty('metricName', 'LCP');
+ });
+
+ it('should report poor INP metric', async () => {
+ const webVitals = mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue);
+ await vi.dynamicImportSettled();
+
+ webVitals.emit({ name: 'INP', value: 600, rating: 'poor', delta: 600 });
+
+ expect(onIssue).toHaveBeenCalledTimes(1);
+ expect(onIssue.mock.calls[0][0].title).toContain('Poor Web Vital');
+ });
+
+ it('should not emit event for non-poor web vital metric', async () => {
+ const webVitals = mockWebVitals();
+ const { PerformanceIssuesMonitor } = await import('../src/addons/performance-issues');
+ const onIssue = vi.fn();
+ const monitor = new PerformanceIssuesMonitor();
+
+ monitor.init({ longTasks: false, longAnimationFrames: false, webVitals: true }, onIssue);
+ await vi.dynamicImportSettled();
+
+ webVitals.emit({ name: 'FCP', value: 1800, rating: 'good', delta: 1800 });
+ expect(onIssue).not.toHaveBeenCalled();
+ });
+
+});
diff --git a/yarn.lock b/yarn.lock
index 317217ec..0c09764c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -587,6 +587,7 @@ __metadata:
vite-plugin-dts: "npm:^4.2.4"
vitest: "npm:^4.0.18"
vue: "npm:^2"
+ web-vitals: "npm:^5.1.0"
languageName: unknown
linkType: soft
@@ -6193,6 +6194,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"