@newrelic/browser-agent 1.319.0 → 1.320.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/common/config/init.js +17 -2
  3. package/dist/cjs/common/config/runtime.js +2 -1
  4. package/dist/cjs/common/constants/env.cdn.js +1 -1
  5. package/dist/cjs/common/constants/env.npm.js +1 -1
  6. package/dist/cjs/common/constants/iframe-constants.js +16 -0
  7. package/dist/cjs/common/url/add-url.js +23 -0
  8. package/dist/cjs/common/util/console.js +40 -33
  9. package/dist/cjs/common/v2/mfe-vitals.js +9 -2
  10. package/dist/cjs/common/v2/script-correlation.js +2 -0
  11. package/dist/cjs/common/v2/script-tracker-constants.js +13 -0
  12. package/dist/cjs/common/v2/script-tracker.js +54 -29
  13. package/dist/cjs/common/v2/timing-factory.js +18 -0
  14. package/dist/cjs/common/v2/utils.js +100 -6
  15. package/dist/cjs/features/ajax/instrument/index.js +4 -14
  16. package/dist/cjs/features/utils/instrument-base.js +13 -0
  17. package/dist/cjs/interfaces/registered-iframe-entity.js +527 -0
  18. package/dist/cjs/loaders/api/register.js +10 -6
  19. package/dist/cjs/loaders/configure/configure.js +14 -0
  20. package/dist/cjs/loaders/configure/iframe-message-handler.js +270 -0
  21. package/dist/esm/common/config/init.js +17 -2
  22. package/dist/esm/common/config/runtime.js +2 -1
  23. package/dist/esm/common/constants/env.cdn.js +1 -1
  24. package/dist/esm/common/constants/env.npm.js +1 -1
  25. package/dist/esm/common/constants/iframe-constants.js +10 -0
  26. package/dist/esm/common/url/add-url.js +16 -0
  27. package/dist/esm/common/util/console.js +40 -33
  28. package/dist/esm/common/v2/mfe-vitals.js +9 -2
  29. package/dist/esm/common/v2/script-correlation.js +2 -0
  30. package/dist/esm/common/v2/script-tracker-constants.js +7 -0
  31. package/dist/esm/common/v2/script-tracker.js +54 -29
  32. package/dist/esm/common/v2/timing-factory.js +12 -0
  33. package/dist/esm/common/v2/utils.js +97 -6
  34. package/dist/esm/features/ajax/instrument/index.js +1 -11
  35. package/dist/esm/features/utils/instrument-base.js +13 -0
  36. package/dist/esm/interfaces/registered-iframe-entity.js +518 -0
  37. package/dist/esm/loaders/api/register.js +10 -6
  38. package/dist/esm/loaders/configure/configure.js +15 -1
  39. package/dist/esm/loaders/configure/iframe-message-handler.js +263 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/types/common/config/init.d.ts.map +1 -1
  42. package/dist/types/common/config/runtime.d.ts.map +1 -1
  43. package/dist/types/common/constants/iframe-constants.d.ts +11 -0
  44. package/dist/types/common/constants/iframe-constants.d.ts.map +1 -0
  45. package/dist/types/common/url/add-url.d.ts +2 -0
  46. package/dist/types/common/url/add-url.d.ts.map +1 -0
  47. package/dist/types/common/util/console.d.ts +40 -33
  48. package/dist/types/common/util/console.d.ts.map +1 -1
  49. package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -1
  50. package/dist/types/common/v2/script-correlation.d.ts +2 -0
  51. package/dist/types/common/v2/script-correlation.d.ts.map +1 -1
  52. package/dist/types/common/v2/script-tracker-constants.d.ts +7 -0
  53. package/dist/types/common/v2/script-tracker-constants.d.ts.map +1 -0
  54. package/dist/types/common/v2/script-tracker.d.ts +4 -1
  55. package/dist/types/common/v2/script-tracker.d.ts.map +1 -1
  56. package/dist/types/common/v2/timing-factory.d.ts +9 -0
  57. package/dist/types/common/v2/timing-factory.d.ts.map +1 -0
  58. package/dist/types/common/v2/utils.d.ts +37 -0
  59. package/dist/types/common/v2/utils.d.ts.map +1 -1
  60. package/dist/types/features/ajax/instrument/index.d.ts.map +1 -1
  61. package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
  62. package/dist/types/interfaces/registered-iframe-entity.d.ts +116 -0
  63. package/dist/types/interfaces/registered-iframe-entity.d.ts.map +1 -0
  64. package/dist/types/loaders/configure/configure.d.ts.map +1 -1
  65. package/dist/types/loaders/configure/iframe-message-handler.d.ts +17 -0
  66. package/dist/types/loaders/configure/iframe-message-handler.d.ts.map +1 -0
  67. package/package.json +9 -1
  68. package/src/common/config/init.js +13 -2
  69. package/src/common/config/runtime.js +2 -1
  70. package/src/common/constants/iframe-constants.js +11 -0
  71. package/src/common/url/add-url.js +18 -0
  72. package/src/common/util/console.js +40 -33
  73. package/src/common/v2/mfe-vitals.js +7 -4
  74. package/src/common/v2/script-correlation.js +2 -0
  75. package/src/common/v2/script-tracker-constants.js +7 -0
  76. package/src/common/v2/script-tracker.js +65 -26
  77. package/src/common/v2/timing-factory.js +10 -0
  78. package/src/common/v2/utils.js +102 -6
  79. package/src/features/ajax/instrument/index.js +1 -13
  80. package/src/features/utils/instrument-base.js +12 -0
  81. package/src/interfaces/registered-iframe-entity.js +482 -0
  82. package/src/loaders/api/register.js +9 -5
  83. package/src/loaders/configure/configure.js +15 -1
  84. package/src/loaders/configure/iframe-message-handler.js +244 -0
@@ -16,6 +16,18 @@ export const V2_TYPES = {
16
16
  BA: 'BA'
17
17
  }
18
18
 
19
+ /**
20
+ * Returns a single registered entity associated with a given iframe interface ID. Returns undefined if no entity is found.
21
+ * @param {string} iframeInterfaceId
22
+ * @param {*} agentRef the agent reference
23
+ * @returns {import("../../loaders/api/register-api-types").RegisterAPI|undefined}
24
+ */
25
+ export function getRegisteredEntityByIframeInterfaceId (iframeInterfaceId, agentRef) {
26
+ if (!isValid(iframeInterfaceId, agentRef)) return undefined
27
+ const registeredEntities = agentRef.runtime.registeredEntities
28
+ return registeredEntities?.find(entity => entity.metadata.target.iframeInterfaceId === iframeInterfaceId)
29
+ }
30
+
19
31
  /**
20
32
  * Returns the registered target associated with a given ID. Returns an empty array if no target is found.
21
33
  * @param {string|number} id
@@ -23,21 +35,73 @@ export const V2_TYPES = {
23
35
  * @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
24
36
  */
25
37
  export function getRegisteredTargetsFromId (id, agentRef) {
26
- if (!id || !agentRef?.init.api.register.enabled) return []
38
+ if (!isValid(id, agentRef)) return []
27
39
  const registeredEntities = agentRef.runtime.registeredEntities
28
40
  return registeredEntities?.filter(entity => String(entity.metadata.target.id) === String(id)).map(entity => entity.metadata.target) || []
29
41
  }
30
42
 
31
43
  /**
32
44
  * 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.
45
+ * Multiple registrations that resolve to the same underlying script asset AND represent the same logical MFE (i.e.
46
+ * share the same customer-supplied `target.id`) are collapsed to a single target via
47
+ * {@link dedupeRegisteredEntitiesByAsset}, since auto-instrumented events (AJAX, JS errors, logs, WebSockets) should
48
+ * only be reported once per real occurrence when the same MFE was registered many times over -- not once per
49
+ * duplicate registration. Distinct MFEs (different `target.id`) that happen to share a script (e.g. two different
50
+ * MFEs both registered from the same inline `<script>` block) are intentionally NOT collapsed, since each is a
51
+ * genuinely different entity that should still receive its own copy of the matched event.
33
52
  * @param {string} filename
34
53
  * @param {*} agentRef
35
54
  * @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
36
55
  */
37
56
  export function getRegisteredTargetsFromFilename (filename, agentRef) {
38
- if (!filename || !agentRef?.init.api.register.enabled) return []
57
+ if (!isValid(filename, agentRef)) return []
39
58
  const registeredEntities = agentRef.runtime.registeredEntities
40
- return registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename)).map(entity => entity.metadata.target) || []
59
+ const matches = registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename))
60
+ return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target)
61
+ }
62
+
63
+ /**
64
+ * Collapses a list of registered entities down to one canonical entity per unique, defined `metadata.timings.asset` +
65
+ * `target.id` combination. Entities whose asset could not be resolved (undefined -- e.g. inline scripts or scripts
66
+ * not found in the resource timing buffer) are never deduped against each other or against resolved entities, since
67
+ * there is no signal that they represent the same underlying script. Entities that share an asset but have different
68
+ * `target.id`s are never deduped against each other either, since a differing id means the customer registered
69
+ * genuinely distinct MFEs (not duplicate registrations of the same one).
70
+ *
71
+ * Canonical selection per asset+id: prefer an entity whose target has not been deregistered (`target.blocked ===
72
+ * false`) over one that has; otherwise the first-encountered entity wins, for determinism.
73
+ * @param {Array} entities registered entities (each with `metadata.timings.asset` and `metadata.target`)
74
+ * @returns {Array} deduped list of entities, preserving relative order of first occurrence
75
+ */
76
+ export function dedupeRegisteredEntitiesByAsset (entities) {
77
+ if (!entities?.length) return entities || []
78
+
79
+ const byKey = new Map() // `${asset}::${id}` -> canonical entity
80
+ const result = []
81
+
82
+ for (const entity of entities) {
83
+ const asset = entity.metadata?.timings?.asset
84
+ if (!asset) {
85
+ // can't safely dedupe unresolved-asset entities -- always keep as-is
86
+ result.push(entity)
87
+ continue
88
+ }
89
+
90
+ const key = `${asset}::${entity.metadata?.target?.id}`
91
+ const existing = byKey.get(key)
92
+ if (!existing) {
93
+ byKey.set(key, entity)
94
+ result.push(entity)
95
+ } else if (existing.metadata.target?.blocked && !entity.metadata.target?.blocked) {
96
+ // swap in a non-deregistered target as the canonical one for this asset+id
97
+ const idx = result.indexOf(existing)
98
+ if (idx !== -1) result[idx] = entity
99
+ byKey.set(key, entity)
100
+ }
101
+ // else: existing canonical entity wins (already non-blocked, or both blocked -- first wins); drop this duplicate
102
+ }
103
+
104
+ return result
41
105
  }
42
106
 
43
107
  /**
@@ -92,7 +156,7 @@ export function shouldDuplicate (target, aggregateInstance) {
92
156
  * @returns {Array} An array of targets found from the stack trace. If no targets are found or allowed, returns an array with undefined.
93
157
  */
94
158
  export function findTargetsFromStackTrace (agentRef) {
95
- if (!agentRef?.init.api.register.enabled || !agentRef?.runtime?.registeredEntities?.length) return [undefined]
159
+ if (!isValid(true, agentRef) || !agentRef?.runtime?.registeredEntities?.length) return [undefined]
96
160
 
97
161
  const targets = []
98
162
  try {
@@ -104,8 +168,30 @@ export function findTargetsFromStackTrace (agentRef) {
104
168
  } catch (err) {
105
169
  // Silent catch to prevent errors from propagating
106
170
  }
107
- if (!targets.length) targets.push(undefined) // if we can't find any targets from the stack trace, return an array with undefined to signify the container agent is the target
108
- return targets
171
+
172
+ const deduped = dedupeTargetsByInstance(targets)
173
+ if (!deduped.length) deduped.push(undefined) // if we can't find any targets from the stack trace, return an array with undefined to signify the container agent is the target
174
+ return deduped
175
+ }
176
+
177
+ /**
178
+ * Removes duplicate targets from an array, keyed by `target.instance`. This guards against the same canonical
179
+ * target re-entering the array via multiple matched stack-frame URLs (e.g. a recursive call whose stack contains the
180
+ * same file at multiple depths). Entries with no `instance` (i.e. `undefined`, meaning "the container agent")
181
+ * naturally collapse to a single entry too, which is the desired behavior.
182
+ * @param {Array} targets
183
+ * @returns {Array} deduped list of targets, preserving relative order of first occurrence
184
+ */
185
+ export function dedupeTargetsByInstance (targets) {
186
+ const seen = new Set()
187
+ const result = []
188
+ for (const target of targets) {
189
+ const key = target?.instance
190
+ if (seen.has(key)) continue
191
+ seen.add(key)
192
+ result.push(target)
193
+ }
194
+ return result
109
195
  }
110
196
 
111
197
  /**
@@ -118,3 +204,13 @@ export function findTargetsFromStackTrace (agentRef) {
118
204
  function supportsV2 (aggregateInstance) {
119
205
  return aggregateInstance?.harvestEndpointVersion === 2
120
206
  }
207
+
208
+ /**
209
+ * Determines if the given identifier and agent reference are valid for use for entity lookups and other operations in the utils methods. This is a common check that is used across multiple methods in this module to ensure that the necessary data is present and that the register API is enabled before attempting to perform operations that depend on those things.
210
+ * @param {*} identifier The identifier to check.
211
+ * @param {*} agentRef The agent reference to check.
212
+ * @returns {boolean} Returns true if the identifier and agent reference are valid, false otherwise.
213
+ */
214
+ function isValid (identifier, agentRef) {
215
+ return !!identifier && !!agentRef?.init.api.register.enabled
216
+ }
@@ -20,6 +20,7 @@ import { SUPPORTABILITY_METRIC } from '../../metrics/constants'
20
20
  import { now } from '../../../common/timing/now'
21
21
  import { hasUndefinedHostname } from '../../../common/deny-list/deny-list'
22
22
  import { extractUrl } from '../../../common/url/extract-url'
23
+ import { addUrl } from '../../../common/url/add-url'
23
24
 
24
25
  var handlers = ['load', 'error', 'abort', 'timeout']
25
26
  var handlersLen = handlers.length
@@ -481,19 +482,6 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
481
482
  }
482
483
  }
483
484
 
484
- function addUrl (ctx, url) {
485
- var parsed = parseUrl(url)
486
- var params = ctx.params || ctx
487
-
488
- params.hostname = parsed.hostname
489
- params.port = parsed.port
490
- params.protocol = parsed.protocol
491
- params.host = parsed.hostname + ':' + parsed.port
492
- params.pathname = parsed.pathname
493
- ctx.parsedOrigin = parsed
494
- ctx.sameOrigin = parsed.sameOrigin
495
- }
496
-
497
485
  function parseResponseHeaders (headerStr) {
498
486
  const headers = {}
499
487
  if (!headerStr) return headers
@@ -115,6 +115,18 @@ export class InstrumentBase extends FeatureBase {
115
115
  handle(SESSION_ERROR, [e], undefined, this.featureName, this.ee)
116
116
  }
117
117
 
118
+ if (agentRef.init.api.register.allow_iframe_bridge) {
119
+ try {
120
+ // This chunk doesn't exist in the lite build (see webpack IgnorePlugin config) since none
121
+ // of lite's features wire up agent.register -- guard against that rather than letting an
122
+ // unhandled rejection surface if this flag is ever set on a lite page.
123
+ const { setupIframeMFEMessageListener } = await import(/* webpackChunkName: "iframe-message-handler" */ '../../loaders/configure/iframe-message-handler')
124
+ setupIframeMFEMessageListener(agentRef)
125
+ } catch (e) {
126
+ warn(23, e)
127
+ }
128
+ }
129
+
118
130
  /**
119
131
  * Note this try-catch differs from the one in Agent.run() in that it's placed later in a page's lifecycle and
120
132
  * it's only responsible for aborting its one specific feature, rather than all.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ // libraries
7
+ import { onCLS, onFCP, onINP, onLCP } from 'web-vitals'
8
+ // internal
9
+ import { globalScope, isBrowserScope } from '../common/constants/runtime'
10
+ import { isIFrameWindow } from '../common/dom/iframe'
11
+ import { now } from '../common/timing/now'
12
+ import { warn } from '../common/util/console'
13
+ import { findScriptTimings } from '../common/v2/script-tracker'
14
+ import { addUrl } from '../common/url/add-url'
15
+ import { generateUuid } from '../common/ids/unique-id'
16
+ import { IFRAME_TIMING_UPDATE, IFRAME_API, IFRAME_API_RESPONSE, IFRAME_VITALS_UPDATE, IFRAME_AJAX } from '../common/constants/iframe-constants'
17
+ import { castErrorEvent, castError, castPromiseRejectionEvent } from '../features/jserrors/shared/cast-error'
18
+
19
+ const REGISTER = 'register' // define it here to prevent importing the full list of constants for build size.
20
+ const VITALS = [[onCLS, 'cls'], [onLCP, 'lcp'], [onFCP, 'fcp'], [onINP, 'inp']]
21
+ const AJAX_INITIATOR_TYPES = { xmlhttprequest: 'xhr', fetch: 'fetch', beacon: 'beacon' }
22
+
23
+ /**
24
+ * @typedef {import('../loaders/api/register-api-types').RegisterAPI} RegisterAPI
25
+ * @typedef {import('../loaders/api/register-api-types').RegisterAPIMetadata} RegisterAPIMetadata
26
+ * @typedef {import('../loaders/api/register-api-types').RegisterAPIConstructor} RegisterAPIConstructor
27
+ */
28
+
29
+ /**
30
+ * @experimental
31
+ * IMPORTANT: This feature is being developed for use internally and is not in a public-facing production-ready state.
32
+ * It is not recommended for use in production environments and will not receive support for issues.
33
+ *
34
+ * An interface for registering an external caller to report through the base agent to a different target than the base agent.
35
+ */
36
+ export class RegisteredIframeEntity {
37
+ /** @type {RegisterAPIMetadata} */
38
+ metadata = {
39
+ target: {},
40
+ timings: {},
41
+ customAttributes: {},
42
+ vitals: {
43
+ cls: { value: null },
44
+ lcp: { value: null },
45
+ fcp: { value: null },
46
+ inp: { value: null }
47
+ }
48
+ }
49
+
50
+ /** @private Map to store pending promise resolvers keyed by message ID */
51
+ #pendingMessages = new Map()
52
+ /** @private Unique ID for this iframe interface instance to correlate messages */
53
+ #iframeInterfaceId = generateUuid()
54
+ /** @private Counter for generating unique message IDs */
55
+ #messageIdCounter = 0
56
+ /** @private Promise that resolves when registration with parent completes */
57
+ #registrationPromise = null
58
+ /** @private Resource timing observer used to seed AJAX state */
59
+ #resourceObserver = null
60
+ /** @private Original target descriptor (serializable) for postMessage */
61
+ #targetDescriptor = null
62
+ /**
63
+ * Whether this entity is blocked from sending further calls to the container. Backed by
64
+ * `this.metadata.target.blocked` (rather than an independent field) so that this always
65
+ * reflects the container's blocked state too, including future syncs from response metadata --
66
+ * there is only ever one source of truth for "blocked", not one flag per side.
67
+ * @returns {boolean}
68
+ */
69
+ get blocked () {
70
+ return !!this.metadata.target.blocked
71
+ }
72
+
73
+ set blocked (value) {
74
+ this.metadata.target.blocked = value
75
+ }
76
+
77
+ /** @private Parent window origin for secure postMessage */
78
+ #parentOrigin = (() => {
79
+ try {
80
+ return globalScope?.location?.ancestorOrigins?.[0] || (globalScope?.document?.referrer ? new URL(globalScope.document.referrer).origin : '*')
81
+ } catch (e) {
82
+ return '*'
83
+ }
84
+ })()
85
+
86
+ /**
87
+ *
88
+ * @param {RegisterAPIConstructor} opts The options for setting up the registered iframe entity.
89
+ */
90
+ constructor (opts) {
91
+ warn(54)
92
+ // Store original descriptor for postMessage (before any function merging)
93
+ this.metadata.target = this.#targetDescriptor = opts
94
+
95
+ if (!isBrowserScope || !isIFrameWindow(globalScope)) {
96
+ warn(72)
97
+ this.blocked = true
98
+ return
99
+ }
100
+
101
+ if (this.#parentOrigin === '*') {
102
+ // If the parent's origin cannot be determined, fail closed rather than allow postMessage
103
+ // traffic to/from any origin
104
+ warn(78)
105
+ this.blocked = true
106
+ return
107
+ }
108
+
109
+ // Store the registration promise so other methods can wait for it
110
+ this.#registrationPromise = this.#register(opts)
111
+ this.#registrationPromise
112
+ .then(() => {
113
+ const timings = findScriptTimings()
114
+ // Send initial timing values, skipping ones still at their "not yet known" default
115
+ // (e.g. reportedAt: undefined, fetchStart: 0) to avoid pointless postMessage calls
116
+ for (const [key, value] of Object.entries(timings)) {
117
+ if (key !== 'correlation' && value) {
118
+ this.#postTimingToAgent(key, value)
119
+ }
120
+ }
121
+ // Proxy the timings object to watch for updates to fetchStart, fetchEnd, asset, type
122
+ this.metadata.timings = new Proxy(timings, {
123
+ set: (target, key, value) => {
124
+ const changed = target[key] !== value
125
+ target[key] = value
126
+
127
+ // Send updates for these 4 properties when they change
128
+ if (changed && this.metadata.target.id && key !== 'correlation') {
129
+ this.#postTimingToAgent(key, value)
130
+ }
131
+ return true
132
+ }
133
+ })
134
+ })
135
+ .catch(() => {})
136
+
137
+ this.#setupErrorListeners()
138
+ this.#setupVitalsListeners()
139
+ this.#setupAjaxObserver()
140
+ this.#setupResponseListener()
141
+ this.#bindPublicMethods()
142
+ }
143
+
144
+ /**
145
+ * Registers this entity with the parent agent, then seeds initial timings and FCP.
146
+ * @private
147
+ * @param {RegisterAPIConstructor} opts
148
+ * @returns {Promise<void>}
149
+ */
150
+ async #register (opts) {
151
+ try {
152
+ const response = await this.#postMethodToAgent(REGISTER, [opts])
153
+ if (response.metadata) Object.assign(this.metadata, response.metadata)
154
+ return response
155
+ } catch (err) {
156
+ warn(73, err)
157
+ this.blocked = true
158
+ throw err
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Wires up global error/rejection listeners that funnel into noticeError.
164
+ * @private
165
+ */
166
+ #setupErrorListeners () {
167
+ globalScope.addEventListener('error', err => {
168
+ this.noticeError(castErrorEvent(err))
169
+ })
170
+
171
+ globalScope.addEventListener('unhandledrejection', event => {
172
+ this.noticeError(castPromiseRejectionEvent(event))
173
+ })
174
+ }
175
+
176
+ /**
177
+ * Wires up web-vitals callbacks to report vitals updates to the parent.
178
+ * @private
179
+ */
180
+ #setupVitalsListeners () {
181
+ VITALS.forEach(([vitalFn, property]) => {
182
+ vitalFn(({ value }) => {
183
+ this.metadata.vitals[property].value = value
184
+ this.#postMessageToParent(IFRAME_VITALS_UPDATE, {
185
+ entries: [{ property, value }]
186
+ })
187
+ }, { reportAllChanges: property === 'cls' || property === 'inp' })
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Instruments ajax using buffered resource timing so pre-registration entries are included.
193
+ * @private
194
+ */
195
+ #setupAjaxObserver () {
196
+ if (!globalScope.PerformanceObserver?.supportedEntryTypes?.includes('resource')) return
197
+
198
+ this.#resourceObserver = new globalScope.PerformanceObserver(list => {
199
+ // Batched into a single postMessage per observer callback rather than one per entry --
200
+ // with buffered: true this callback can fire with dozens of pre-existing entries at once,
201
+ // and each postMessage carries its own await/registration/dispatch overhead independent of
202
+ // payload size, so sending them individually multiplies that overhead for no benefit.
203
+ const entries = list.getEntries().map(resource => this.#buildAjaxPayload(resource)).filter(Boolean)
204
+ if (!entries.length) return
205
+ this.#postMessageToParent(IFRAME_AJAX, { entries })
206
+ })
207
+ this.#resourceObserver.observe({ type: 'resource', buffered: true })
208
+ }
209
+
210
+ /**
211
+ * Builds the AJAX event payload for a single resource timing entry, if it looks like a network request.
212
+ * @private
213
+ * @param {PerformanceResourceTiming} resource
214
+ * @returns {object|undefined}
215
+ */
216
+ #buildAjaxPayload (resource) {
217
+ if (!(resource.initiatorType in AJAX_INITIATOR_TYPES)) return
218
+ // Cross-origin requests without a Timing-Allow-Origin response header report responseStatus (and
219
+ // transferSize) as 0 per spec -- that's a browser privacy restriction, not evidence the request
220
+ // failed, so it should still be reported rather than dropped. status: 0 is already the established
221
+ // "unknown" convention elsewhere in the ajax feature (see features/ajax/instrument/index.js).
222
+ const params = { status: resource.responseStatus }
223
+ const metrics = { rxSize: resource.transferSize, duration: Math.floor(resource.duration), cbTime: 0 }
224
+ addUrl(params, resource.name)
225
+
226
+ return {
227
+ params,
228
+ metrics,
229
+ start: resource.startTime,
230
+ end: resource.responseEnd,
231
+ initiatorType: AJAX_INITIATOR_TYPES[resource.initiatorType]
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Listens for postMessage responses from the parent window and routes them to pending resolvers.
237
+ * @private
238
+ */
239
+ #setupResponseListener () {
240
+ globalScope.addEventListener('message', (event) => {
241
+ if (this.blocked) return
242
+ // Validate message structure
243
+ if (event.data?.type !== IFRAME_API_RESPONSE) return
244
+
245
+ // Validate iframeInterfaceId first to confirm this message actually claims to be addressed
246
+ // to this instance -- messageIds are only unique per-instance, so we must not act on one
247
+ // (including rejecting) until we know it's actually meant for us.
248
+ if (event.data.iframeInterfaceId !== this.#iframeInterfaceId) {
249
+ warn(75)
250
+ return
251
+ }
252
+
253
+ // Validate origin now that we know the message claims to be for us. Reject the pending
254
+ // call immediately rather than leaving it to time out, since we know exactly which
255
+ // messageId this response was for.
256
+ if (event.origin !== this.#parentOrigin) {
257
+ warn(74, event.origin)
258
+ this.#closePending({ messageId: event.data.messageId, error: 'Rejected message from unauthorized origin' })
259
+ return
260
+ }
261
+
262
+ this.#closePending(event.data)
263
+ })
264
+ }
265
+
266
+ /**
267
+ * Explicitly binds API methods as own properties for better console visibility.
268
+ * @private
269
+ */
270
+ #bindPublicMethods () {
271
+ this.addPageAction = this.addPageAction.bind(this)
272
+ this.deregister = this.deregister.bind(this)
273
+ this.recordCustomEvent = this.recordCustomEvent.bind(this)
274
+ this.measure = this.measure.bind(this)
275
+ this.setCustomAttribute = this.setCustomAttribute.bind(this)
276
+ this.noticeError = this.noticeError.bind(this)
277
+ this.setUserId = this.setUserId.bind(this)
278
+ this.setApplicationVersion = this.setApplicationVersion.bind(this)
279
+ this.log = this.log.bind(this)
280
+ }
281
+
282
+ /**
283
+ * Low-level helper to send postMessage to parent window with error handling
284
+ * @private
285
+ * @param {string} type - The message type to send
286
+ * @param {object} data - The message payload to send
287
+ * @param {boolean} [bypassRegistration=false] - Whether to bypass waiting for registration
288
+ * @param {boolean} [needsResponse=false] - Whether to wait for a response from the parent
289
+ * @returns {Promise<void>}
290
+ */
291
+ async #postMessageToParent (type, data, bypassRegistration = false, needsResponse = false) {
292
+ if (this.blocked) return
293
+
294
+ const timestamp = now()
295
+ try {
296
+ await (bypassRegistration ? Promise.resolve() : this.#registrationPromise)
297
+ const messageId = ++this.#messageIdCounter
298
+ const pending = needsResponse ? this.#openPending(messageId) : Promise.resolve()
299
+ globalScope.parent.postMessage({
300
+ type,
301
+ target: this.#targetDescriptor,
302
+ timestamp,
303
+ iframeInterfaceId: this.#iframeInterfaceId,
304
+ messageId,
305
+ ...data
306
+ }, this.#parentOrigin)
307
+ return await pending
308
+ } catch (err) {
309
+ // If the postMessage never responded with a new message, it will surface here -- but, this could be expected, as many messages are sent to the parent that don't require a response.
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Sends a timing property update message to the parent window
315
+ * @private
316
+ * @param {string} property - The property name that changed
317
+ * @param {*} value - The new value
318
+ */
319
+ #postTimingToAgent (property, value) {
320
+ this.#postMessageToParent(IFRAME_TIMING_UPDATE, {
321
+ entries: [{ property, value }]
322
+ })
323
+ }
324
+
325
+ /**
326
+ * Sends a message to the parent window's agent using postMessage API
327
+ * @private
328
+ * @param {string} method The API method name to invoke
329
+ * @param {Array} args The arguments to pass to the method
330
+ * @returns {Promise<any>} Promise that resolves with the response from the agent
331
+ */
332
+ async #postMethodToAgent (method, args) {
333
+ return await this.#postMessageToParent(IFRAME_API, {
334
+ entries: [{ method, args }]
335
+ }, method === REGISTER, true)
336
+ }
337
+
338
+ /**
339
+ * Opens a pending entry for a message awaiting a response, auto-rejecting after a timeout.
340
+ * @private
341
+ * @param {number} messageId
342
+ * @returns {Promise<any>}
343
+ */
344
+ #openPending (messageId) {
345
+ const resolvers = {}
346
+ const pending = new Promise((resolve, reject) => {
347
+ resolvers.resolve = resolve
348
+ resolvers.reject = reject
349
+ })
350
+ this.#pendingMessages.set(messageId, resolvers)
351
+ // Timeout after 10 seconds
352
+ setTimeout(() => this.#closePending({ messageId, error: 'Timed out' }), 10000)
353
+ return pending
354
+ }
355
+
356
+ /**
357
+ * Resolves or rejects a pending message by ID, based on an incoming response (or timeout).
358
+ * @private
359
+ * @param {{messageId: number, error?: string, result?: any, metadata?: object}} event
360
+ */
361
+ #closePending (event = {}) {
362
+ const { messageId, error, result, metadata } = event
363
+ const pending = this.#pendingMessages.get(messageId)
364
+ if (pending) {
365
+ if (error) pending.reject(new Error(error))
366
+ else pending.resolve({ result, metadata })
367
+ this.#pendingMessages.delete(messageId)
368
+ }
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Public API
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /**
376
+ * Reports a browser PageAction event along with a name and optional attributes to the registered target.
377
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/addpageaction/}
378
+ * @param {string} name Name or category of the action. Reported as the actionName attribute.
379
+ * @param {object} [attributes] JSON object with one or more key/value pairs. For example: {key:"value"}. The key is reported as its own PageAction attribute with the specified values.
380
+ */
381
+ addPageAction (name, attributes) {
382
+ this.#postMethodToAgent('addPageAction', [name, attributes])
383
+ }
384
+
385
+ /**
386
+ * @experimental
387
+ * IMPORTANT: This feature is being developed for use internally and is not in a public-facing production-ready state.
388
+ * It is not recommended for use in production environments and will not receive support for issues.
389
+ *
390
+ * Deregister the registered entity (this), which blocks its use and captures end of life timings.
391
+ * @returns {Promise<void>}
392
+ */
393
+ async deregister () {
394
+ try {
395
+ this.#resourceObserver?.disconnect()
396
+ } catch (err) { }
397
+
398
+ this.#resourceObserver = null
399
+ try {
400
+ const response = await this.#postMethodToAgent('deregister', [])
401
+ if (response?.metadata) Object.assign(this.metadata, response.metadata)
402
+ } finally {
403
+ // Always end up blocked locally, even if the round trip to the container never resolved --
404
+ // deregistering is a one-way decision and further local calls should stop regardless.
405
+ this.blocked = true
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Records a custom event with a specified eventType and attributes.
411
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/recordCustomEvent/}
412
+ * @param {string} eventType The eventType to store the event as.
413
+ * @param {Object} [attributes] JSON object with one or more key/value pairs. For example: {key:"value"}.
414
+ */
415
+ recordCustomEvent (eventType, attributes) {
416
+ this.#postMethodToAgent('recordCustomEvent', [eventType, attributes])
417
+ }
418
+
419
+ /**
420
+ * Measures a task that is recorded as a BrowserPerformance event.
421
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/measure/}
422
+ * @param {string} name The name of the task
423
+ * @param {{start?: number|PerformanceMark, end?: number|PerformanceMark, customAttributes?: object}} [options] An object used to control the way the measure API operates
424
+ * @returns {Promise<{start: number, end: number, duration: number, customAttributes: object}>} Measurement details
425
+ */
426
+ async measure (name, options) {
427
+ return (await this.#postMethodToAgent('measure', [name, options])).result
428
+ }
429
+
430
+ /**
431
+ * Adds a user-defined attribute name and value to subsequent events on the page for the registered target. Note -- the persist flag does not work with the register API.
432
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/setcustomattribute/}
433
+ * @param {string} name Name of the attribute. Appears as column in the PageView event. It will also appear as a column in the PageAction event if you are using it.
434
+ * @param {string|number|boolean|null} value Value of the attribute. Appears as the value in the named attribute column in the PageView event. It will appear as a column in the PageAction event if you are using it. Custom attribute values cannot be complex objects, only simple types such as Strings, Integers and Booleans. Passing a null value unsets any existing attribute of the same name.
435
+ * @param {boolean} [persist] Default false. If set to true, the name-value pair will also be set into the browser's storage API. Then on the following instrumented pages that load within the same session, the pair will be re-applied as a custom attribute.
436
+ */
437
+ setCustomAttribute (name, value, persist) {
438
+ this.#postMethodToAgent('setCustomAttribute', [name, value, persist])
439
+ }
440
+
441
+ /**
442
+ * Identifies a browser error without disrupting your app's operations for the registered target.
443
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/noticeerror/}
444
+ * @param {Error|string} error Provide a meaningful error message that you can use when analyzing data on browser's JavaScript errors page.
445
+ * @param {object} [customAttributes] An object containing name/value pairs representing custom attributes.
446
+ */
447
+ noticeError (error, customAttributes) {
448
+ this.#postMethodToAgent('noticeError', [castError(error), customAttributes])
449
+ }
450
+
451
+ /**
452
+ * Adds a user-defined identifier string to subsequent events on the page for the registered target.
453
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/setuserid/}
454
+ * @param {string|null} value A string identifier for the end-user, useful for tying all browser events to specific users. The value parameter does not have to be unique. If IDs should be unique, the caller is responsible for that validation. Passing a null value unsets any existing user ID.
455
+ * @param {boolean} [resetSession=false] Optional param. Should not be used from a registered entity context. To reset a session when updating user id, must be initiated by the main agent.
456
+ */
457
+ setUserId (value, resetSession = false) {
458
+ this.#postMethodToAgent('setUserId', [value, resetSession])
459
+ }
460
+
461
+ /**
462
+ * Adds a user-defined application version string to subsequent events on the page for the registered target.
463
+ * This decorates all payloads with an attribute of `application.version` which is queryable in NR1.
464
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/setapplicationversion/}
465
+ * @param {string|null} value A string identifier for the application version, useful for
466
+ * tying all browser events to a specific release tag. The value parameter does not
467
+ * have to be unique. Passing a null value unsets any existing value.
468
+ */
469
+ setApplicationVersion (value) {
470
+ this.#postMethodToAgent('setApplicationVersion', [value])
471
+ }
472
+
473
+ /**
474
+ * Capture a single log for the registered target.
475
+ * {@link https://docs.newrelic.com/docs/browser/new-relic-browser/browser-apis/log/}
476
+ * @param {string} message String to be captured as log message
477
+ * @param {{customAttributes?: object, level?: 'ERROR'|'TRACE'|'DEBUG'|'INFO'|'WARN'}} [options] customAttributes defaults to `{}` if not assigned, level defaults to `info` if not assigned.
478
+ */
479
+ log (message, options) {
480
+ this.#postMethodToAgent('log', [message, options])
481
+ }
482
+ }