bee-ladybug 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD CHANGED
@@ -2,7 +2,7 @@
2
2
  # BeeLadybug
3
3
 
4
4
  <p align="center">
5
- <img src="https://raw.githubusercontent.com/antonioprosperi2-svg/BeeLadybug-universal/main/docs/logo.jpg" alt="BeeLadybug" width="720">
5
+ <img src="https://beeenginejs.com/wp-content/uploads/2026/10/beeladybug-logo-animato-ezgif.com-effects.gif" alt="BeeEngine Logo" width="900" />
6
6
  </p>
7
7
 
8
8
  Universal telemetry and visual inspection overlay.
@@ -133,12 +133,26 @@ bee.subscribe((packet) => {
133
133
  | `F2` | mostra / nascondi overlay (e hitbox canvas) |
134
134
  | `F3` | slow-motion del tempo fittizio (`0.25x` / `1x`) |
135
135
  | `F4` | freeze / run del tempo fittizio |
136
+ | `F8` | scarica il report HTML |
136
137
  | `F6` | CSS hunt, se `CSSAdapter` è attaccato |
137
138
  | `P` | blocca il pannello CSS sull'elemento corrente, solo con la hunt accesa |
138
139
  | `S` | scansione CSS, solo con la hunt accesa |
139
140
 
140
141
  Niente `F12`, niente tilde: sui layout italiani la tilde non è un tasto unico.
141
142
 
143
+ ## Export
144
+
145
+ `F8`, il pulsante **F8 EXPORT** nel piede dell'overlay, o `exportReport()` scaricano `report-AAAAMMGG-HHMM.html`. Il file raccoglie log, serie e una sezione per ogni adapter attaccato. `json` e `csv` usano gli stessi dati.
146
+
147
+ ```js
148
+ import { exportReport } from './src/index.js';
149
+
150
+ exportReport(bee, { format: 'html' });
151
+ bee.exportReport('json');
152
+ ```
153
+
154
+ Con `ui: false`, o senza `document`, la chiamata restituisce la stringa o l'oggetto e non scarica nulla.
155
+
142
156
  ## CanvasAdapter
143
157
 
144
158
  ```js
@@ -350,6 +364,7 @@ const bee = new BeeLadybugCore({
350
364
  toggleKey: 'F2',
351
365
  slowKey: 'F3',
352
366
  freezeKey: 'F4',
367
+ exportKey: 'F8',
353
368
  slowScale: 0.25,
354
369
  maxLogs: 250,
355
370
  historySize: 120,
@@ -368,6 +383,7 @@ bee.getHud();
368
383
  bee.show(); bee.hide(); bee.toggle();
369
384
  bee.freeze(); bee.unfreeze(); bee.toggleFreeze();
370
385
  bee.applySlowMo(); bee.restoreRealtime();
386
+ bee.exportReport('html');
371
387
  bee.registerAssistant({ name: 'mock', complete: async ({ snapshot }) => 'ok' });
372
388
  bee.setActiveAssistant('mock');
373
389
  await bee.ask('why fps drop?');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bee-ladybug",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Universal telemetry and visual inspection overlay for Canvas, DOM, Python and AI flows.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -2,6 +2,7 @@
2
2
  * AntAdapter — DOM mutation rate tracker.
3
3
  *
4
4
  * Independent MutationObserver. Can sit on the same root as WebDOMAdapter.
5
+ * `snapshot()` lists nodes still over the mutation threshold.
5
6
  * The core must never import this file.
6
7
  *
7
8
  * @example
@@ -9,6 +10,10 @@
9
10
  * ant.attach();
10
11
  */
11
12
 
13
+ import { isReportNoise } from '../core/ReportExporter.js';
14
+
15
+ const MAX_HOT = 40;
16
+
12
17
  export const ANT_ADAPTER_DEFAULTS = Object.freeze({
13
18
  threshold: 30,
14
19
  windowMs: 1000,
@@ -35,12 +40,14 @@ export class AntAdapter {
35
40
  this.#attached = false;
36
41
  this.#observer = null;
37
42
  this.#counts = new WeakMap();
43
+ this.#hot = [];
38
44
  this.#onMutate = (records) => this.#ingest(records);
39
45
  }
40
46
 
41
47
  #attached;
42
48
  #observer;
43
49
  #counts;
50
+ #hot;
44
51
  #onMutate;
45
52
 
46
53
  attach() {
@@ -50,7 +57,8 @@ export class AntAdapter {
50
57
  this.core?.registerAdapter('ant', {
51
58
  enable: () => this.#enable(),
52
59
  disable: () => this.#disable(),
53
- label: 'ANT'
60
+ label: 'ANT',
61
+ snapshot: () => this.snapshot()
54
62
  });
55
63
  this.core?.sendData('log', {
56
64
  source: this.source,
@@ -67,6 +75,30 @@ export class AntAdapter {
67
75
  return this;
68
76
  }
69
77
 
78
+ /**
79
+ * Nodes whose current window is still at or above the threshold.
80
+ * Hidden boxes, the WordPress admin bar, and BeeLadybug chrome are skipped.
81
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
82
+ */
83
+ snapshot() {
84
+ if (!this.#attached) return null;
85
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
86
+ const items = [];
87
+ for (let i = 0; i < this.#hot.length && items.length < MAX_HOT; i++) {
88
+ const row = this.#hot[i];
89
+ if (!row || now - row.windowStart >= this.windowMs) continue;
90
+ if (isBeeChrome(row.node) || isReportNoise(row.node)) continue;
91
+ items.push({
92
+ severity: 'warn',
93
+ selector: row.selector,
94
+ rule: 'mutations',
95
+ value: `${row.count}/${this.windowMs}ms`,
96
+ source: this.source
97
+ });
98
+ }
99
+ return { name: 'ant', items };
100
+ }
101
+
70
102
  #enable() {
71
103
  if (!this.#attached || this.#observer) return;
72
104
  if (typeof MutationObserver === 'undefined' || !this.root) return;
@@ -89,6 +121,7 @@ export class AntAdapter {
89
121
  if (this.#observer) this.#observer.disconnect();
90
122
  this.#observer = null;
91
123
  this.#counts = new WeakMap();
124
+ this.#hot = [];
92
125
  }
93
126
 
94
127
  #ingest(records) {
@@ -104,6 +137,7 @@ export class AntAdapter {
104
137
  this.#counts.set(node, slot);
105
138
  }
106
139
  slot.count += 1;
140
+ this.#track(node, slot);
107
141
  if (slot.count < this.threshold || slot.warned) continue;
108
142
 
109
143
  slot.warned = true;
@@ -115,6 +149,23 @@ export class AntAdapter {
115
149
  });
116
150
  }
117
151
  }
152
+
153
+ #track(node, slot) {
154
+ const index = this.#hot.findIndex((row) => row.node === node);
155
+ if (slot.count < this.threshold) {
156
+ if (index >= 0) this.#hot.splice(index, 1);
157
+ return;
158
+ }
159
+ const row = {
160
+ node,
161
+ selector: nodeSelector(node),
162
+ count: slot.count,
163
+ windowStart: slot.windowStart
164
+ };
165
+ if (index >= 0) this.#hot[index] = row;
166
+ else this.#hot.push(row);
167
+ if (this.#hot.length > MAX_HOT) this.#hot.splice(0, this.#hot.length - MAX_HOT);
168
+ }
118
169
  }
119
170
 
120
171
  function positive(value, fallback) {
@@ -4,6 +4,7 @@
4
4
  * Hover an element to list !important hits, viewport overflow, negative
5
5
  * margins, invisible text, click blockers and stretched images. The core
6
6
  * only receives packets and detail rows; it does not parse CSS.
7
+ * `snapshot()` repeats that scan for the report while the hunt is off.
7
8
  *
8
9
  * @example
9
10
  * const css = new CSSAdapter(core);
@@ -11,6 +12,10 @@
11
12
  * // F6 toggles hunt. S scans the page while hunt is on.
12
13
  */
13
14
 
15
+ import { isReportNoise } from '../core/ReportExporter.js';
16
+
17
+ const MAX_SNAPSHOT = 80;
18
+
14
19
  export const CSS_ADAPTER_DEFAULTS = Object.freeze({
15
20
  source: 'css',
16
21
  hotkey: 'F6',
@@ -104,7 +109,8 @@ export class CSSAdapter {
104
109
  hotkey: this.hotkey,
105
110
  enabled: false,
106
111
  enable: () => this.#enable(),
107
- disable: () => this.#disable()
112
+ disable: () => this.#disable(),
113
+ snapshot: () => this.snapshot()
108
114
  });
109
115
  this.core?.sendData('log', {
110
116
  source: this.source,
@@ -191,6 +197,98 @@ export class CSSAdapter {
191
197
  return this;
192
198
  }
193
199
 
200
+ /**
201
+ * Full hunt scan for the report. Hunt can stay off: nothing is logged,
202
+ * pinned, or published. Hidden boxes, WordPress admin, and BeeLadybug
203
+ * chrome are skipped.
204
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
205
+ */
206
+ snapshot() {
207
+ if (!this.#attached) return null;
208
+ if (typeof document === 'undefined' || !document.body) return { name: 'css', items: [] };
209
+
210
+ const built = collectImportantRules();
211
+ const nodes = document.body.querySelectorAll('*');
212
+ const overflow = [];
213
+ const rows = [];
214
+ for (let i = 0; i < nodes.length; i++) {
215
+ const el = nodes[i];
216
+ if (!el || isBeeChrome(el) || isSkippedTag(el) || isReportNoise(el)) continue;
217
+ const measured = measureViewportOverflow(el);
218
+ if (measured) overflow.push(measured);
219
+ const findings = [];
220
+ pushNegativeMargins(el, findings);
221
+ pushInvisible(el, findings);
222
+ pushBlockers(el, findings);
223
+ pushImage(el, findings);
224
+ pushStacking(el, findings);
225
+ pushImportant(el, built.rules, findings);
226
+ for (let j = 0; j < findings.length; j++) {
227
+ rows.push({ el, selector: cssPath(el), ...findings[j] });
228
+ }
229
+ }
230
+ const kept = keepOverflowCauses(overflow);
231
+ for (let i = 0; i < kept.length; i++) {
232
+ const cause = kept[i];
233
+ rows.push({
234
+ el: cause.el,
235
+ selector: cssPath(cause.el),
236
+ kind: 'overflows viewport',
237
+ rank: 0,
238
+ level: 'error',
239
+ score: cause.score
240
+ });
241
+ }
242
+
243
+ let causes = [];
244
+ const plain = [];
245
+ for (let i = 0; i < rows.length; i++) {
246
+ const row = rows[i];
247
+ if (describeCause(row.kind, row.selector)) {
248
+ causes = addCause(causes, { kind: row.kind, path: row.selector, el: row.el }, MAX_SNAPSHOT);
249
+ } else {
250
+ plain.push(row);
251
+ }
252
+ }
253
+
254
+ const items = [];
255
+ for (let i = 0; i < causes.length; i++) {
256
+ const entry = causes[i];
257
+ items.push({
258
+ severity: reportSeverity(entry.level),
259
+ selector: entry.cause,
260
+ rule: causeRule(entry.type),
261
+ value: formatCause(entry),
262
+ source: this.source
263
+ });
264
+ }
265
+ const groups = groupIssues(plain);
266
+ for (let i = 0; i < groups.length; i++) {
267
+ const group = groups[i];
268
+ const count = group.count > 1 ? ` ×${group.count}` : '';
269
+ items.push({
270
+ severity: reportSeverity(group.level),
271
+ selector: group.selector,
272
+ rule: plainRule(group.kind),
273
+ value: `${group.kind}${count}`,
274
+ source: this.source
275
+ });
276
+ }
277
+ for (let i = 0; i < built.unreadable.length; i++) {
278
+ const label = built.unreadable[i];
279
+ items.push({
280
+ severity: 'ok',
281
+ selector: label,
282
+ rule: 'unreadable sheet',
283
+ value: label,
284
+ source: this.source
285
+ });
286
+ }
287
+ items.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
288
+ if (items.length > MAX_SNAPSHOT) items.length = MAX_SNAPSHOT;
289
+ return { name: 'css', items };
290
+ }
291
+
194
292
  #enable() {
195
293
  if (this.#active || typeof document === 'undefined' || typeof window === 'undefined') return;
196
294
  this.#active = true;
@@ -919,6 +1017,34 @@ function nowMs() {
919
1017
  return typeof performance !== 'undefined' ? performance.now() : Date.now();
920
1018
  }
921
1019
 
1020
+ function reportSeverity(level) {
1021
+ if (level === 'error') return 'error';
1022
+ if (level === 'warn' || level === 'warning') return 'warn';
1023
+ return 'ok';
1024
+ }
1025
+
1026
+ function severityRank(level) {
1027
+ if (level === 'error') return 0;
1028
+ if (level === 'warn') return 1;
1029
+ return 2;
1030
+ }
1031
+
1032
+ function causeRule(type) {
1033
+ if (type === 'block') return 'click blocked';
1034
+ if (type === 'stack') return 'stacking context';
1035
+ if (type === 'stack-hint') return 'will-change';
1036
+ return 'overflow';
1037
+ }
1038
+
1039
+ function plainRule(kind) {
1040
+ const text = String(kind || '');
1041
+ if (text.startsWith('!important')) return '!important';
1042
+ if (text.startsWith('invisible text')) return 'invisible text';
1043
+ if (text.startsWith('negative margin')) return 'negative margin';
1044
+ if (text === 'distorted image') return 'distorted image';
1045
+ return text;
1046
+ }
1047
+
922
1048
  function issue(kind, rank, score) {
923
1049
  return { level: 'error', rank, score, kind, text: kind };
924
1050
  }
@@ -4,6 +4,7 @@
4
4
  * Reads generic AABB entities, measures host FPS, computes collisions,
5
5
  * and pushes packets into the core. Hitboxes are drawn on a stacked
6
6
  * overlay canvas so the game pixels stay untouched.
7
+ * `snapshot()` hands the report hitboxes, collisions, and FPS min/avg/max.
7
8
  *
8
9
  * The core must never import this file.
9
10
  *
@@ -59,6 +60,7 @@ export class CanvasAdapter {
59
60
  this.timeScale = 1;
60
61
 
61
62
  this.#attached = false;
63
+ this.#reporting = true;
62
64
  this.#overlay = null;
63
65
  this.#unsubscribe = null;
64
66
  this.#rafId = 0;
@@ -66,6 +68,7 @@ export class CanvasAdapter {
66
68
  this.#manualPumpWarned = false;
67
69
  this.#lastPump = 0;
68
70
  this.#fps = 0;
71
+ this.#fpsSamples = [];
69
72
  this.#wasColliding = new Set();
70
73
  this.#onResize = () => this.#syncOverlay();
71
74
  this.#onTick = () => {
@@ -80,6 +83,7 @@ export class CanvasAdapter {
80
83
  }
81
84
 
82
85
  #attached;
86
+ #reporting;
83
87
  #overlay;
84
88
  #unsubscribe;
85
89
  #rafId;
@@ -87,6 +91,7 @@ export class CanvasAdapter {
87
91
  #manualPumpWarned;
88
92
  #lastPump;
89
93
  #fps;
94
+ #fpsSamples;
90
95
  #wasColliding;
91
96
  #onResize;
92
97
  #onTick;
@@ -108,6 +113,15 @@ export class CanvasAdapter {
108
113
  }
109
114
  if (this.autoPump) this.startAutoPump();
110
115
 
116
+ this.#reporting = true;
117
+ this.core?.registerAdapter('canvas', {
118
+ label: 'CANVAS',
119
+ enabled: true,
120
+ enable: () => this.#enable(),
121
+ disable: () => this.#disable(),
122
+ snapshot: () => this.snapshot()
123
+ });
124
+
111
125
  this.core?.sendData('log', {
112
126
  source: this.source,
113
127
  message: `CanvasAdapter attached (${this.entities.length} entities).`
@@ -126,6 +140,7 @@ export class CanvasAdapter {
126
140
  this.#stopAutoPumpLoop();
127
141
  if (this.#unsubscribe) this.#unsubscribe();
128
142
  this.#unsubscribe = null;
143
+ this.core?.unregisterAdapter('canvas');
129
144
  this.#unmountOverlay();
130
145
  if (typeof window !== 'undefined') {
131
146
  window.removeEventListener('resize', this.#onResize);
@@ -161,6 +176,28 @@ export class CanvasAdapter {
161
176
  return this;
162
177
  }
163
178
 
179
+ /**
180
+ * Hitboxes currently on the entity list, plus FPS min/avg/max.
181
+ * Returns null when the adapter is not attached. The core stores this
182
+ * function and does not read the rows.
183
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
184
+ */
185
+ snapshot() {
186
+ if (!this.#attached) return null;
187
+ const boxes = this.#collectBoxes();
188
+ this.#markCollisions(boxes);
189
+ const items = boxes.map((box) => ({
190
+ severity: box.hit ? 'warn' : 'ok',
191
+ selector: box.name,
192
+ rule: 'hitbox',
193
+ value: `${box.x},${box.y} ${box.w}x${box.h}${box.hit ? ' colliding' : ''}`,
194
+ source: this.source
195
+ }));
196
+ const fps = this.#fpsItem();
197
+ if (fps) items.push(fps);
198
+ return { name: 'canvas', items };
199
+ }
200
+
164
201
  /**
165
202
  * Call once per host frame, after entity positions are updated.
166
203
  * Pass a 2D context only if overlay is disabled and you want hitboxes
@@ -174,6 +211,7 @@ export class CanvasAdapter {
174
211
  this.#warnManualPump();
175
212
  return this;
176
213
  }
214
+ if (!this.#reporting) return this;
177
215
 
178
216
  const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
179
217
  if (this.#lastPump) {
@@ -257,6 +295,8 @@ export class CanvasAdapter {
257
295
  if (!tracked && boxes[0]) tracked = boxes[0];
258
296
 
259
297
  if (this.#fps > 0) {
298
+ this.#fpsSamples.push(this.#fps);
299
+ if (this.#fpsSamples.length > 120) this.#fpsSamples.shift();
260
300
  this.core.sendData('fps', { value: this.#fps, source: this.source });
261
301
  }
262
302
  this.core.sendData('metric', {
@@ -374,7 +414,49 @@ export class CanvasAdapter {
374
414
  overlay.style.top = `${rect.top}px`;
375
415
  overlay.style.width = `${rect.width}px`;
376
416
  overlay.style.height = `${rect.height}px`;
377
- overlay.style.display = this.core && this.core.visible === false ? 'none' : 'block';
417
+ const hidden = !this.#reporting || (this.core && this.core.visible === false);
418
+ overlay.style.display = hidden ? 'none' : 'block';
419
+ }
420
+
421
+ #enable() {
422
+ this.#reporting = true;
423
+ this.#syncOverlay();
424
+ }
425
+
426
+ #disable() {
427
+ this.#reporting = false;
428
+ this.#syncOverlay();
429
+ }
430
+
431
+ #fpsItem() {
432
+ const stats = this.#fpsStats();
433
+ if (!stats) return null;
434
+ return {
435
+ severity: 'ok',
436
+ selector: 'host',
437
+ rule: 'fps',
438
+ value: `min ${round1(stats.min)} avg ${round1(stats.avg)} max ${round1(stats.max)}`,
439
+ source: this.source
440
+ };
441
+ }
442
+
443
+ #fpsStats() {
444
+ const history = typeof this.core?.getHistory === 'function' ? this.core.getHistory('fps') : null;
445
+ if (history && Array.isArray(history.values) && history.values.length) {
446
+ return { min: history.min, avg: history.avg, max: history.max };
447
+ }
448
+ const samples = this.#fpsSamples;
449
+ if (!samples.length) return null;
450
+ let min = samples[0];
451
+ let max = samples[0];
452
+ let sum = 0;
453
+ for (let i = 0; i < samples.length; i++) {
454
+ const value = samples[i];
455
+ if (value < min) min = value;
456
+ if (value > max) max = value;
457
+ sum += value;
458
+ }
459
+ return { min, avg: sum / samples.length, max };
378
460
  }
379
461
  }
380
462
 
@@ -409,3 +491,7 @@ function idFromKey(id) {
409
491
  const parts = String(id).split(':');
410
492
  return parts[parts.length - 1] || id;
411
493
  }
494
+
495
+ function round1(value) {
496
+ return Math.round(Number(value) * 10) / 10;
497
+ }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * SpiderAdapter — long-task and slow-resource tracker.
3
3
  *
4
- * Native PerformanceObserver only. The core must never import this file.
4
+ * Native PerformanceObserver only. `snapshot()` keeps the long tasks and
5
+ * slow resources already seen. The core must never import this file.
5
6
  *
6
7
  * @example
7
8
  * const spider = new SpiderAdapter(core, { resourceThreshold: 500 });
8
9
  * spider.attach();
9
10
  */
10
11
 
12
+ const MAX_SPIDER = 40;
13
+
11
14
  export const SPIDER_ADAPTER_DEFAULTS = Object.freeze({
12
15
  longTaskThreshold: 50,
13
16
  resourceThreshold: 500,
@@ -30,6 +33,8 @@ export class SpiderAdapter {
30
33
  this.#longObserver = null;
31
34
  this.#resourceObserver = null;
32
35
  this.#longTaskMissingLogged = false;
36
+ this.#longTasks = [];
37
+ this.#resources = [];
33
38
  this.#onLongTask = (list) => this.#ingestLongTasks(list);
34
39
  this.#onResource = (list) => this.#ingestResources(list);
35
40
  }
@@ -38,6 +43,8 @@ export class SpiderAdapter {
38
43
  #longObserver;
39
44
  #resourceObserver;
40
45
  #longTaskMissingLogged;
46
+ #longTasks;
47
+ #resources;
41
48
  #onLongTask;
42
49
  #onResource;
43
50
 
@@ -48,7 +55,8 @@ export class SpiderAdapter {
48
55
  this.core?.registerAdapter('spider', {
49
56
  enable: () => this.#enable(),
50
57
  disable: () => this.#disable(),
51
- label: 'SPIDER'
58
+ label: 'SPIDER',
59
+ snapshot: () => this.snapshot()
52
60
  });
53
61
  this.core?.sendData('log', {
54
62
  source: this.source,
@@ -65,6 +73,36 @@ export class SpiderAdapter {
65
73
  return this;
66
74
  }
67
75
 
76
+ /**
77
+ * Long tasks and slow resources kept since attach. Returns null when detached.
78
+ * @returns {{ name: string, items: Array<{ severity: string, selector: string, rule: string, value: string, source: string }> } | null}
79
+ */
80
+ snapshot() {
81
+ if (!this.#attached) return null;
82
+ const items = [];
83
+ for (let i = 0; i < this.#longTasks.length; i++) {
84
+ const entry = this.#longTasks[i];
85
+ items.push({
86
+ severity: 'warn',
87
+ selector: entry.attribution,
88
+ rule: 'longtask',
89
+ value: `${entry.duration}ms`,
90
+ source: this.source
91
+ });
92
+ }
93
+ for (let i = 0; i < this.#resources.length; i++) {
94
+ const entry = this.#resources[i];
95
+ items.push({
96
+ severity: 'warn',
97
+ selector: entry.name,
98
+ rule: 'resource',
99
+ value: `${entry.duration}ms`,
100
+ source: this.source
101
+ });
102
+ }
103
+ return { name: 'spider', items };
104
+ }
105
+
68
106
  #enable() {
69
107
  if (!this.#attached) return;
70
108
  if (typeof PerformanceObserver !== 'function') {
@@ -111,6 +149,10 @@ export class SpiderAdapter {
111
149
  for (let i = 0; i < entries.length; i++) {
112
150
  const entry = entries[i];
113
151
  if (!entry || entry.duration <= this.longTaskThreshold) continue;
152
+ remember(this.#longTasks, {
153
+ attribution: entry.attribution?.[0]?.containerType ?? 'unknown',
154
+ duration: Math.round(entry.duration)
155
+ });
114
156
  this.core.sendData('telemetry', {
115
157
  source: this.source,
116
158
  kind: 'longtask',
@@ -126,6 +168,10 @@ export class SpiderAdapter {
126
168
  for (let i = 0; i < entries.length; i++) {
127
169
  const entry = entries[i];
128
170
  if (!entry || entry.duration <= this.resourceThreshold) continue;
171
+ remember(this.#resources, {
172
+ name: String(entry.name || 'resource'),
173
+ duration: Math.round(entry.duration)
174
+ });
129
175
  this.core.sendData('warn', {
130
176
  source: this.source,
131
177
  message: `Risorsa lenta: ${entry.name} (${Math.round(entry.duration)}ms)`
@@ -143,6 +189,11 @@ export class SpiderAdapter {
143
189
  }
144
190
  }
145
191
 
192
+ function remember(list, entry) {
193
+ list.unshift(entry);
194
+ if (list.length > MAX_SPIDER) list.pop();
195
+ }
196
+
146
197
  function positive(value, fallback) {
147
198
  const n = Number(value);
148
199
  return Number.isFinite(n) && n > 0 ? n : fallback;