Skip to content

Comments

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

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

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

Conversation

@Dobrunia
Copy link
Member

Summary

  • Add main-thread blocking detection to the JavaScript 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 via the existing send() pipeline with the blocking details in context
  • Both observers are enabled by default and can be toggled independently via the mainThreadBlocking setting, or disabled entirely with false
  • Feature detection is automatic — on unsupported browsers (anything non-Chromium) the observers silently skip with an info log

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 6 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
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.

2 participants