@newrelic/browser-agent 1.321.0-rc.7 → 1.321.0-rc.9

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.
Files changed (46) hide show
  1. package/dist/cjs/common/constants/env.cdn.js +1 -1
  2. package/dist/cjs/common/constants/env.npm.js +1 -1
  3. package/dist/cjs/common/util/console.js +1 -0
  4. package/dist/cjs/common/v2/manifest.js +142 -0
  5. package/dist/cjs/common/v2/script-tracker-types.js +27 -0
  6. package/dist/cjs/common/v2/script-tracker.js +223 -25
  7. package/dist/cjs/common/v2/utils.js +47 -1
  8. package/dist/cjs/features/generic_events/aggregate/index.js +28 -1
  9. package/dist/cjs/loaders/api/register-api-types.js +13 -4
  10. package/dist/cjs/loaders/api/register.js +18 -0
  11. package/dist/esm/common/constants/env.cdn.js +1 -1
  12. package/dist/esm/common/constants/env.npm.js +1 -1
  13. package/dist/esm/common/util/console.js +1 -0
  14. package/dist/esm/common/v2/manifest.js +136 -0
  15. package/dist/esm/common/v2/script-tracker-types.js +24 -0
  16. package/dist/esm/common/v2/script-tracker.js +222 -26
  17. package/dist/esm/common/v2/utils.js +46 -1
  18. package/dist/esm/features/generic_events/aggregate/index.js +29 -2
  19. package/dist/esm/loaders/api/register-api-types.js +14 -4
  20. package/dist/esm/loaders/api/register.js +18 -1
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/dist/types/common/config/init-types.d.ts +1 -1
  23. package/dist/types/common/util/console.d.ts +1 -0
  24. package/dist/types/common/util/console.d.ts.map +1 -1
  25. package/dist/types/common/v2/manifest.d.ts +66 -0
  26. package/dist/types/common/v2/manifest.d.ts.map +1 -0
  27. package/dist/types/common/v2/script-tracker-types.d.ts +25 -0
  28. package/dist/types/common/v2/script-tracker-types.d.ts.map +1 -0
  29. package/dist/types/common/v2/script-tracker.d.ts +25 -4
  30. package/dist/types/common/v2/script-tracker.d.ts.map +1 -1
  31. package/dist/types/common/v2/utils.d.ts +12 -0
  32. package/dist/types/common/v2/utils.d.ts.map +1 -1
  33. package/dist/types/features/generic_events/aggregate/index.d.ts.map +1 -1
  34. package/dist/types/loaders/api/register-api-types.d.ts +31 -4
  35. package/dist/types/loaders/api/register-api-types.d.ts.map +1 -1
  36. package/dist/types/loaders/api/register.d.ts +1 -0
  37. package/dist/types/loaders/api/register.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/common/util/console.js +1 -0
  40. package/src/common/v2/manifest.js +126 -0
  41. package/src/common/v2/script-tracker-types.js +24 -0
  42. package/src/common/v2/script-tracker.js +218 -26
  43. package/src/common/v2/utils.js +42 -1
  44. package/src/features/generic_events/aggregate/index.js +22 -2
  45. package/src/loaders/api/register-api-types.js +14 -4
  46. package/src/loaders/api/register.js +16 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/browser-agent",
3
- "version": "1.321.0-rc.7",
3
+ "version": "1.321.0-rc.9",
4
4
  "private": false,
5
5
  "author": "New Relic Browser Agent Team <browser-agent@newrelic.com>",
6
6
  "description": "New Relic Browser Agent",
@@ -95,6 +95,7 @@ import { dispatchGlobalEvent } from '../dispatch/global-event'
95
95
  * | 77 | Agent rejected post message, could not validate origin. |
96
96
  * | 78 | RegisteredIframeEntity could not determine parent origin and will not register, to avoid trusting messages from any origin. |
97
97
  * | 79 | Unable to initialize Connector and/or Harvester. |
98
+ * | 80 | An invalid manifest option was provided to register() and will be ignored. |
98
99
  * | 81 | Entities were detected that share a name with different IDs - This can cause multiple entities to have the same name in New Relic. |
99
100
  * | 82 | Entities were detected that share an ID with different names - This can cause your entity's name to change unexpectedly. |
100
101
  *
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { cleanURL } from '../url/clean-url'
7
+ import { warn } from '../util/console'
8
+ import { single } from '../util/invoke'
9
+
10
+ /**
11
+ * @typedef {Object} AssetFile
12
+ * @property {string|RegExp} matcher - the path/path-fragment string, or RegExp, used to match a resolved URL against this asset
13
+ * @property {'script'|'asset'} [type] - optional override for script-capability inference. When omitted, script-capability is inferred from a
14
+ * `.js` suffix on a string `matcher` (a RegExp `matcher` is never inferred as a script). Supply `type: 'script'` to explicitly flag an entry
15
+ * as a script regardless of its matcher shape/suffix (e.g. an extensionless URL, or a RegExp targeting a script path), or `type: 'asset'` to
16
+ * explicitly flag it as non-script. Any other value is invalid, logs a warning, and is ignored (falls back to inference).
17
+ */
18
+
19
+ /** Warns at most once per page for any invalid manifest entry (bad `matcher` or bad `type`) -- mirrors the
20
+ * existing `invalidTimingMethod` single()-wrapped warning pattern in register.js. */
21
+ const warnInvalidManifestEntry = single((secondary) => warn(80, secondary))
22
+
23
+ /**
24
+ * @typedef {Object} ParsedManifestAsset
25
+ * @property {string|RegExp} pattern - the original matcher supplied by the customer
26
+ * @property {(url: string) => boolean} test - precompiled matcher against a resolved URL
27
+ * @property {boolean} isScript
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} ParsedManifest
32
+ * @property {ParsedManifestAsset[]} assets - all supplied assets
33
+ * @property {ParsedManifestAsset[]} scripts - the subset of assets inferred/declared as scripts (`.js`)
34
+ */
35
+
36
+ /**
37
+ * Parses a raw manifest supplied to `register()` into precompiled matcher closures. Parsing happens once, at
38
+ * registration time, so downstream event attribution (which runs on every ajax/error/log/websocket event) never has
39
+ * to re-derive matching logic from the raw customer input.
40
+ * @param {{assets?: Array<AssetFile>}} [rawManifest]
41
+ * @returns {ParsedManifest|undefined} undefined if no usable assets were supplied, so callers can cheaply skip all manifest logic
42
+ */
43
+ export function parseManifest (rawManifest) {
44
+ if (!Array.isArray(rawManifest?.assets) || !rawManifest.assets.length) return undefined
45
+
46
+ const assets = rawManifest.assets.map(parseAsset).filter(Boolean)
47
+ if (!assets.length) return undefined
48
+
49
+ return { assets, scripts: assets.filter(asset => asset.isScript) }
50
+ }
51
+
52
+ /**
53
+ * @param {AssetFile} entry
54
+ * @returns {ParsedManifestAsset|undefined}
55
+ */
56
+ function parseAsset (entry) {
57
+ if (!entry || typeof entry !== 'object') return undefined
58
+
59
+ const { matcher, type } = entry
60
+ const inferredIsScript = typeof matcher === 'string' && isScriptPath(matcher)
61
+
62
+ let isScript = inferredIsScript
63
+ if (type !== undefined) {
64
+ if (isValidType(type)) {
65
+ isScript = isScriptType(type)
66
+ } else {
67
+ // Invalid `type` never overrides -- fall back to inference-only behavior rather than forcing `isScript: false`,
68
+ // so a typo'd `type` can't silently break stack-trace attribution for an otherwise-valid `.js` matcher.
69
+ warnInvalidManifestEntry(type)
70
+ }
71
+ }
72
+
73
+ if (isRegExp(matcher)) {
74
+ // Reset lastIndex before every test -- a `g`/`y` flagged matcher otherwise carries state across calls
75
+ // (this closure is reused for every event), causing intermittent, input-order-dependent match failures.
76
+ return { pattern: matcher, test: (url) => { matcher.lastIndex = 0; return matcher.test(cleanURL(url)) }, isScript }
77
+ }
78
+
79
+ if (typeof matcher === 'string' && matcher.length > 0) {
80
+ return { pattern: matcher, test: (url) => cleanURL(url).includes(matcher), isScript }
81
+ }
82
+
83
+ // Every other matcher shape (missing, empty string, null, number, plain object, ...) is invalid -- notably an
84
+ // empty string must be rejected here rather than falling through to the string branch above, since
85
+ // ''.includes('') is always true and would otherwise silently match every URL.
86
+ warnInvalidManifestEntry(matcher)
87
+ return undefined
88
+ }
89
+
90
+ function isScriptPath (path) {
91
+ return path.endsWith('.js')
92
+ }
93
+
94
+ function isValidType (type) {
95
+ return type === 'script' || type === 'asset'
96
+ }
97
+
98
+ /**
99
+ * Cross-realm-safe check for whether a value is a RegExp. `instanceof RegExp` only returns true when the value's
100
+ * prototype chain links to THIS realm's `RegExp.prototype` -- a regex literal constructed in a different realm
101
+ * (an iframe, a Worker via structured clone, a WebDriver sandbox such as Firefox's geckodriver executeScript
102
+ * context) is still a genuine RegExp, just not an instance of this realm's constructor, and would otherwise be
103
+ * silently dropped by parseAsset below.
104
+ * @param {*} value
105
+ * @returns {boolean}
106
+ */
107
+ function isRegExp (value) {
108
+ return Object.prototype.toString.call(value) === '[object RegExp]'
109
+ }
110
+
111
+ function isScriptType (type) {
112
+ return type === 'script'
113
+ }
114
+
115
+ /**
116
+ * Determines whether a resolved URL matches any asset in a parsed manifest.
117
+ * @param {ParsedManifest|undefined} parsed
118
+ * @param {string} url
119
+ * @param {{scriptsOnly?: boolean}} [options]
120
+ * @returns {boolean}
121
+ */
122
+ export function matchManifestAsset (parsed, url, { scriptsOnly = false } = {}) {
123
+ if (!parsed || !url) return false
124
+ const candidates = scriptsOnly ? parsed.scripts : parsed.assets
125
+ return candidates.some(asset => asset.test(url))
126
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * @typedef {(start: number, end: number) => void} RecordManifestScriptWindowFn - Widens the live scriptStart/
8
+ * scriptEnd window with one manifest script asset's DOM correlation timing. `start`/`end` are that asset's
9
+ * `correlation.script.start`/`.end`, or falsy if not yet resolved. See `findScriptTimings`, which registers the
10
+ * concrete implementation, for the exact widening semantics (never shrinks either bound).
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} TimingsInternals
15
+ * @property {Set<string>} weighedAssetUrls - Always present (seeded by `getOrCreateInternals`, see
16
+ * `script-tracker.js`). Cleaned URLs already folded into `timings.totalWeight`/`timings.renderBlocking` by
17
+ * `applyResourceWeight`, so the same underlying resource (e.g. a manifest asset that's also the .register calling
18
+ * script itself) is never counted twice.
19
+ * @property {RecordManifestScriptWindowFn} recordManifestScriptWindow - Always present (seeded by
20
+ * `getOrCreateInternals`, see `script-tracker.js`). `findScriptTimings` overrides the seeded default with one that
21
+ * folds into the live `scriptStart`/`scriptEnd` getters instead.
22
+ */
23
+
24
+ export default {}
@@ -12,8 +12,10 @@ import { CORRELATION_STALE_THRESHOLD_MS } from './script-tracker-constants'
12
12
  import { timingFactory } from './timing-factory'
13
13
 
14
14
  /**
15
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
15
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
16
16
  * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
17
+ * @typedef {import('./script-tracker-types').RecordManifestScriptWindowFn} RecordManifestScriptWindowFn
18
+ * @typedef {import('./script-tracker-types').TimingsInternals} TimingsInternals
17
19
  */
18
20
 
19
21
  /** export for testing purposes */
@@ -33,11 +35,43 @@ export const scriptCorrelations = new Map()
33
35
  let poSubscribers = []
34
36
 
35
37
  /**
36
- * Retrieves a script correlation by URL using exact matching
38
+ * Bookkeeping keyed by a `timings` object, kept off the object itself since it's exposed directly to customers via
39
+ * `register().metadata.timings`.
40
+ * @type {WeakMap<RegisterAPITimings, TimingsInternals>}
41
+ */
42
+ const timingsInternals = new WeakMap()
43
+
44
+ /**
45
+ * Gets (or lazily creates) the bookkeeping record for a `timings` object. A fresh record's `recordManifestScriptWindow`
46
+ * defaults to widening `timings.scriptStart`/`scriptEnd` directly -- correct for a plain `timings` object never
47
+ * produced by `findScriptTimings`. `findScriptTimings` overrides that default with one that folds into its live
48
+ * getters instead.
49
+ * @param {RegisterAPITimings} timings
50
+ * @returns {TimingsInternals}
51
+ */
52
+ function getOrCreateInternals (timings) {
53
+ let internals = timingsInternals.get(timings)
54
+ if (!internals) {
55
+ internals = {
56
+ weighedAssetUrls: new Set(),
57
+ recordManifestScriptWindow: (start, end) => {
58
+ if (start) timings.scriptStart = timings.scriptStart > 0 ? Math.min(timings.scriptStart, start) : start
59
+ if (end) timings.scriptEnd = timings.scriptEnd > 0 ? Math.max(timings.scriptEnd, end) : end
60
+ }
61
+ }
62
+ timingsInternals.set(timings, internals)
63
+ }
64
+ return internals
65
+ }
66
+
67
+ /**
68
+ * Retrieves a script correlation by URL using exact matching. Exported so other features (e.g. generic_events'
69
+ * resource attribution) can key off the same DOM node/load-timing tracking this module already does for every
70
+ * `<script>` element, rather than setting up a second, redundant observer.
37
71
  * @param {string} targetUrl - The URL to find
38
72
  * @returns {ScriptCorrelation | undefined} - The correlation object if found
39
73
  */
40
- function findCorrelation (targetUrl) {
74
+ export function findCorrelation (targetUrl) {
41
75
  return scriptCorrelations.get(targetUrl)
42
76
  }
43
77
 
@@ -88,20 +122,24 @@ if (globalScope.MutationObserver && globalScope.document) {
88
122
  }
89
123
 
90
124
  if (globalScope.PerformanceObserver?.supportedEntryTypes.includes('resource')) {
91
- /** We must track the script assets this way, because the performance buffer can fill up and when it does that
92
- * it stops accepting new entries (instead of dropping old entries), which means if the register API is called
93
- * after the buffer fills up we won't be able to get the script timing information from the resource timing API
94
- */
125
+ // Tracked via an observer (not a later buffer read) because the performance buffer stops accepting new entries
126
+ // once full, instead of dropping old ones -- a late register() call could otherwise miss timing entirely.
95
127
  const scriptObserver = new PerformanceObserver((list) => {
96
- list.getEntries().filter(validEntryCriteria).forEach((entry) => {
97
- // Update correlation with performance data (creates entry if needed)
98
- const entryUrl = cleanURL(entry.name)
99
- const correlation = getOrCreateCorrelation(entryUrl)
100
- correlation.performance.start = Math.floor(entry.startTime)
101
- correlation.performance.end = Math.floor(entry.responseEnd)
102
- correlation.performance.value = entry
103
-
104
- // Clear resolved or expired subscribers
128
+ list.getEntries().forEach((entry) => {
129
+ // Correlation bookkeeping only makes sense for script-like entries -- gated on validEntryCriteria so
130
+ // scriptCorrelations doesn't grow for every image/css/font load on the page.
131
+ if (validEntryCriteria(entry)) {
132
+ const entryUrl = cleanURL(entry.name)
133
+ const correlation = getOrCreateCorrelation(entryUrl)
134
+ correlation.performance.start = Math.floor(entry.startTime)
135
+ correlation.performance.end = Math.floor(entry.responseEnd)
136
+ correlation.performance.value = entry
137
+ }
138
+
139
+ // Late-resolution subscribers can be for any asset type (not just scripts), so every entry is checked here,
140
+ // unfiltered. Skipped when nothing is pending, the common case.
141
+ if (!poSubscribers.length) return
142
+
105
143
  const canClear = []
106
144
  poSubscribers.forEach(({ test, addedAt }, idx) => {
107
145
  if (test(entry) || now() - addedAt > 10000) canClear.push(idx)
@@ -197,6 +235,32 @@ function applyPerformanceEntry (timings, entry) {
197
235
  timings.fetchEnd = Math.floor(entry.responseEnd)
198
236
  timings.asset = entry.name
199
237
  timings.type = entry.initiatorType
238
+ applyResourceWeight(timings, entry)
239
+ }
240
+
241
+ /**
242
+ * Accumulates the byte weight and render-blocking status of a single detected asset (the entry script or a resolved
243
+ * manifest asset) into a timings object. Shared by both the entry-script path (`applyPerformanceEntry`) and the
244
+ * manifest path (`applyManifestEntry`) so `totalWeight`/`renderBlocking` reflect every asset actually detected,
245
+ * regardless of which path found it.
246
+ * @param {RegisterAPITimings} timings
247
+ * @param {PerformanceResourceTiming} entry
248
+ */
249
+ function applyResourceWeight (timings, entry) {
250
+ // De-dupe by cleaned URL: a manifest can list the .register calling script itself as one of its own assets,
251
+ // which would otherwise weigh the same resource twice (once via findScriptTimings, once via applyManifestTimings).
252
+ const url = cleanURL(entry.name)
253
+ const { weighedAssetUrls } = getOrCreateInternals(timings)
254
+ if (weighedAssetUrls.has(url)) return
255
+ weighedAssetUrls.add(url)
256
+
257
+ // transferSize is 0 for cross-origin responses without Timing-Allow-Origin (a privacy restriction, not a
258
+ // zero-byte asset) -- adding 0 is correct either way.
259
+ timings.totalWeight = (timings.totalWeight || 0) + (entry.transferSize || 0)
260
+ // 'blocking' always wins and never gets downgraded; 'non-blocking' only applies if nothing already resolved
261
+ // true; no value at all (unsupported browser) leaves renderBlocking untouched (stays `undefined`).
262
+ if (entry.renderBlockingStatus === 'blocking') timings.renderBlocking = true
263
+ else if (entry.renderBlockingStatus === 'non-blocking' && timings.renderBlocking !== true) timings.renderBlocking = false
200
264
  }
201
265
 
202
266
  /**
@@ -220,13 +284,117 @@ function subscribeToLatePerformanceEntry (timings, mfeScriptUrl) {
220
284
  }
221
285
 
222
286
  /**
223
- * Uses the stack of the initiator function, returns script timing information if a script can be found with the resource timing API matching the URL found in the stack.
224
- * @param {RegisterAPITarget} [target] - The MFE target being registered. Its id is used to scope stale-correlation detection per-MFE rather than per-script-URL, so one script registering multiple distinct MFEs doesn't misclassify a later MFE's registration as a stale reuse of an earlier one's.
287
+ * Applies one manifest asset's performance entry to a timings object: weight/renderBlocking always accumulate;
288
+ * fetchStart/fetchEnd and scriptStart/scriptEnd widen (never shrink) only when `timingMethod` calls for it; asset/
289
+ * type get anchored to the first script asset seen to resolve.
290
+ * @param {RegisterAPITimings} timings
291
+ * @param {PerformanceResourceTiming} entry
292
+ * @param {import('./manifest').ParsedManifestAsset} asset - the manifest asset this entry resolved
293
+ * @param {{ resolved: boolean }} entryState - shared "first script asset wins" guard for a single `applyManifestTimings` call
294
+ * @param {'entry'|'scripts'|'all'} [timingMethod] - the registered MFE's timing method; `undefined`/'entry' means weight/render-blocking still accumulate, but no timing widening happens at all
295
+ */
296
+ function applyManifestEntry (timings, entry, asset, entryState, timingMethod) {
297
+ // Weight isn't a timing concern, so it accumulates for every matched asset regardless of timingMethod.
298
+ applyResourceWeight(timings, entry)
299
+
300
+ if (timingMethod !== 'scripts' && timingMethod !== 'all') return // no timing-widening effect at the 'entry' default/unset
301
+
302
+ const widensAllAssets = timingMethod === 'all'
303
+ // Under 'scripts', only script assets widen the fetch window; under 'all', every matched asset does.
304
+ if (widensAllAssets || asset.isScript) {
305
+ const start = Math.floor(entry.startTime)
306
+ const end = Math.floor(entry.responseEnd)
307
+ // fetchStart/fetchEnd default to 0 ("not yet found") -- only fold into the min/max once they're positive,
308
+ // or 0 would permanently win Math.min.
309
+ timings.fetchStart = timings.fetchStart > 0 ? Math.min(timings.fetchStart, start) : start
310
+ timings.fetchEnd = timings.fetchEnd > 0 ? Math.max(timings.fetchEnd, end) : end
311
+ }
312
+
313
+ // Non-script assets never execute, so only script assets widen the execution window or anchor asset/type.
314
+ if (asset.isScript) {
315
+ const correlation = findCorrelation(cleanURL(entry.name))
316
+ if (correlation) {
317
+ // Widens the aggregate scriptStart/scriptEnd window with this asset's current correlation timing. Re-called
318
+ // as a 'load'/'error' listener below if its DOM completion hasn't fired yet, so a later, larger end still counts.
319
+ const widenScriptWindowForAsset = () => {
320
+ const { start: scriptStart, end: scriptEnd } = correlation.script
321
+ getOrCreateInternals(timings).recordManifestScriptWindow(scriptStart, scriptEnd)
322
+ }
323
+ widenScriptWindowForAsset()
324
+ if (!correlation.dom.end && correlation.dom.value) {
325
+ ;['load', 'error'].forEach(eventType => correlation.dom.value.addEventListener(eventType, widenScriptWindowForAsset, { once: true }))
326
+ }
327
+ }
328
+
329
+ if (!entryState.resolved) {
330
+ timings.asset = entry.name
331
+ timings.type = entry.initiatorType
332
+ entryState.resolved = true
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Subscribes to late resource timing emissions for manifest assets not yet resolved against the buffered entries.
339
+ * Reuses the shared page-wide scriptObserver/poSubscribers mechanism (one PerformanceObserver for all MFEs, not
340
+ * one per MFE) and, unlike that observer's own correlation bookkeeping, checks every resource entry -- not just
341
+ * script-like ones -- so lazy-loaded images/fonts/stylesheets resolve too.
342
+ * @param {RegisterAPITimings} timings
343
+ * @param {Set<import('./manifest').ParsedManifestAsset>} pending - manifest assets still unresolved
344
+ * @param {{ resolved: boolean }} entryState - shared "first script asset wins" guard for a single `applyManifestTimings` call
345
+ * @param {'entry'|'scripts'|'all'} [timingMethod] - forwarded to `applyManifestEntry` for each late-resolving asset
346
+ */
347
+ function subscribeToLateManifestEntries (timings, pending, entryState, timingMethod) {
348
+ if (!globalScope.PerformanceObserver?.supportedEntryTypes?.includes('resource')) return
349
+
350
+ poSubscribers.push({
351
+ addedAt: now(),
352
+ test: (entry) => {
353
+ const matched = [...pending].find(asset => asset.test(entry.name))
354
+ if (matched) {
355
+ applyManifestEntry(timings, entry, matched, entryState, timingMethod)
356
+ pending.delete(matched)
357
+ }
358
+ return pending.size === 0
359
+ }
360
+ })
361
+ }
362
+
363
+ /**
364
+ * Applies a registered MFE's manifest to a timings object (already populated by `findScriptTimings`). No-op if no
365
+ * manifest is present. Weight/renderBlocking always accumulate from every detected manifest asset; timing widening
366
+ * (fetchStart/fetchEnd/scriptStart/scriptEnd/asset anchor) is opt-in via `timingMethod` -- see `applyManifestEntry`.
367
+ * @param {RegisterAPITimings} timings - the timings object to widen in place
368
+ * @param {RegisterAPITarget} target - the registered MFE target, which may carry a parsed `manifest`
369
+ */
370
+ export function applyManifestTimings (timings, target) {
371
+ const parsedManifest = target?.manifest
372
+ if (!parsedManifest || !parsedManifest.assets.length) return
373
+
374
+ const entryState = { resolved: false }
375
+ const pending = new Set(parsedManifest.assets)
376
+
377
+ const resourceEntries = globalScope.performance?.getEntriesByType('resource') || []
378
+ resourceEntries.forEach((entry) => {
379
+ const matched = [...pending].find(asset => asset.test(entry.name))
380
+ if (matched) {
381
+ applyManifestEntry(timings, entry, matched, entryState, target.timingMethod)
382
+ pending.delete(matched)
383
+ }
384
+ })
385
+
386
+ if (pending.size) subscribeToLateManifestEntries(timings, pending, entryState, target.timingMethod)
387
+ }
388
+
389
+ /**
390
+ * Uses the initiator function's stack to find script timing information via the resource timing API.
391
+ * @param {RegisterAPITarget} [target] - the MFE target being registered; its id scopes stale-correlation
392
+ * detection per-MFE rather than per-script-URL (see isCorrelationStale below)
225
393
  * @returns {RegisterAPITimings} Object containing script fetch start and end times, and the asset URL if found
226
394
  */
227
395
  export function findScriptTimings (target) {
228
396
  const mfeId = target?.id
229
- const timings = { registeredAt: now(), reportedAt: undefined, fetchStart: 0, fetchEnd: 0, scriptStart: 0, scriptEnd: 0, asset: undefined, type: 'unknown' }
397
+ const timings = { registeredAt: now(), reportedAt: undefined, fetchStart: 0, fetchEnd: 0, scriptStart: 0, scriptEnd: 0, asset: undefined, type: 'unknown', totalWeight: 0, renderBlocking: undefined }
230
398
  const stack = getDeepStackTrace()
231
399
  if (!stack) return timings
232
400
 
@@ -266,9 +434,8 @@ export function findScriptTimings (target) {
266
434
  }
267
435
 
268
436
  // A correlation can be reused across multiple `register()` calls for the same script URL (e.g. an SPA
269
- // remounting the same MFE without the script actually reloading). When that happens, its dom/performance
270
- // timings still describe the *original* load, not this one. Detect that case so scriptStart/scriptEnd
271
- // below can ignore the stale data instead of reporting it as if it were fresh.
437
+ // remounting the same MFE without the script reloading) -- its dom/performance timings would then describe
438
+ // the *original* load. Detect that so scriptStart/scriptEnd below can ignore the stale data.
272
439
  const correlation = timings.correlation
273
440
  const alreadyClaimedByThisMFE = !!mfeId && !!correlation?.claimedBy.has(mfeId)
274
441
  if (correlation && mfeId) correlation.claimedBy.add(mfeId)
@@ -279,17 +446,42 @@ export function findScriptTimings (target) {
279
446
  return staleness > CORRELATION_STALE_THRESHOLD_MS
280
447
  }
281
448
 
282
- // Use getters here because the correlation data may arrive after this function returns the timing object, and we want to provide the most up-to-date timing information possible when the getters are accessed at harvest time.
283
- // Non-stale: fall back to fetchEnd if correlation data isn't available yet (our best approximation for script execution start). Stale: fall back straight to registeredAt — fetchEnd would be derived from the same stale correlation, so it can't be trusted either.
449
+ // Only reached for a real (non-inline), stack-attributable script -- scriptStart/scriptEnd become live getters
450
+ // below, so manifest widening needs a hook that composes with them instead of overriding them (see
451
+ // recordManifestScriptWindow's doc comment). Every other path keeps getOrCreateInternals' plain-value-widening
452
+ // default, which is already correct there.
453
+ let manifestScriptStart = 0
454
+ let manifestScriptEnd = 0
455
+ /**
456
+ * Widens the running manifestScriptStart/manifestScriptEnd accumulators with one asset's correlation timing.
457
+ * Never shrinks either bound; a falsy (unresolved) start/end is ignored.
458
+ * @type {RecordManifestScriptWindowFn}
459
+ */
460
+ getOrCreateInternals(timings).recordManifestScriptWindow = (start, end) => {
461
+ if (start) manifestScriptStart = manifestScriptStart > 0 ? Math.min(manifestScriptStart, start) : start
462
+ if (end) manifestScriptEnd = manifestScriptEnd > 0 ? Math.max(manifestScriptEnd, end) : end
463
+ }
464
+
465
+ // Getters, since correlation data may still arrive after this function returns -- we want the freshest value
466
+ // at harvest time. Non-stale: fall back to fetchEnd (best approximation) if correlation isn't available yet.
467
+ // Stale: fall back to registeredAt, since fetchEnd would derive from the same stale correlation. Manifest
468
+ // widening is re-read on every access rather than baked in once, so it composes with a correlation that
469
+ // resolves later.
284
470
  Object.defineProperty(
285
471
  timings,
286
472
  'scriptStart',
287
- timingFactory(() => isCorrelationStale() ? timings.registeredAt : (correlation?.script.start ?? timings.fetchEnd))
473
+ timingFactory(() => {
474
+ const ownStart = isCorrelationStale() ? timings.registeredAt : (correlation?.script.start ?? timings.fetchEnd)
475
+ return manifestScriptStart > 0 ? Math.min(ownStart, manifestScriptStart) : ownStart
476
+ })
288
477
  )
289
478
  Object.defineProperty(
290
479
  timings,
291
480
  'scriptEnd',
292
- timingFactory(() => isCorrelationStale() ? timings.registeredAt : (correlation?.script.end ?? timings.registeredAt))
481
+ timingFactory(() => {
482
+ const ownEnd = isCorrelationStale() ? timings.registeredAt : (correlation?.script.end ?? timings.registeredAt)
483
+ return manifestScriptEnd > 0 ? Math.max(ownEnd, manifestScriptEnd) : ownEnd
484
+ })
293
485
  )
294
486
  } catch (error) {
295
487
  // Don't let stack parsing errors break anything
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { extractUrlsFromStack, getDeepStackTrace } from './script-tracker'
7
+ import { matchManifestAsset } from './manifest'
8
+ import { cleanURL } from '../url/clean-url'
7
9
  import { V2_TYPES } from './constants'
8
10
 
9
11
  /**
@@ -47,6 +49,36 @@ export function getRegisteredTargetsFromId (id, agentRef) {
47
49
  return registeredEntities?.filter(entity => String(entity.metadata.target.id) === String(id)).map(entity => entity.metadata.target) || []
48
50
  }
49
51
 
52
+ /**
53
+ * Returns the registered target(s) whose resource matches a given resource URL -- used to attribute `BrowserPerformance`
54
+ * (PerformanceResourceTiming) events, which never have a JS call stack to walk (they're fired for declarative
55
+ * `<script src>`/`<link>`/`<img>` tags, not JS execution), so stack-trace attribution (see {@link findTargetsFromStackTrace})
56
+ * doesn't apply. Unlike {@link getRegisteredTargetsFromFilename}, this matches manifest assets of ANY type (scripts,
57
+ * images, fonts, css, etc.) -- non-script assets can't produce a JS stack frame to match against, but they can and do
58
+ * produce their own resource timing entries. Returns an empty array if no target is found.
59
+ * @param {string} url - the resource's URL, as reported by the Performance API
60
+ * @param {*} agentRef
61
+ * @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
62
+ */
63
+ export function getRegisteredTargetsFromResourceUrl (url, agentRef) {
64
+ if (!isValid(url, agentRef)) return []
65
+ const registeredEntities = agentRef.runtime.registeredEntities
66
+ const cleanedUrl = cleanURL(url)
67
+ const matches = registeredEntities?.filter(entity => {
68
+ const manifest = entity.metadata.target?.manifest
69
+ if (manifest) {
70
+ // A manifest was supplied -- it is the sole source of truth for attribution. The caller-script fallback
71
+ // below never applies here, so e.g. a registrar script that calls register() on behalf of many MFEs (each
72
+ // with its own manifest naming only that MFE's own files) never has ITS OWN activity attributed just because
73
+ // it happened to be the one that called register().
74
+ return matchManifestAsset(manifest, url, { scriptsOnly: false })
75
+ }
76
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
77
+ return !!entity.metadata.timings?.asset && cleanURL(entity.metadata.timings.asset) === cleanedUrl
78
+ })
79
+ return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target)
80
+ }
81
+
50
82
  /**
51
83
  * Returns the registered target(s) associated with a given filename if found in the resource timing API during registration. Returns an empty array if no target is found.
52
84
  * Multiple registrations that resolve to the same underlying script asset AND represent the same logical MFE (i.e.
@@ -63,7 +95,16 @@ export function getRegisteredTargetsFromId (id, agentRef) {
63
95
  export function getRegisteredTargetsFromFilename (filename, agentRef) {
64
96
  if (!isValid(filename, agentRef)) return []
65
97
  const registeredEntities = agentRef.runtime.registeredEntities
66
- const matches = registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename))
98
+ const matches = registeredEntities?.filter(entity => {
99
+ const manifest = entity.metadata.target?.manifest
100
+ if (manifest) {
101
+ // See the identical case in getRegisteredTargetsFromResourceUrl above: once a manifest exists, it is the
102
+ // sole source of truth for attribution -- the caller-script fallback below never applies.
103
+ return matchManifestAsset(manifest, filename, { scriptsOnly: true })
104
+ }
105
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
106
+ return !!entity.metadata.timings?.asset?.endsWith(filename)
107
+ })
67
108
  return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target)
68
109
  }
69
110
 
@@ -15,7 +15,8 @@ import { UserActionsAggregator } from './user-actions/user-actions-aggregator'
15
15
  import { isIFrameWindow } from '../../../common/dom/iframe'
16
16
  import { isPureObject } from '../../../common/util/type-check'
17
17
  import { EVENT_TYPES } from '../../../common/constants/events'
18
- import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils'
18
+ import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate, getRegisteredTargetsFromResourceUrl } from '../../../common/v2/utils'
19
+ import { findCorrelation } from '../../../common/v2/script-tracker'
19
20
 
20
21
  export class Aggregate extends AggregateBase {
21
22
  static featureName = FEATURE_NAME
@@ -229,7 +230,26 @@ export class Aggregate extends AggregateBase {
229
230
  firstParty
230
231
  }
231
232
 
232
- this.addEvent(event)
233
+ const targets = getRegisteredTargetsFromResourceUrl(name, this.agentRef)
234
+ if (targets.length) {
235
+ targets.forEach(target => this.addEvent({ ...event }, target))
236
+ return
237
+ }
238
+
239
+ this.addEvent({ ...event }, undefined)
240
+
241
+ // This resource entry can resolve before a self-registering script has actually executed and called
242
+ // register() on itself (responseEnd fires before script parse/execution). If this URL belongs to a
243
+ // <script> we're tracking that hasn't finished loading yet, retry resolution once it has -- reporting
244
+ // a second (deliberately duplicate) copy under the real target if one shows up by then. One-shot,
245
+ // self-cleans via `once: true`, so there's nothing to leak if the script never registers.
246
+ const correlation = findCorrelation(cleanURL(name))
247
+ if (correlation?.dom.value && !correlation.dom.end) {
248
+ const retryAttribution = () => {
249
+ getRegisteredTargetsFromResourceUrl(name, this.agentRef).forEach(target => this.addEvent({ ...event }, target))
250
+ }
251
+ ;['load', 'error'].forEach(evtType => correlation.dom.value.addEventListener(evtType, retryAttribution, { once: true }))
252
+ }
233
253
  } catch (err) {
234
254
  this.ee.emit('internal-error', [err, 'GenericEvents-Resource'])
235
255
  }
@@ -17,6 +17,10 @@
17
17
  * @property {RegisterAPIMetadata} metadata - The metadata object containing the custom attributes and target information for the registered entity.
18
18
  */
19
19
 
20
+ /**
21
+ * @typedef {import('../../common/v2/manifest').AssetFile} AssetFile
22
+ */
23
+
20
24
  /**
21
25
  * @typedef {Object} RegisterAPIConstructor
22
26
  * @property {string} id - The unique id for the registered entity. This will be assigned to any synthesized entities.
@@ -24,6 +28,8 @@
24
28
  * @property {{[key: string]: any}} [tags] - The tags for the registered entity as key-value pairs. This will be assigned to any synthesized entities. Tags are converted to source.* attributes (e.g., {environment: 'production'} becomes source.environment: 'production').
25
29
  * @property {RegisterAPITarget} [parent] - The parent target for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
26
30
  * @property {string} [parentId] - The parentId for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
31
+ * @property {{assets?: Array<AssetFile>}} [manifest] - An optional manifest describing the MFE's known assets (scripts, stylesheets, images, fonts, etc.) as `AssetFile` entries, used to improve the accuracy of event attribution (errors, logs, ajax, websockets), attribute `BrowserPerformance` resource events (any asset type, not just scripts) and, depending on `timingMethod`, MicroFrontEndTiming values. Each `AssetFile`'s script-capability is inferred from a `.js` suffix on a string `matcher` (a RegExp `matcher` is never inferred as a script), unless overridden via `type: 'script'` or explicitly disabled via `type: 'asset'`. An `AssetFile` with an invalid `matcher` or an unrecognized `type` is discarded/ignored (with a console warning) rather than applied. If omitted, the agent falls back to its existing behavior of only evaluating the script that called `register()` (resource attribution still applies to that script).
32
+ * @property {'entry'|'scripts'|'all'} [timingMethod] - Controls which manifest assets are used to calculate MicroFrontEndTiming values: 'entry' (the default) leaves timing based entirely on the script that called `register()`, unaffected by the manifest; 'scripts' widens the timing window across all script assets; 'all' widens it across every asset.
27
33
  */
28
34
 
29
35
  /**
@@ -39,18 +45,22 @@
39
45
  * @property {string} name - The name returned for the registered entity.
40
46
  * @property {{[key: string]: any}} [tags] - The tags for the registered entity as key-value pairs.
41
47
  * @property {string} [parentId] - The parentId for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
48
+ * @property {import('../../common/v2/manifest').ParsedManifest} [manifest] - The parsed manifest for the registered entity, if one was supplied.
49
+ * @property {'entry'|'scripts'|'all'} [timingMethod] - The timing method supplied for the registered entity, if any.
42
50
  */
43
51
 
44
52
  /**
45
53
  * @typedef {Object} RegisterAPITimings
46
54
  * @property {number} registeredAt - The timestamp when the registered entity was created.
47
55
  * @property {number} [reportedAt] - The timestamp when the registered entity was deregistered.
48
- * @property {number} fetchStart - The timestamp when the registered entity began fetching (performance.start).
49
- * @property {number} fetchEnd - The timestamp when the registered entity finished fetching (performance.end).
50
- * @property {number} scriptStart - The timestamp when script initialization began (max of dom.start or performance.end, or performance.end if no dom.start).
51
- * @property {number} scriptEnd - The timestamp when script loading completed (dom.end or registeredAt if no dom.end).
56
+ * @property {number} fetchStart - The timestamp when the registered entity began fetching (performance.start). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the earliest fetchStart across all matched manifest assets.
57
+ * @property {number} fetchEnd - The timestamp when the registered entity finished fetching (performance.end). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the latest fetchEnd across all matched manifest assets.
58
+ * @property {number} scriptStart - The timestamp when script initialization began (max of dom.start or performance.end, or performance.end if no dom.start). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the earliest scriptStart across all matched manifest script assets (non-script assets are excluded).
59
+ * @property {number} scriptEnd - The timestamp when script loading completed (dom.end or registeredAt if no dom.end). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the latest scriptEnd across all matched manifest script assets (non-script assets are excluded).
52
60
  * @property {Object} [asset] - The asset path (if found) for the registered entity.
53
61
  * @property {string} type - The type of timing associated with the registered entity, 'script' or 'link' if found with the performance resource API, 'fetch' for dynamic imports, 'inline' if found to be associated with the root document URL, or 'unknown' if no associated resource could be found.
62
+ * @property {number} totalWeight - The sum of `transferSize` (bytes) across every asset detected for the registered entity -- the entry script, plus, when a manifest is supplied, every matched manifest asset (script or non-script). Unlike fetchStart/fetchEnd/scriptStart/scriptEnd above, this is never gated behind `timingMethod` -- manifest assets contribute their bytes even at the 'entry' default/unset. Cross-origin assets without a Timing-Allow-Origin header report 0 bytes per the Resource Timing spec.
63
+ * @property {boolean} [renderBlocking] - True if any detected asset (entry script or matched manifest asset) has a `renderBlockingStatus` of 'blocking'; false if none were 'blocking' but at least one reported 'non-blocking'; left `undefined` if no detected asset reported the attribute at all (e.g. unsupported browser). A single 'blocking' asset always wins over any number of 'non-blocking' ones, regardless of resolution order.
54
64
  */
55
65
 
56
66
  /**