diff --git a/src/common/config/init.js b/src/common/config/init.js index b185c8efc..4c444e527 100644 --- a/src/common/config/init.js +++ b/src/common/config/init.js @@ -84,7 +84,8 @@ const model = () => { }, session: { expiresMs: DEFAULT_EXPIRES_MS, - inactiveMs: DEFAULT_INACTIVE_MS + inactiveMs: DEFAULT_INACTIVE_MS, + user_journey: true }, session_replay: { // feature settings diff --git a/src/common/session/session-entity.js b/src/common/session/session-entity.js index eec036d85..b8015ca4c 100644 --- a/src/common/session/session-entity.js +++ b/src/common/session/session-entity.js @@ -19,7 +19,7 @@ import { windowAddEventListener } from '../event-listener/event-listener-opts' // this is what can be stored in local storage (not enforced but probably should be) // these values should sync between local storage and the parent class props -const model = { +export const model = { value: '', inactiveAt: 0, expiresAt: 0, @@ -29,6 +29,8 @@ const model = { sessionTraceMode: MODE.OFF, traceHarvestStarted: false, serverTimeDiff: null, // set by TimeKeeper; "undefined" value will not be stringified and stored but "null" will + userJourneyPaths: '', + userJourneyTimestamps: '', custom: {} } diff --git a/src/features/generic_events/aggregate/index.js b/src/features/generic_events/aggregate/index.js index cffe84a9a..79e14a968 100644 --- a/src/features/generic_events/aggregate/index.js +++ b/src/features/generic_events/aggregate/index.js @@ -25,6 +25,18 @@ export class Aggregate extends AggregateBase { this.eventsPerHarvest = 1000 this.referrerUrl = (isBrowserScope && document.referrer) ? cleanURL(document.referrer) : undefined + this.beforeUnloadFns = [] + this.harvestOpts.beforeUnload = () => { + this.beforeUnloadFns.forEach(fn => fn()) + } + + const { userJourneyPaths, userJourneyTimestamps } = agentRef.runtime.session.read() + this.userJourney = { + host: globalScope.location?.host, + paths: userJourneyPaths, + timestamps: userJourneyTimestamps + } + this.waitForFlags(['ins']).then(([ins]) => { if (!ins) { this.blocked = true @@ -34,6 +46,27 @@ export class Aggregate extends AggregateBase { this.trackSupportabilityMetrics() + registerHandler('user-journey', (timestamp, url) => { + const { hash, pathname, search } = new URL(url) + if (this.userJourney.paths.length + pathname.length + hash.length + search.length > 4096) return + if (this.userJourney.timestamps.length + ('' + timestamp).length > 4096) return + + if (this.userJourney.paths) this.userJourney.paths += '>' + this.userJourney.paths += pathname + search + hash + if (this.userJourney.timestamps) this.userJourney.timestamps += '>' + this.userJourney.timestamps += this.agentRef.runtime.timeKeeper.correctRelativeTimestamp(timestamp) + this.syncWithSessionManager({ userJourneyPaths: this.userJourney.paths, userJourneyTimestamps: this.userJourney.timestamps }) + }, this.featureName, this.ee) + this.beforeUnloadFns.push(() => { + this.addEvent({ + eventType: 'SessionMetadata', + + host: this.userJourney.host, + paths: this.userJourney.paths, + timestamps: this.userJourney.timestamps + }) + }) + registerHandler('api-recordCustomEvent', (timestamp, eventType, attributes) => { if (RESERVED_EVENT_TYPES.includes(eventType)) return warn(46) this.addEvent({ @@ -63,7 +96,7 @@ export class Aggregate extends AggregateBase { let addUserAction if (isBrowserScope && agentRef.init.user_actions.enabled) { this.userActionAggregator = new UserActionsAggregator() - this.harvestOpts.beforeUnload = () => addUserAction?.(this.userActionAggregator.aggregationEvent) + this.beforeUnloadFns.push(() => addUserAction?.(this.userActionAggregator.aggregationEvent)) addUserAction = (aggregatedUserAction) => { try { @@ -237,7 +270,7 @@ export class Aggregate extends AggregateBase { const defaultEventAttributes = { /** should be overridden by the event-specific attributes, but just in case -- set it to now() */ - timestamp: Math.floor(this.agentRef.runtime.timeKeeper.correctRelativeTimestamp(now())), + timestamp: this.toEpoch(now()), /** all generic events require pageUrl(s) */ pageUrl: cleanURL('' + initialLocation), currentUrl: cleanURL('' + location) @@ -286,4 +319,8 @@ export class Aggregate extends AggregateBase { if (this.agentRef.init.performance.resources.first_party_domains?.length !== 0) handle(SUPPORTABILITY_METRIC_CHANNEL, [configPerfTag + 'Resources/FirstPartyDomains/Changed'], undefined, FEATURE_NAMES.metrics, this.ee) if (this.agentRef.init.performance.resources.ignore_newrelic === false) handle(SUPPORTABILITY_METRIC_CHANNEL, [configPerfTag + 'Resources/IgnoreNewrelic/Changed'], undefined, FEATURE_NAMES.metrics, this.ee) } + + syncWithSessionManager (state = {}) { + this.agentRef.runtime.session.write(state) + } } diff --git a/src/features/generic_events/instrument/index.js b/src/features/generic_events/instrument/index.js index f5d92be17..5862f7d57 100644 --- a/src/features/generic_events/instrument/index.js +++ b/src/features/generic_events/instrument/index.js @@ -6,7 +6,9 @@ import { globalScope, isBrowserScope } from '../../../common/constants/runtime' import { handle } from '../../../common/event-emitter/handle' import { windowAddEventListener } from '../../../common/event-listener/event-listener-opts' +import { now } from '../../../common/timing/now' import { debounce } from '../../../common/util/invoke' +import { wrapHistory } from '../../../common/wrap/wrap-history' import { InstrumentBase } from '../../utils/instrument-base' import { FEATURE_NAME, OBSERVED_EVENTS, OBSERVED_WINDOW_EVENTS } from '../constants' @@ -43,6 +45,17 @@ export class Instrument extends InstrumentBase { }) observer.observe({ type: 'resource', buffered: true }) } + if (agentRef.init.session.user_journey) { + const trackUserJourney = (timestamp = now()) => { + handle('user-journey', [timestamp, location], undefined, this.featureName, this.ee) + } + + trackUserJourney() + const historyEE = wrapHistory(this.ee) + historyEE.on('pushState-end', trackUserJourney) + historyEE.on('replaceState-end', trackUserJourney) + windowAddEventListener('popstate', (evt) => trackUserJourney(evt.timeStamp), true, this.removeOnAbort?.signal) + } } /** If any of the sources are active, import the aggregator. otherwise deregister */ diff --git a/tests/components/session-entity.test.js b/tests/components/session-entity.test.js index 49a5bbdbc..81bade92e 100644 --- a/tests/components/session-entity.test.js +++ b/tests/components/session-entity.test.js @@ -1,6 +1,6 @@ import { PREFIX } from '../../src/common/session/constants' -import { SessionEntity } from '../../src/common/session/session-entity' -import { LocalMemory, model } from './session-helpers' +import { SessionEntity, model } from '../../src/common/session/session-entity' +import { LocalMemory } from './session-helpers' import * as runtimeModule from '../../src/common/constants/runtime' jest.useFakeTimers() diff --git a/tests/components/session-helpers.js b/tests/components/session-helpers.js index 8e90f6919..7bdf12c8d 100644 --- a/tests/components/session-helpers.js +++ b/tests/components/session-helpers.js @@ -29,15 +29,3 @@ export class LocalMemory { } } } -export const model = { - value: '', - inactiveAt: 0, - expiresAt: 0, - updatedAt: Date.now(), - sessionReplayMode: 0, - sessionReplaySentFirstChunk: false, - sessionTraceMode: 0, - traceHarvestStarted: false, - serverTimeDiff: null, - custom: {} -} diff --git a/tests/unit/common/config/init.test.js b/tests/unit/common/config/init.test.js index d57cb6cec..ec2f9de43 100644 --- a/tests/unit/common/config/init.test.js +++ b/tests/unit/common/config/init.test.js @@ -103,7 +103,8 @@ test('init props exist and return expected defaults', () => { }) expect(config.session).toEqual({ expiresMs: 14400000, - inactiveMs: 1800000 + inactiveMs: 1800000, + user_journey: true }) expect(config.session_replay).toEqual({ autoStart: true, diff --git a/tools/test-builds/browser-agent-wrapper/package.json b/tools/test-builds/browser-agent-wrapper/package.json index ced543f43..53f13de4a 100644 --- a/tools/test-builds/browser-agent-wrapper/package.json +++ b/tools/test-builds/browser-agent-wrapper/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.279.1.tgz" + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.280.0.tgz" } } diff --git a/tools/test-builds/library-wrapper/package.json b/tools/test-builds/library-wrapper/package.json index e31f6a238..9ee0f6541 100644 --- a/tools/test-builds/library-wrapper/package.json +++ b/tools/test-builds/library-wrapper/package.json @@ -10,7 +10,7 @@ "license": "ISC", "dependencies": { "@apollo/client": "^3.8.8", - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.279.1.tgz", + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.280.0.tgz", "graphql": "^16.8.1" } } diff --git a/tools/test-builds/raw-src-wrapper/package.json b/tools/test-builds/raw-src-wrapper/package.json index 9ef3ca785..b5c08bcfa 100644 --- a/tools/test-builds/raw-src-wrapper/package.json +++ b/tools/test-builds/raw-src-wrapper/package.json @@ -9,6 +9,6 @@ "author": "", "license": "ISC", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.279.1.tgz" + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.280.0.tgz" } } diff --git a/tools/test-builds/vite-react-wrapper/package.json b/tools/test-builds/vite-react-wrapper/package.json index 89a7eb6c9..31787c353 100644 --- a/tools/test-builds/vite-react-wrapper/package.json +++ b/tools/test-builds/vite-react-wrapper/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "dependencies": { - "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.279.1.tgz", + "@newrelic/browser-agent": "file:../../../temp/newrelic-browser-agent-1.280.0.tgz", "react": "18.2.0", "react-dom": "18.2.0" },