Skip to content

feat: add main-thread blocking detection (Long Tasks + LoAF + web Vitals)#166

Open
Dobrunia wants to merge 13 commits intomasterfrom
feat/thread-blocking-detection
Open

feat: add main-thread blocking detection (Long Tasks + LoAF + web Vitals)#166
Dobrunia wants to merge 13 commits intomasterfrom
feat/thread-blocking-detection

Conversation

@Dobrunia
Copy link
Member

@Dobrunia Dobrunia commented Feb 23, 2026

Summary

  • Add main-thread freeze detection to JS catcher:
    • Long Tasks API
    • Long Animation Frames (LoAF)
  • Add aggregated Web Vitals issues monitoring (LCP, FCP, TTFB, INP, CLS):
    • sends one dedicated Hawk issue event when at least one metric is poor
    • includes full metrics payload (value, rating, delta) for diagnostics
  • New unified config:
    • issues.errors
    • issues.webVitals
    • issues.longTasks.thresholdMs
    • issues.longAnimationFrames.thresholdMs

Defaults

issues: {  
  errors: true,  
  webVitals: false,  
  longTasks: false, 
  longAnimationFrames: false
}

Dependency / Bundle behavior

  • web-vitals is an optional peer dependency:
    • peerDependencies: { "web-vitals": "^4.0.0 || ^5.0.0" }
    • peerDependenciesMeta.web-vitals.optional = true
  • web-vitals is loaded via dynamic import and only when issues.webVitals === true.
  • If Web Vitals are disabled, package is not required and is not used at runtime.

Browser support

API Chrome Edge Firefox Safari
Long Tasks 58+ 79+
LoAF 123+ 123+

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds main-thread blocking detection to the JavaScript error catcher using the Long Tasks API and Long Animation Frames (LoAF) API. When a blocking entry is detected (task >50 ms), a dedicated Hawk event is sent immediately with blocking details in the context.

Changes:

  • Added new longTasks.ts addon that sets up PerformanceObservers for Long Tasks and LoAF APIs
  • Integrated the observers into the Catcher constructor with configurable options
  • Added mainThreadBlocking configuration option to HawkInitialSettings

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/javascript/src/addons/longTasks.ts New addon implementing Long Tasks and LoAF observers with feature detection and data serialization
packages/javascript/src/catcher.ts Integrated main-thread blocking detection into the constructor with callback to send()
packages/javascript/src/types/hawk-initial-settings.ts Added mainThreadBlocking configuration option type definition
packages/javascript/package.json Bumped minor version from 3.2.18 to 3.3.0 for new feature
packages/javascript/README.md Added documentation for main-thread blocking detection feature with configuration examples
Comments suppressed due to low confidence (2)

packages/javascript/src/addons/longTasks.ts:182

  • Using buffered: true means the observer will immediately process all past long task entries that occurred before the observer was set up. If Hawk is initialized late in the page lifecycle on a slow device, this could result in a burst of events being sent all at once, potentially overwhelming the transport or causing rate limiting issues. Consider documenting this behavior or providing an option to disable buffering for users who only want to track tasks from the point of initialization forward.
    }).observe({ type: 'longtask', buffered: true });

packages/javascript/src/addons/longTasks.ts:245

  • Using buffered: true means the observer will immediately process all past LoAF entries that occurred before the observer was set up. If Hawk is initialized late in the page lifecycle on a slow device, this could result in a burst of events being sent all at once, potentially overwhelming the transport or causing rate limiting issues. Consider documenting this behavior or providing an option to disable buffering for users who only want to track frames from the point of initialization forward.
    }).observe({ type: 'long-animation-frame', buffered: true });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 223 to 225
['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null],
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truthy check here will incorrectly treat a value of 0 as falsy and return null instead of 0. If styleAndLayoutStart is 0 (which is a valid timestamp), it will be excluded from the serialized data. Consider using an explicit null/undefined check instead: loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null

Suggested change
['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null],
['renderStart', loaf.renderStart != null ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null],

Copilot uses AI. Check for mistakes.
Comment on lines 163 to 182
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const task = entry as LongTaskPerformanceEntry;
const durationMs = Math.round(task.duration);
const attr = task.attribution?.[0];

const details = compact([
['kind', 'longtask'],
['entryName', task.name],
['startTime', Math.round(task.startTime)],
['durationMs', durationMs],
['containerType', attr?.containerType],
['containerSrc', attr?.containerSrc],
['containerId', attr?.containerId],
['containerName', attr?.containerName],
]);

onEntry({ title: `Long Task ${durationMs} ms`, context: { details } });
}
}).observe({ type: 'longtask', buffered: true });
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PerformanceObserver instance created here is not stored or returned, making it impossible to disconnect or clean up the observer later. This could be a memory leak concern if the Hawk catcher is destroyed and recreated multiple times in the application lifecycle (e.g., in SPAs with dynamic module loading). Consider storing the observer instance and providing a cleanup mechanism, similar to how other addons handle resource management.

Copilot uses AI. Check for mistakes.
if (options.longAnimationFrames ?? true) {
observeLoAF(onEntry);
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new main-thread blocking detection feature lacks test coverage. While testing browser APIs like PerformanceObserver can be challenging, the codebase has comprehensive tests for other addons (e.g., breadcrumbs.test.ts). Consider adding tests for at least: 1) feature detection logic (supportsEntryType), 2) the compact utility function, 3) script serialization (serializeScript), 4) proper handling of callbacks when entries are detected, and 5) handling of disabled options. These can be tested with mocks for PerformanceObserver.

Suggested change
}
}
/**
* Internal helpers exported for unit testing.
*
* These exports are not part of the public API surface and may change
* without notice. They exist solely to enable targeted tests for:
* - feature detection logic (supportsEntryType)
* - the compact utility function
* - script serialization (serializeScript)
* - observer behavior and option handling
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const __longTasksTestables__ = {
supportsEntryType,
compact,
serializeScript,
observeLongTasks,
observeLoAF,
observeMainThreadBlocking,
};

Copilot uses AI. Check for mistakes.
['sourceCharPosition', s.sourceCharPosition != null && s.sourceCharPosition >= 0 ? s.sourceCharPosition : null],
['duration', Math.round(s.duration)],
['executionStart', s.executionStart != null ? Math.round(s.executionStart) : null],
['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration ? Math.round(s.forcedStyleAndLayoutDuration) : null],
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truthy check here will incorrectly treat a value of 0 as falsy and return null instead of 0. If forcedStyleAndLayoutDuration is 0 (which is a valid duration meaning no forced style/layout work), it will be excluded from the serialized data. Consider using an explicit null/undefined check instead: s.forcedStyleAndLayoutDuration != null ? Math.round(s.forcedStyleAndLayoutDuration) : null

Suggested change
['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration ? Math.round(s.forcedStyleAndLayoutDuration) : null],
['forcedStyleAndLayoutDuration', s.forcedStyleAndLayoutDuration != null ? Math.round(s.forcedStyleAndLayoutDuration) : null],

Copilot uses AI. Check for mistakes.
['blockingDurationMs', blockingDurationMs],
['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null],
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truthy check here will incorrectly treat a value of 0 as falsy and return null instead of 0. If firstUIEventTimestamp is 0 (which is theoretically a valid timestamp at the navigation start), it will be excluded from the serialized data. Consider using an explicit null/undefined check instead: loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null

Suggested change
['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null],

Copilot uses AI. Check for mistakes.
Comment on lines 200 to 245
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const loaf = entry as LoAFEntry;
const durationMs = Math.round(loaf.duration);
const blockingDurationMs = loaf.blockingDuration != null
? Math.round(loaf.blockingDuration)
: null;

const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName);

const scripts = relevantScripts?.length
? relevantScripts.reduce<Json>((acc, s, i) => {
acc[`script_${i}`] = serializeScript(s);

return acc;
}, {})
: null;

const details = compact([
['kind', 'loaf'],
['startTime', Math.round(loaf.startTime)],
['durationMs', durationMs],
['blockingDurationMs', blockingDurationMs],
['renderStart', loaf.renderStart ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp ? Math.round(loaf.firstUIEventTimestamp) : null],
['scripts', scripts],
]);

const blockingNote = blockingDurationMs != null
? ` (blocking ${blockingDurationMs} ms)`
: '';

const topScript = relevantScripts?.[0];
const culprit = topScript?.sourceFunctionName
|| topScript?.invoker
|| topScript?.sourceURL
|| '';
const culpritNote = culprit ? ` — ${culprit}` : '';

onEntry({
title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`,
context: { details },
});
}
}).observe({ type: 'long-animation-frame', buffered: true });
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PerformanceObserver instance created here is not stored or returned, making it impossible to disconnect or clean up the observer later. This could be a memory leak concern if the Hawk catcher is destroyed and recreated multiple times in the application lifecycle (e.g., in SPAs with dynamic module loading). Consider storing the observer instance and providing a cleanup mechanism, similar to how other addons handle resource management.

Copilot uses AI. Check for mistakes.
/**
* Build a Json object from entries, dropping null / undefined / empty-string values
*/
function compact(entries: [string, JsonNode | null | undefined][]): Json {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utility function should be added to /utils and covered by tests

Comment on lines 55 to 62
interface LongTaskAttribution {
name: string;
entryType: string;
containerType?: string;
containerSrc?: string;
containerId?: string;
containerName?: string;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move types to the /types folder or the @hawk.so/types package.

and add clear jsdocs to each property.

*/
function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void {
if (!supportsEntryType('longtask')) {
log('Long Tasks API is not supported in this browser', 'info');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such logs should not be shown by default, only when user explicitly enables such logs

Comment on lines 208 to 211
const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName);

const scripts = relevantScripts?.length
? relevantScripts.reduce<Json>((acc, s, i) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add jsdocs here

*
* @default enabled with default options (both longTasks and longAnimationFrames on)
*/
mainThreadBlocking?: false | MainThreadBlockingOptions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why Long Animation Frames is configured via mainThreadBlocking option. It's confusing.

Maybe we can use a better naming

Dobrunia and others added 8 commits February 24, 2026 00:59
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Peter <specc.dev@gmail.com>
Co-authored-by: Peter <specc.dev@gmail.com>
…interfaces and improving script serialization
- Introduced `web-vitals` as a peer dependency and added it to the package.json.
- Updated README to reflect new issues detection configuration, including `issues.webVitals`.
- Refactored the catcher to utilize an `IssuesMonitor` for handling global errors and performance issues.
- Removed the Long Tasks and LoAF tracking functionality, consolidating it under the new issues detection system.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (10)

packages/javascript/src/addons/issues.ts:298

  • JSDoc comment is missing a description. It should describe what this function does, e.g., "Resolve and validate the threshold value, applying minimum and default values as needed."
/**
 *
 * @param value custom threshold
 * @param fallback default threshold
 */

packages/javascript/src/addons/issues.ts:319

  • JSDoc comment is missing a description. It should describe what this function does, e.g., "Serialize a LoAF script entry to a compact JSON object, omitting null and undefined values."
/**
 *
 * @param script loaf script entry
 */

packages/javascript/src/addons/issues.ts:338

  • JSDoc comment is missing a description. It should describe what this function does, e.g., "Serialize a Web Vitals report to a compact JSON object for transmission."
/**
 *
 * @param report aggregated vitals report
 */

packages/javascript/src/addons/issues.ts:81

  • JSDoc comment is missing a description. It should describe what this method does, e.g., "Observe Long Tasks using the Performance API and emit issues when duration exceeds threshold."
  /**
   *
   * @param thresholdMs max allowed duration
   * @param onIssue issue callback
   */

packages/javascript/src/addons/issues.ts:203

  • JSDoc comment is missing a description. It should describe what this method does, e.g., "Initialize Web Vitals monitoring and emit issue when at least one metric is poor."
  /**
   *
   * @param onIssue issue callback
   */

packages/javascript/src/addons/issues.ts:311

  • JSDoc comment is missing a description. It should describe what this function does, e.g., "Format a Web Vital metric value for display (fixed precision for CLS, milliseconds for others)."
/**
 *
 * @param name metric name
 * @param value metric value
 */

packages/javascript/src/addons/issues.ts:121

  • The observe call should have the options object on a single line or properly formatted. The current formatting with buffered: true on a separate line without proper indentation is inconsistent with typical JavaScript style conventions.
      this.longTaskObserver.observe({ type: 'longtask',
        buffered: true });

packages/javascript/src/addons/issues.ts:194

  • The observe call should have the options object on a single line or properly formatted. The current formatting with buffered: true on a separate line without proper indentation is inconsistent with typical JavaScript style conventions.
      this.loafObserver.observe({ type: 'long-animation-frame',
        buffered: true });

packages/javascript/src/addons/issues.ts:131

  • JSDoc comment is missing a description. It should describe what this method does, e.g., "Observe Long Animation Frames using the Performance API and emit issues when duration exceeds threshold."
  /**
   *
   * @param thresholdMs max allowed duration
   * @param onIssue issue callback
   */

packages/javascript/src/addons/issues.ts:281

  • JSDoc comment is missing a description. It should describe what this function does, e.g., "Check if the Performance API supports a specific entry type."
/**
 *
 * @param type performance entry type
 */

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 209 to 212
const tryReport = (): void => {
if (this.destroyed || reported || collected.length < TOTAL_WEB_VITALS) {
return;
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Web Vitals reporting waits for all 5 metrics (LCP, FCP, TTFB, INP, CLS) to be collected before reporting. However, not all metrics may be available on every page (e.g., INP requires user interaction, LCP may not occur on certain pages). This means poor metrics might never be reported if the page doesn't trigger all 5 metrics. Consider reporting when a timeout expires or when the page is about to be unloaded, rather than waiting for all 5 metrics indefinitely.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 356
export class IssuesMonitor {
private longTaskObserver: PerformanceObserver | null = null;
private loafObserver: PerformanceObserver | null = null;
private destroyed = false;

/**
* Initialize selected issue detectors.
*
* @param options detectors config
* @param onIssue issue callback
*/
public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void {
if (options.longTasks !== false) {
this.observeLongTasks(
resolveThreshold(options.longTasks?.thresholdMs, DEFAULT_LONG_TASK_THRESHOLD_MS),
onIssue
);
}

if (options.longAnimationFrames !== false) {
this.observeLoAF(
resolveThreshold(options.longAnimationFrames?.thresholdMs, DEFAULT_LOAF_THRESHOLD_MS),
onIssue
);
}

if (options.webVitals === true) {
this.observeWebVitals(onIssue);
}
}

/**
* Cleanup active observers.
*/
public destroy(): void {
this.destroyed = true;
this.longTaskObserver?.disconnect();
this.loafObserver?.disconnect();
this.longTaskObserver = null;
this.loafObserver = null;
}

/**
*
* @param thresholdMs max allowed duration
* @param onIssue issue callback
*/
private observeLongTasks(thresholdMs: number, onIssue: (event: IssueEvent) => void): void {
if (!supportsEntryType('longtask')) {
return;
}

try {
this.longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (this.destroyed) {
return;
}

const task = entry as LongTaskPerformanceEntry;
const durationMs = Math.round(task.duration);
const attr = task.attribution?.[0];

if (durationMs < thresholdMs) {
continue;
}

const details = compactJson([
['kind', 'longtask'],
['entryName', task.name],
['startTime', Math.round(task.startTime)],
['durationMs', durationMs],
['containerType', attr?.containerType],
['containerSrc', attr?.containerSrc],
['containerId', attr?.containerId],
['containerName', attr?.containerName],
]);

onIssue({
title: `Long Task ${durationMs} ms`,
context: { freezeDetection: details },
});
}
});

this.longTaskObserver.observe({ type: 'longtask',
buffered: true });
} catch {
this.longTaskObserver = null;
}
}

/**
*
* @param thresholdMs max allowed duration
* @param onIssue issue callback
*/
private observeLoAF(thresholdMs: number, onIssue: (event: IssueEvent) => void): void {
if (!supportsEntryType('long-animation-frame')) {
return;
}

try {
this.loafObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (this.destroyed) {
return;
}

const loaf = entry as LoAFEntry;
const durationMs = Math.round(loaf.duration);

if (durationMs < thresholdMs) {
continue;
}

const blockingDurationMs = loaf.blockingDuration !== undefined && loaf.blockingDuration !== null
? Math.round(loaf.blockingDuration)
: null;

const relevantScripts = loaf.scripts?.filter((s) => s.sourceURL || s.sourceFunctionName) ?? [];
const scripts = relevantScripts.length
? relevantScripts.reduce<Json>((acc, script, i) => {
acc[`script_${i}`] = serializeScript(script);

return acc;
}, {})
: null;

const details = compactJson([
['kind', 'loaf'],
['startTime', Math.round(loaf.startTime)],
['durationMs', durationMs],
['blockingDurationMs', blockingDurationMs],
['renderStart', loaf.renderStart != null ? Math.round(loaf.renderStart) : null],
['styleAndLayoutStart', loaf.styleAndLayoutStart != null ? Math.round(loaf.styleAndLayoutStart) : null],
['firstUIEventTimestamp', loaf.firstUIEventTimestamp != null ? Math.round(loaf.firstUIEventTimestamp) : null],
['scripts', scripts],
]);

const blockingNote = blockingDurationMs !== null
? ` (blocking ${blockingDurationMs} ms)`
: '';

const topScript = relevantScripts[0];
const culprit = topScript?.sourceFunctionName
|| topScript?.invoker
|| topScript?.sourceURL
|| '';
const culpritNote = culprit ? ` — ${culprit}` : '';

onIssue({
title: `Long Animation Frame ${durationMs} ms${blockingNote}${culpritNote}`,
context: { freezeDetection: details },
});
}
});

this.loafObserver.observe({ type: 'long-animation-frame',
buffered: true });
} catch {
this.loafObserver = null;
}
}

/**
*
* @param onIssue issue callback
*/
private observeWebVitals(onIssue: (event: IssueEvent) => void): void {
void import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => {
const collected: WebVitalMetric[] = [];
let reported = false;

const tryReport = (): void => {
if (this.destroyed || reported || collected.length < TOTAL_WEB_VITALS) {
return;
}

reported = true;

const poor = collected.filter((metric) => metric.rating === 'poor');

if (poor.length === 0) {
return;
}

const summary = poor
.map((metric) => {
const thresholds = METRIC_THRESHOLDS[metric.name];
const threshold = thresholds ? ` (poor > ${formatValue(metric.name, thresholds[1])})` : '';

return `${metric.name} = ${formatValue(metric.name, metric.value)}${threshold}`;
})
.join(', ');

const report: WebVitalsReport = {
summary,
poorCount: poor.length,
metrics: collected.reduce<Record<string, WebVitalMetric>>((acc, metric) => {
acc[metric.name] = metric;

return acc;
}, {}),
};

onIssue({
title: `Poor Web Vitals: ${summary}`,
context: {
webVitals: serializeWebVitalsReport(report),
},
});
};

const collect = (metric: { name: string; value: number; rating: WebVitalRating; delta: number }): void => {
if (this.destroyed || reported) {
return;
}

collected.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
});

tryReport();
};

onCLS(collect);
onINP(collect);
onLCP(collect);
onFCP(collect);
onTTFB(collect);
}).catch(() => {
log(
'web-vitals package is required for Web Vitals tracking. Install it with: npm i web-vitals',
'warn'
);
});
}
}

/**
*
* @param type performance entry type
*/
function supportsEntryType(type: string): boolean {
try {
return (
typeof PerformanceObserver !== 'undefined' &&
typeof PerformanceObserver.supportedEntryTypes !== 'undefined' &&
PerformanceObserver.supportedEntryTypes.includes(type)
);
} catch {
return false;
}
}

/**
*
* @param value custom threshold
* @param fallback default threshold
*/
function resolveThreshold(value: number | undefined, fallback: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
return Math.max(MIN_ISSUE_THRESHOLD_MS, fallback);
}

return Math.max(MIN_ISSUE_THRESHOLD_MS, Math.round(value));
}

/**
*
* @param name metric name
* @param value metric value
*/
function formatValue(name: string, value: number): string {
return name === 'CLS' ? value.toFixed(3) : `${Math.round(value)}ms`;
}

/**
*
* @param script loaf script entry
*/
function serializeScript(script: LoAFScript): Json {
return compactJson([
['invoker', script.invoker],
['invokerType', script.invokerType],
['sourceURL', script.sourceURL],
['sourceFunctionName', script.sourceFunctionName],
['sourceCharPosition', script.sourceCharPosition != null && script.sourceCharPosition >= 0 ? script.sourceCharPosition : null],
['duration', Math.round(script.duration)],
['executionStart', script.executionStart != null ? Math.round(script.executionStart) : null],
['forcedStyleAndLayoutDuration', script.forcedStyleAndLayoutDuration != null ? Math.round(script.forcedStyleAndLayoutDuration) : null],
['pauseDuration', script.pauseDuration != null ? Math.round(script.pauseDuration) : null],
['windowAttribution', script.windowAttribution],
]);
}

/**
*
* @param report aggregated vitals report
*/
function serializeWebVitalsReport(report: WebVitalsReport): Json {
const metrics = Object.entries(report.metrics).reduce<Json>((acc, [name, metric]) => {
acc[name] = compactJson([
['name', metric.name],
['value', metric.value],
['rating', metric.rating],
['delta', metric.delta],
]);

return acc;
}, {});

return compactJson([
['summary', report.summary],
['poorCount', report.poorCount],
['metrics', metrics],
]);
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IssuesMonitor class lacks test coverage. The codebase has comprehensive test coverage for similar addons (e.g., BreadcrumbManager in breadcrumbs.test.ts). Critical behaviors that should be tested include: threshold validation and clamping, observer initialization and cleanup, metric collection logic, and the aggregation behavior for Web Vitals. Tests should also verify error handling when APIs are unsupported or when web-vitals package is missing.

Copilot uses AI. Check for mistakes.
Comment on lines 335 to 338

if (shouldDetectPerformanceIssues) {
this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context));
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The performance issues monitor is only initialized when errors are enabled. This means if a user sets issues.errors: false but issues.longTasks: true or issues.webVitals: true, those features won't work. Performance monitoring should be independent of error handling and should initialize regardless of the errors setting.

Suggested change
if (shouldDetectPerformanceIssues) {
this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context));
}
}
if (shouldDetectPerformanceIssues) {
this.issuesMonitor.init(issues, (entry) => this.send(entry.title, entry.context));

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 64
public init(options: IssuesOptions, onIssue: (event: IssueEvent) => void): void {
if (options.longTasks !== false) {
this.observeLongTasks(
resolveThreshold(options.longTasks?.thresholdMs, DEFAULT_LONG_TASK_THRESHOLD_MS),
onIssue
);
}

if (options.longAnimationFrames !== false) {
this.observeLoAF(
resolveThreshold(options.longAnimationFrames?.thresholdMs, DEFAULT_LOAF_THRESHOLD_MS),
onIssue
);
}

if (options.webVitals === true) {
this.observeWebVitals(onIssue);
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The init method can be called multiple times, which would create duplicate PerformanceObserver instances and result in duplicate issue reports. Consider adding a guard to prevent re-initialization, similar to how BreadcrumbManager has an isInitialized flag that prevents duplicate initialization.

Copilot uses AI. Check for mistakes.
Comment on lines 253 to 258
- **Long Tasks API** — reports any task taking longer than 50 ms.
- **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+).

Both freeze detectors are enabled by default. If one API is unsupported, the other still works.
Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.).
`thresholdMs` is the max allowed duration budget. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`.
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that Long Tasks API "reports any task taking longer than 50 ms" but this is the browser's inherent threshold, not configurable by the code. The actual behavior is: the browser reports tasks >= 50ms, then the code filters these based on the configured thresholdMs (default 100ms). The documentation should clarify that 50ms is the browser's minimum threshold, and the code applies an additional filter on top of that.

Suggested change
- **Long Tasks API** — reports any task taking longer than 50 ms.
- **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+).
Both freeze detectors are enabled by default. If one API is unsupported, the other still works.
Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.).
`thresholdMs` is the max allowed duration budget. Hawk emits an issue when measured duration is equal to or greater than this value. Values below `50ms` are clamped to `50ms`.
- **Long Tasks API**the browser reports tasks that block the main thread for at least 50 ms (this 50 ms minimum is defined by the browser and cannot be lowered by Hawk).
- **Long Animation Frames (LoAF)**the browser reports frames taking at least 50 ms with richer script attribution (Chrome 123+, Edge 123+); this 50 ms minimum is also browser-defined.
Both freeze detectors are enabled by default. If one API is unsupported, the other still works.
Each detected freeze is reported immediately with detailed context (duration, blocking time, scripts involved, etc.).
`thresholdMs` is the max allowed duration budget applied on top of what the browser reports. Hawk emits an issue only when the measured duration is equal to or greater than `thresholdMs`. Because the browser never reports events under 50 ms, values below `50ms` are clamped to `50ms`.

Copilot uses AI. Check for mistakes.
@Dobrunia Dobrunia changed the title feat: add main-thread blocking detection (Long Tasks + LoAF) feat: add main-thread blocking detection (Long Tasks + LoAF + web Vitals) Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants