Skip to content

Commit f5a369f

Browse files
committed
feat: add main-thread blocking detection (Long Tasks + LoAF)
1 parent 4c3d0ee commit f5a369f

File tree

5 files changed

+249
-1
lines changed

5 files changed

+249
-1
lines changed

packages/javascript/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Error tracking for JavaScript/TypeScript applications.
1111
- 🛡️ Sensitive data filtering
1212
- 🌟 Source maps consuming
1313
- 💬 Console logs tracking
14+
- 🧊 Main-thread blocking detection (Chromium-only)
1415
- <img src="https://cdn.svglogos.dev/logos/vue.svg" width="16" height="16"> &nbsp;Vue support
1516
- <img src="https://cdn.svglogos.dev/logos/react.svg" width="16" height="16"> &nbsp;React support
1617

@@ -90,6 +91,7 @@ Initialization settings:
9091
| `consoleTracking` | boolean | optional | Initialize console logs tracking |
9192
| `breadcrumbs` | false or BreadcrumbsOptions object | optional | Configure breadcrumbs tracking (see below) |
9293
| `beforeSend` | function(event) => event \| false \| void | optional | Filter data before sending. Return modified event, `false` to drop the event. |
94+
| `mainThreadBlocking` | false or MainThreadBlockingOptions object | optional | Main-thread blocking detection (see below) |
9395

9496
Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition.
9597

@@ -232,6 +234,49 @@ const breadcrumbs = hawk.breadcrumbs.get();
232234
hawk.breadcrumbs.clear();
233235
```
234236
237+
## Main-Thread Blocking Detection
238+
239+
> **Chromium-only** (Chrome, Edge). On unsupported browsers the feature is silently skipped — no errors, no overhead.
240+
241+
Hawk can detect tasks that block the browser's main thread for too long and send them as dedicated events. Two complementary APIs are used under the hood:
242+
243+
- **Long Tasks API** — reports any task taking longer than 50 ms.
244+
- **Long Animation Frames (LoAF)** — reports frames taking longer than 50 ms with richer script attribution (Chrome 123+, Edge 123+).
245+
246+
Both are enabled by default. When a blocking entry is detected, Hawk immediately sends a separate event with details in the context (duration, blocking time, scripts involved, etc.).
247+
248+
### Disabling
249+
250+
Disable the feature entirely:
251+
252+
```js
253+
const hawk = new HawkCatcher({
254+
token: 'INTEGRATION_TOKEN',
255+
mainThreadBlocking: false
256+
});
257+
```
258+
259+
### Selective Configuration
260+
261+
Enable only one of the two observers:
262+
263+
```js
264+
const hawk = new HawkCatcher({
265+
token: 'INTEGRATION_TOKEN',
266+
mainThreadBlocking: {
267+
longTasks: true, // Long Tasks API (default: true)
268+
longAnimationFrames: false // LoAF (default: true)
269+
}
270+
});
271+
```
272+
273+
### Options
274+
275+
| Option | Type | Default | Description |
276+
|--------|------|---------|-------------|
277+
| `longTasks` | `boolean` | `true` | Observe Long Tasks (tasks blocking the main thread for >50 ms). |
278+
| `longAnimationFrames` | `boolean` | `true` | Observe Long Animation Frames — provides script-level attribution for slow frames. Requires Chrome 123+ / Edge 123+. |
279+
235280
## Source maps consuming
236281
237282
If your bundle is minified, it is useful to pass source-map files to the Hawk. After that you will see beautiful

packages/javascript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hawk.so/javascript",
3-
"version": "3.2.18",
3+
"version": "3.3.0",
44
"description": "JavaScript errors tracking for Hawk.so",
55
"files": [
66
"dist"
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @file Long Task & Long Animation Frame (LoAF) tracking via PerformanceObserver
3+
*
4+
* Both APIs are Chromium-only (Chrome, Edge).
5+
* - Long Tasks: tasks blocking the main thread for >50 ms
6+
* - LoAF (Chrome 123+): richer attribution per long animation frame
7+
*
8+
* Sets up observers and fires `onEntry` per detected entry — fire and forget.
9+
*/
10+
11+
import type { EventContext, Json } from '@hawk.so/types';
12+
import log from '../utils/log';
13+
14+
/**
15+
* Configuration for main-thread blocking detection
16+
*
17+
* Both features are Chromium-only (Chrome, Edge).
18+
* Feature detection is performed automatically — on unsupported browsers
19+
* the observers simply won't start.
20+
*/
21+
export interface MainThreadBlockingOptions {
22+
/**
23+
* Track Long Tasks (tasks blocking the main thread for >50 ms).
24+
* Uses PerformanceObserver with `longtask` entry type.
25+
*
26+
* Chromium-only (Chrome, Edge)
27+
*
28+
* @default true
29+
*/
30+
longTasks?: boolean;
31+
32+
/**
33+
* Track Long Animation Frames (LoAF) — frames taking >50 ms.
34+
* Provides richer attribution data than Long Tasks.
35+
* Uses PerformanceObserver with `long-animation-frame` entry type.
36+
*
37+
* Chromium-only (Chrome 123+, Edge 123+)
38+
*
39+
* @default true
40+
*/
41+
longAnimationFrames?: boolean;
42+
}
43+
44+
/**
45+
* Payload passed to the callback when a long task / LoAF is detected
46+
*/
47+
export interface LongTaskEvent {
48+
title: string;
49+
context: EventContext;
50+
}
51+
52+
/**
53+
* LoAF entry shape (spec is still evolving)
54+
*/
55+
interface LoAFEntry extends PerformanceEntry {
56+
blockingDuration?: number;
57+
scripts?: {
58+
name: string;
59+
invoker?: string;
60+
invokerType?: string;
61+
sourceURL?: string;
62+
duration: number;
63+
}[];
64+
}
65+
66+
function supportsEntryType(type: string): boolean {
67+
try {
68+
return (
69+
typeof PerformanceObserver !== 'undefined' &&
70+
typeof PerformanceObserver.supportedEntryTypes !== 'undefined' &&
71+
PerformanceObserver.supportedEntryTypes.includes(type)
72+
);
73+
} catch {
74+
return false;
75+
}
76+
}
77+
78+
function observeLongTasks(onEntry: (e: LongTaskEvent) => void): void {
79+
if (!supportsEntryType('longtask')) {
80+
log('Long Tasks API is not supported in this browser', 'info');
81+
82+
return;
83+
}
84+
85+
try {
86+
new PerformanceObserver((list) => {
87+
for (const entry of list.getEntries()) {
88+
const durationMs = Math.round(entry.duration);
89+
90+
onEntry({
91+
title: `Long Task ${durationMs} ms`,
92+
context: {
93+
kind: 'longtask',
94+
startTime: Math.round(entry.startTime),
95+
durationMs,
96+
},
97+
});
98+
}
99+
}).observe({ type: 'longtask', buffered: true });
100+
} catch { /* unsupported — ignore */ }
101+
}
102+
103+
function observeLoAF(onEntry: (e: LongTaskEvent) => void): void {
104+
if (!supportsEntryType('long-animation-frame')) {
105+
log('Long Animation Frames (LoAF) API is not supported in this browser', 'info');
106+
107+
return;
108+
}
109+
110+
try {
111+
new PerformanceObserver((list) => {
112+
for (const entry of list.getEntries()) {
113+
const loaf = entry as LoAFEntry;
114+
const durationMs = Math.round(loaf.duration);
115+
const blockingDurationMs = loaf.blockingDuration != null
116+
? Math.round(loaf.blockingDuration)
117+
: undefined;
118+
119+
const scripts = loaf.scripts
120+
?.filter((s) => s.sourceURL)
121+
.reduce<Record<string, Json>>((acc, s, i) => {
122+
acc[`script_${i}`] = {
123+
name: s.name,
124+
invoker: s.invoker ?? '',
125+
invokerType: s.invokerType ?? '',
126+
sourceURL: s.sourceURL ?? '',
127+
duration: Math.round(s.duration),
128+
};
129+
130+
return acc;
131+
}, {});
132+
133+
const blockingNote = blockingDurationMs != null
134+
? ` (blocking ${blockingDurationMs} ms)`
135+
: '';
136+
137+
const context: EventContext = {
138+
kind: 'loaf',
139+
startTime: Math.round(loaf.startTime),
140+
durationMs,
141+
};
142+
143+
if (blockingDurationMs != null) {
144+
context.blockingDurationMs = blockingDurationMs;
145+
}
146+
147+
if (scripts && Object.keys(scripts).length > 0) {
148+
context.scripts = scripts;
149+
}
150+
151+
onEntry({
152+
title: `Long Animation Frame ${durationMs} ms${blockingNote}`,
153+
context,
154+
});
155+
}
156+
}).observe({ type: 'long-animation-frame', buffered: true });
157+
} catch { /* unsupported — ignore */ }
158+
}
159+
160+
/**
161+
* Set up observers for main-thread blocking detection.
162+
* Each detected entry fires `onEntry` immediately.
163+
*/
164+
export function observeMainThreadBlocking(
165+
options: MainThreadBlockingOptions,
166+
onEntry: (e: LongTaskEvent) => void
167+
): void {
168+
if (options.longTasks ?? true) {
169+
observeLongTasks(onEntry);
170+
}
171+
172+
if (options.longAnimationFrames ?? true) {
173+
observeLoAF(onEntry);
174+
}
175+
}

packages/javascript/src/catcher.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { HawkJavaScriptEvent } from './types';
1818
import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
1919
import { ConsoleCatcher } from './addons/consoleCatcher';
2020
import { BreadcrumbManager } from './addons/breadcrumbs';
21+
import { observeMainThreadBlocking } from './addons/longTasks';
2122
import { validateUser, validateContext, isValidEventPayload } from './utils/validation';
2223

2324
/**
@@ -177,6 +178,17 @@ export default class Catcher {
177178
this.breadcrumbManager = null;
178179
}
179180

181+
/**
182+
* Main-thread blocking detection (Long Tasks + LoAF)
183+
* Chromium-only — on unsupported browsers this is a no-op
184+
*/
185+
if (settings.mainThreadBlocking !== false) {
186+
observeMainThreadBlocking(
187+
settings.mainThreadBlocking || {},
188+
(entry) => this.send(entry.title, entry.context)
189+
);
190+
}
191+
180192
/**
181193
* Set global handlers
182194
*/

packages/javascript/src/types/hawk-initial-settings.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { EventContext, AffectedUser } from '@hawk.so/types';
22
import type { HawkJavaScriptEvent } from './event';
33
import type { Transport } from './transport';
44
import type { BreadcrumbsOptions } from '../addons/breadcrumbs';
5+
import type { MainThreadBlockingOptions } from '../addons/longTasks';
56

67
/**
78
* JS Catcher initial settings
@@ -98,4 +99,19 @@ export interface HawkInitialSettings {
9899
* If not provided, default WebSocket transport is used.
99100
*/
100101
transport?: Transport;
102+
103+
/**
104+
* Main-thread blocking detection.
105+
* Observes Long Tasks and Long Animation Frames (LoAF) via PerformanceObserver
106+
* and sends a dedicated event when blocking is detected.
107+
*
108+
* Chromium-only (Chrome, Edge). On unsupported browsers the observers
109+
* simply won't start — no errors, no overhead.
110+
*
111+
* Pass `false` to disable entirely.
112+
* Pass an options object to toggle individual observers.
113+
*
114+
* @default enabled with default options (both longTasks and longAnimationFrames on)
115+
*/
116+
mainThreadBlocking?: false | MainThreadBlockingOptions;
101117
}

0 commit comments

Comments
 (0)