Skip to content

Commit 15c2982

Browse files
committed
fix: update Web Vitals handling and improve documentation
1 parent d77424c commit 15c2982

5 files changed

Lines changed: 65 additions & 163 deletions

File tree

packages/javascript/README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Error tracking for JavaScript/TypeScript applications.
1212
- 🌟 Source maps consuming
1313
- 💬 Console logs tracking
1414
- 🧊 Main-thread blocking detection (Long Tasks + LoAF, Chromium-only)
15-
- 📊 Aggregated Web Vitals issues monitoring
15+
- 📊 Web Vitals issues monitoring
1616
- ⚙️ Unified `issues` configuration (errors + performance detectors)
1717
- <img src="https://cdn.svglogos.dev/logos/vue.svg" width="16" height="16"> &nbsp;Vue support
1818
- <img src="https://cdn.svglogos.dev/logos/react.svg" width="16" height="16"> &nbsp;React support
@@ -255,16 +255,15 @@ Freeze detectors use two complementary APIs:
255255
- **Long Tasks API** — browser reports tasks taking longer than 50 ms.
256256
- **Long Animation Frames (LoAF)** — browser reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+).
257257
258-
Both freeze detectors are disabled by default. If enabled and one API is unsupported, the other still works.
258+
Both freeze detectors are enabled by default. If one API is unsupported, the other still works.
259259
Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.).
260260
`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`.
261261
262-
### Web Vitals (Aggregated)
262+
### Web Vitals
263263
264-
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`.
265-
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.
264+
When `issues.webVitals` is enabled, Hawk listens to Core Web Vitals (`LCP`, `FCP`, `TTFB`, `INP`, `CLS`) and sends a dedicated issue event for each metric that is rated `poor`.
266265
267-
The event context contains all metrics with:
266+
Each Web Vitals issue context contains metric fields:
268267
- `value`
269268
- `rating`
270269
- `delta`
@@ -311,9 +310,9 @@ const hawk = new HawkCatcher({
311310
| Option | Type | Default | Description |
312311
|--------|------|---------|-------------|
313312
| `errors` | `boolean` | `true` | Enable global errors handling (`window.onerror` and `unhandledrejection`). |
314-
| `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. |
315-
| `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`). |
316-
| `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+. |
313+
| `webVitals` | `boolean` | `true` | Listen to Core Web Vitals and send one issue event per metric when that metric is rated `poor`. Requires optional `web-vitals` dependency. |
314+
| `longTasks` | `boolean` or `{ thresholdMs?: number }` | `true` | `false` disables. `true` enables with default threshold. Object enables and uses `thresholdMs` when valid; otherwise fallback threshold `70ms` is used (minimum effective value `50ms`). |
315+
| `longAnimationFrames` | `boolean` or `{ thresholdMs?: number }` | `true` | `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+. |
317316
318317
## Source maps consuming
319318

packages/javascript/src/addons/performance-issues.ts

Lines changed: 35 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import type {
66
LoAFScript,
77
LongTaskPerformanceEntry,
88
WebVitalMetric,
9-
WebVitalRating,
10-
WebVitalsReport
9+
WebVitalRating
1110
} from '../types/issues';
1211
import { compactJson } from '../utils/compactJson';
1312
import log from '../utils/log';
@@ -27,11 +26,6 @@ export const DEFAULT_LOAF_THRESHOLD_MS = 200;
2726
* Prevents overly aggressive configuration and event spam.
2827
*/
2928
export const MIN_ISSUE_THRESHOLD_MS = 50;
30-
/**
31-
* Maximum waiting time for Web Vitals aggregation before forced report attempt.
32-
*/
33-
export const WEB_VITALS_REPORT_TIMEOUT_MS = 10000;
34-
3529
/**
3630
* Web Vitals "good/poor" boundaries used to enrich issue summaries.
3731
*/
@@ -43,12 +37,6 @@ const METRIC_THRESHOLDS: Record<string, [good: number, poor: number]> = {
4337
CLS: [0.1, 0.25],
4438
};
4539

46-
/**
47-
* Number of Core Web Vitals currently collected by this monitor.
48-
* We wait for all metrics first, then fallback to timeout/pagehide flush.
49-
*/
50-
const TOTAL_WEB_VITALS = 5;
51-
5240
/**
5341
* Minimal Web Vitals API contract used by this monitor.
5442
* Supports module import and global CDN exposure (`window.webVitals`).
@@ -72,8 +60,6 @@ export class PerformanceIssuesMonitor {
7260
private longTaskObserver: PerformanceObserver | null = null;
7361
/** Active observer for Long Animation Frames API. */
7462
private loafObserver: PerformanceObserver | null = null;
75-
/** Cleanup hook for Web Vitals timeout/pagehide listeners. */
76-
private webVitalsCleanup: (() => void) | null = null;
7763
/** Prevents duplicate initialization and duplicate issue streams. */
7864
private isInitialized = false;
7965
/** Marks monitor as stopped to ignore async callbacks after destroy. */
@@ -93,21 +79,21 @@ export class PerformanceIssuesMonitor {
9379
this.isInitialized = true;
9480
this.destroyed = false;
9581

96-
if (options.longTasks !== undefined && options.longTasks !== false) {
82+
if (options.longTasks !== false) {
9783
this.observeLongTasks(
9884
resolveThreshold(resolveThresholdOption(options.longTasks), DEFAULT_LONG_TASK_THRESHOLD_MS),
9985
onIssue
10086
);
10187
}
10288

103-
if (options.longAnimationFrames !== undefined && options.longAnimationFrames !== false) {
89+
if (options.longAnimationFrames !== false) {
10490
this.observeLoAF(
10591
resolveThreshold(resolveThresholdOption(options.longAnimationFrames), DEFAULT_LOAF_THRESHOLD_MS),
10692
onIssue
10793
);
10894
}
10995

110-
if (options.webVitals === true) {
96+
if (options.webVitals !== false) {
11197
this.observeWebVitals(onIssue);
11298
}
11399
}
@@ -123,10 +109,8 @@ export class PerformanceIssuesMonitor {
123109
this.isInitialized = false;
124110
this.longTaskObserver?.disconnect();
125111
this.loafObserver?.disconnect();
126-
this.webVitalsCleanup?.();
127112
this.longTaskObserver = null;
128113
this.loafObserver = null;
129-
this.webVitalsCleanup = null;
130114
}
131115

132116
/**
@@ -255,8 +239,7 @@ export class PerformanceIssuesMonitor {
255239
}
256240

257241
/**
258-
* Observe Web Vitals and emit a single aggregated poor report.
259-
* Reports when all metrics are collected or on timeout/pagehide fallback.
242+
* Observe Web Vitals and emit one issue per poor metric.
260243
*
261244
* Resolution strategy:
262245
* 1) Use global `window.webVitals` when available (CDN scenario)
@@ -271,125 +254,46 @@ export class PerformanceIssuesMonitor {
271254
return;
272255
}
273256

274-
const collected: Record<string, WebVitalMetric> = {};
275-
let reported = false;
276-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
277-
let handlePageHide: (() => void) | null = null;
278-
279-
/**
280-
* Clears timeout and page lifecycle listener bound for this monitor run.
281-
*/
282-
const cleanup = (): void => {
283-
if (timeoutId !== null) {
284-
clearTimeout(timeoutId);
285-
timeoutId = null;
286-
}
287-
288-
if (typeof window !== 'undefined' && handlePageHide !== null) {
289-
window.removeEventListener('pagehide', handlePageHide);
290-
}
291-
};
257+
const reportedPoorMetrics = new Set<string>();
292258

293259
/**
294-
* Tries to emit an aggregated Web Vitals issue.
295-
* When `force` is false, waits for all vitals.
296-
* When `force` is true, emits with currently collected metrics.
297-
*
298-
* @param force
260+
* Emits one issue event for a poor metric.
261+
* Same metric name is reported only once.
299262
*/
300-
const tryReport = (force: boolean): void => {
301-
if (this.destroyed || reported) {
302-
return;
303-
}
304-
305-
const metrics = Object.values(collected);
306-
307-
if (metrics.length === 0) {
263+
const reportPoorMetric = (metric: { name: string; value: number; rating: WebVitalRating; delta: number }): void => {
264+
if (this.destroyed || metric.rating !== 'poor') {
308265
return;
309266
}
310267

311-
if (!force && metrics.length < TOTAL_WEB_VITALS) {
268+
if (reportedPoorMetrics.has(metric.name)) {
312269
return;
313270
}
314271

315-
const poor = metrics.filter((metric) => metric.rating === 'poor');
316-
317-
if (poor.length === 0) {
318-
return;
319-
}
272+
reportedPoorMetrics.add(metric.name);
320273

321-
reported = true;
322-
cleanup();
323-
324-
const summary = poor
325-
.map((metric) => {
326-
const thresholds = METRIC_THRESHOLDS[metric.name];
327-
const threshold = thresholds ? ` (poor > ${formatValue(metric.name, thresholds[1])})` : '';
328-
329-
return `${metric.name} = ${formatValue(metric.name, metric.value)}${threshold}`;
330-
})
331-
.join(', ');
332-
333-
const report: WebVitalsReport = {
334-
summary,
335-
poorCount: poor.length,
336-
metrics: { ...collected },
337-
};
274+
const thresholds = METRIC_THRESHOLDS[metric.name];
275+
const thresholdNote = thresholds ? ` (poor > ${formatValue(metric.name, thresholds[1])})` : '';
276+
const summary = `${metric.name} = ${formatValue(metric.name, metric.value)}${thresholdNote}`;
338277

339278
onIssue({
340-
title: `Poor Web Vitals: ${summary}`,
279+
title: `Poor Web Vital: ${summary}`,
341280
context: {
342-
webVitals: serializeWebVitalsReport(report),
281+
webVitals: serializeWebVitalMetric(metric),
343282
},
344283
});
345284
};
346285

347-
/**
348-
* Collects latest metric snapshot per metric name.
349-
*
350-
* @param metric
351-
*/
352-
const collect = (metric: { name: string; value: number; rating: WebVitalRating; delta: number }): void => {
353-
if (this.destroyed || reported) {
354-
return;
355-
}
356-
357-
collected[metric.name] = {
358-
name: metric.name,
359-
value: metric.value,
360-
rating: metric.rating,
361-
delta: metric.delta,
362-
};
363-
364-
tryReport(false);
365-
};
366-
367-
handlePageHide = (): void => {
368-
tryReport(true);
369-
};
370-
371-
timeoutId = setTimeout(() => {
372-
tryReport(true);
373-
}, WEB_VITALS_REPORT_TIMEOUT_MS);
374-
375-
if (typeof window !== 'undefined' && handlePageHide !== null) {
376-
window.addEventListener('pagehide', handlePageHide);
377-
}
378-
379-
this.webVitalsCleanup = cleanup;
380-
381-
onCLS(collect);
382-
onINP(collect);
383-
onLCP(collect);
384-
onFCP(collect);
385-
onTTFB(collect);
386-
})
387-
.catch(() => {
388-
log(
389-
'Web Vitals tracking requires `web-vitals` (npm) or global `window.webVitals` (CDN).',
390-
'warn'
391-
);
392-
});
286+
onCLS(reportPoorMetric);
287+
onINP(reportPoorMetric);
288+
onLCP(reportPoorMetric);
289+
onFCP(reportPoorMetric);
290+
onTTFB(reportPoorMetric);
291+
}).catch(() => {
292+
log(
293+
'Web Vitals tracking requires `web-vitals` (npm) or global `window.webVitals` (CDN).',
294+
'warn'
295+
);
296+
});
393297
}
394298
}
395299

@@ -507,25 +411,15 @@ function serializeScript(script: LoAFScript): Json {
507411
}
508412

509413
/**
510-
* Serializes aggregated Web Vitals report into event context payload.
414+
* Serializes single Web Vital metric into event context payload.
511415
*
512-
* @param report aggregated vitals report
416+
* @param metric web vital metric
513417
*/
514-
function serializeWebVitalsReport(report: WebVitalsReport): Json {
515-
const metrics = Object.entries(report.metrics).reduce<Json>((acc, [name, metric]) => {
516-
acc[name] = compactJson([
517-
['name', metric.name],
518-
['value', metric.value],
519-
['rating', metric.rating],
520-
['delta', metric.delta],
521-
]);
522-
523-
return acc;
524-
}, {});
525-
418+
function serializeWebVitalMetric(metric: WebVitalMetric): Json {
526419
return compactJson([
527-
['summary', report.summary],
528-
['poorCount', report.poorCount],
529-
['metrics', metrics],
420+
['name', metric.name],
421+
['value', metric.value],
422+
['rating', metric.rating],
423+
['delta', metric.delta],
530424
]);
531425
}

packages/javascript/src/catcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,8 @@ export default class Catcher {
326326
private configureIssues(settings: HawkInitialSettings): void {
327327
const issues = settings.issues ?? {};
328328
const shouldHandleGlobalErrors = settings.disableGlobalErrorsHandling !== true && issues.errors !== false;
329-
const shouldDetectPerformanceIssues = (issues.longTasks !== undefined && issues.longTasks !== false)
330-
|| (issues.longAnimationFrames !== undefined && issues.longAnimationFrames !== false)
329+
const shouldDetectPerformanceIssues = issues.longTasks !== false
330+
|| issues.longAnimationFrames !== false
331331
|| issues.webVitals === true;
332332

333333
if (shouldHandleGlobalErrors) {

packages/javascript/src/types/issues.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface PerformanceIssuesOptions {
1818
/**
1919
* Enable aggregated Web Vitals monitoring.
2020
*
21-
* @default false
21+
* @default true
2222
*/
2323
webVitals?: boolean;
2424

@@ -28,7 +28,7 @@ export interface PerformanceIssuesOptions {
2828
* Any other value enables it with default threshold.
2929
* If `thresholdMs` is a valid number greater than or equal to 50, it is used.
3030
*
31-
* @default false
31+
* @default true
3232
*/
3333
longTasks?: boolean | PerformanceIssueThresholdOptions;
3434

@@ -38,7 +38,7 @@ export interface PerformanceIssuesOptions {
3838
* Any other value enables it with default threshold.
3939
* If `thresholdMs` is a valid number greater than or equal to 50, it is used.
4040
*
41-
* @default false
41+
* @default true
4242
*/
4343
longAnimationFrames?: boolean | PerformanceIssueThresholdOptions;
4444
}

0 commit comments

Comments
 (0)