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"