@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.
- package/CHANGELOG.md +13 -0
- package/dist/cjs/common/config/init.js +17 -2
- package/dist/cjs/common/config/runtime.js +2 -1
- package/dist/cjs/common/constants/env.cdn.js +1 -1
- package/dist/cjs/common/constants/env.npm.js +1 -1
- package/dist/cjs/common/constants/iframe-constants.js +16 -0
- package/dist/cjs/common/url/add-url.js +23 -0
- package/dist/cjs/common/util/console.js +40 -33
- package/dist/cjs/common/v2/mfe-vitals.js +9 -2
- package/dist/cjs/common/v2/script-correlation.js +2 -0
- package/dist/cjs/common/v2/script-tracker-constants.js +13 -0
- package/dist/cjs/common/v2/script-tracker.js +54 -29
- package/dist/cjs/common/v2/timing-factory.js +18 -0
- package/dist/cjs/common/v2/utils.js +100 -6
- package/dist/cjs/features/ajax/instrument/index.js +4 -14
- package/dist/cjs/features/utils/instrument-base.js +13 -0
- package/dist/cjs/interfaces/registered-iframe-entity.js +527 -0
- package/dist/cjs/loaders/api/register.js +10 -6
- package/dist/cjs/loaders/configure/configure.js +14 -0
- package/dist/cjs/loaders/configure/iframe-message-handler.js +270 -0
- package/dist/esm/common/config/init.js +17 -2
- package/dist/esm/common/config/runtime.js +2 -1
- package/dist/esm/common/constants/env.cdn.js +1 -1
- package/dist/esm/common/constants/env.npm.js +1 -1
- package/dist/esm/common/constants/iframe-constants.js +10 -0
- package/dist/esm/common/url/add-url.js +16 -0
- package/dist/esm/common/util/console.js +40 -33
- package/dist/esm/common/v2/mfe-vitals.js +9 -2
- package/dist/esm/common/v2/script-correlation.js +2 -0
- package/dist/esm/common/v2/script-tracker-constants.js +7 -0
- package/dist/esm/common/v2/script-tracker.js +54 -29
- package/dist/esm/common/v2/timing-factory.js +12 -0
- package/dist/esm/common/v2/utils.js +97 -6
- package/dist/esm/features/ajax/instrument/index.js +1 -11
- package/dist/esm/features/utils/instrument-base.js +13 -0
- package/dist/esm/interfaces/registered-iframe-entity.js +518 -0
- package/dist/esm/loaders/api/register.js +10 -6
- package/dist/esm/loaders/configure/configure.js +15 -1
- package/dist/esm/loaders/configure/iframe-message-handler.js +263 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/common/config/init.d.ts.map +1 -1
- package/dist/types/common/config/runtime.d.ts.map +1 -1
- package/dist/types/common/constants/iframe-constants.d.ts +11 -0
- package/dist/types/common/constants/iframe-constants.d.ts.map +1 -0
- package/dist/types/common/url/add-url.d.ts +2 -0
- package/dist/types/common/url/add-url.d.ts.map +1 -0
- package/dist/types/common/util/console.d.ts +40 -33
- package/dist/types/common/util/console.d.ts.map +1 -1
- package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -1
- package/dist/types/common/v2/script-correlation.d.ts +2 -0
- package/dist/types/common/v2/script-correlation.d.ts.map +1 -1
- package/dist/types/common/v2/script-tracker-constants.d.ts +7 -0
- package/dist/types/common/v2/script-tracker-constants.d.ts.map +1 -0
- package/dist/types/common/v2/script-tracker.d.ts +4 -1
- package/dist/types/common/v2/script-tracker.d.ts.map +1 -1
- package/dist/types/common/v2/timing-factory.d.ts +9 -0
- package/dist/types/common/v2/timing-factory.d.ts.map +1 -0
- package/dist/types/common/v2/utils.d.ts +37 -0
- package/dist/types/common/v2/utils.d.ts.map +1 -1
- package/dist/types/features/ajax/instrument/index.d.ts.map +1 -1
- package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
- package/dist/types/interfaces/registered-iframe-entity.d.ts +116 -0
- package/dist/types/interfaces/registered-iframe-entity.d.ts.map +1 -0
- package/dist/types/loaders/configure/configure.d.ts.map +1 -1
- package/dist/types/loaders/configure/iframe-message-handler.d.ts +17 -0
- package/dist/types/loaders/configure/iframe-message-handler.d.ts.map +1 -0
- package/package.json +9 -1
- package/src/common/config/init.js +13 -2
- package/src/common/config/runtime.js +2 -1
- package/src/common/constants/iframe-constants.js +11 -0
- package/src/common/url/add-url.js +18 -0
- package/src/common/util/console.js +40 -33
- package/src/common/v2/mfe-vitals.js +7 -4
- package/src/common/v2/script-correlation.js +2 -0
- package/src/common/v2/script-tracker-constants.js +7 -0
- package/src/common/v2/script-tracker.js +65 -26
- package/src/common/v2/timing-factory.js +10 -0
- package/src/common/v2/utils.js +102 -6
- package/src/features/ajax/instrument/index.js +1 -13
- package/src/features/utils/instrument-base.js +12 -0
- package/src/interfaces/registered-iframe-entity.js +482 -0
- package/src/loaders/api/register.js +9 -5
- package/src/loaders/configure/configure.js +15 -1
- package/src/loaders/configure/iframe-message-handler.js +244 -0
package/src/common/v2/utils.js
CHANGED
|
@@ -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
|
|
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
|
|
57
|
+
if (!isValid(filename, agentRef)) return []
|
|
39
58
|
const registeredEntities = agentRef.runtime.registeredEntities
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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
|
+
}
|