Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v24.16.0
9 changes: 9 additions & 0 deletions src/mp-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import { IErrorReportingService, ILoggingService } from './reporting/types';
import { logDeprecatedMethodUsage } from './reporting/deprecatedMethodLogger';
import { normalizeRoktLauncherOptions } from './roktLauncherOptions';
import { PageViewTracker } from './pageViewTracker';

export interface IErrorLogMessage {
message?: string;
Expand Down Expand Up @@ -87,6 +88,7 @@
_IdentityAPIClient: typeof IdentityAPIClient;
_IntegrationCapture: IntegrationCapture;
_NativeSdkHelpers: INativeSdkHelpers;
_PageViewTracker?: PageViewTracker;
_Persistence: IPersistence;
_CookieConsentManager: ICookieConsentManager;
_ErrorReportingDispatcher: ErrorReportingDispatcher;
Expand Down Expand Up @@ -128,12 +130,12 @@
this._instanceName = instanceName;
this._NativeSdkHelpers = new NativeSdkHelpers(this);
this._SessionManager = new SessionManager(this);
this._Persistence = new Persistence(this);

Check failure on line 133 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Type '_Persistence' is not assignable to type 'IPersistence'.
this._Helpers = new Helpers(this);

Check failure on line 134 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Type 'Helpers' is not assignable to type 'SDKHelpersApi'.
this._Events = new Events(this);
this._CookieSyncManager = new CookieSyncManager(this);
this._ServerModel = new ServerModel(this);
this._Ecommerce = new Ecommerce(this);

Check failure on line 138 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Type 'Ecommerce' is not assignable to type 'IECommerce'.
this._ForwardingStatsUploader = new ForwardingStatsUploader(this);
this._Consent = new Consent(this);
this._IdentityAPIClient = new IdentityAPIClient(this);
Expand Down Expand Up @@ -198,7 +200,7 @@
this.RoktEvents = RoktEvents;


this._Identity = new Identity(this);

Check failure on line 203 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Type 'Identity' is not assignable to type 'IIdentity'.
this.Identity = this._Identity.IdentityAPI;
this.generateHash = this._Helpers.generateHash;

Expand All @@ -207,9 +209,9 @@
this.getDeviceId = this._Persistence.getDeviceId;

if (typeof window !== 'undefined') {
if (window.mParticle && window.mParticle.config) {

Check failure on line 212 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Property 'config' does not exist on type 'typeof import("/home/runner/work/mparticle-web-sdk/mparticle-web-sdk/node_modules/@types/mparticle__web-sdk/index")'.
if (window.mParticle.config.hasOwnProperty('rq')) {

Check failure on line 213 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Property 'config' does not exist on type 'typeof import("/home/runner/work/mparticle-web-sdk/mparticle-web-sdk/node_modules/@types/mparticle__web-sdk/index")'.
this._preInit.readyQueue = window.mParticle.config.rq;

Check failure on line 214 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Property 'config' does not exist on type 'typeof import("/home/runner/work/mparticle-web-sdk/mparticle-web-sdk/node_modules/@types/mparticle__web-sdk/index")'.
}
}
}
Expand Down Expand Up @@ -1585,6 +1587,13 @@

if (getFeatureFlag(AutoLogPageView)) {
mpInstance._Events.logPageView();

if (!mpInstance._PageViewTracker) {
mpInstance._PageViewTracker = new PageViewTracker(mpInstance);
}
mpInstance._PageViewTracker.init();
} else if (mpInstance._PageViewTracker) {
mpInstance._PageViewTracker.teardown();
}

processIdentityCallback(
Expand Down Expand Up @@ -1705,7 +1714,7 @@
mpInstance._ErrorReportingDispatcher.logger = mpInstance.Logger;
mpInstance._LoggingDispatcher.logger = mpInstance.Logger;
mpInstance._Store = new Store(config, mpInstance, apiKey);
window.mParticle.Store = mpInstance._Store;

Check failure on line 1717 in src/mp-instance.ts

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Property 'Store' does not exist on type 'typeof import("/home/runner/work/mparticle-web-sdk/mparticle-web-sdk/node_modules/@types/mparticle__web-sdk/index")'.
mpInstance.Logger.verbose(StartingInitialization);

// Initialize CookieConsentManager with privacy flags from launcherOptions
Expand Down
267 changes: 267 additions & 0 deletions src/pageViewTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { IMParticleWebSDKInstance } from './mp-instance';
import { EventType, MessageType } from './types';

type HistoryStateMethod = History['pushState'];

const WRAPPED_MARKER = '__mpApvWrapped__';

type MarkedHistoryMethod = HistoryStateMethod & {
[WRAPPED_MARKER]?: boolean;
};

export class PageViewTracker {
mpInstance: IMParticleWebSDKInstance;

private lastPath: string | null = null;
private isActive = false;

private originalPushState: HistoryStateMethod | null = null;
private originalReplaceState: HistoryStateMethod | null = null;

private pushStateWrapper: HistoryStateMethod | null = null;
private replaceStateWrapper: HistoryStateMethod | null = null;

private popStateListener: (() => void) | null = null;

constructor(mpInstance: IMParticleWebSDKInstance) {
this.mpInstance = mpInstance;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to this, but I know you've given feedback about not putting the entire mPInstance into the constructor...the bot probably followed most of the examples of how classes are instantiated here and went that route. Just flagging

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. in a case where there are multiple instances, will this work? We only have 1 customer that i'm aware of and they are legacy CDP

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is temporary so I'm not worried about it at the moment. I think the only thing we'll need to inject is the _Events module to fire actual events, so this will likely go away.

}

private isSupportedEnvironment(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.history !== 'undefined' &&

Check warning on line 33 in src/pageViewTracker.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Compare with `undefined` directly instead of using `typeof`.

See more on https://sonarcloud.io/project/issues?id=mParticle_mparticle-web-sdk&issues=AZ_T25u25PSIVTcN6Kne&open=AZ_T25u25PSIVTcN6Kne&pullRequest=1308
typeof window.history.pushState === 'function' &&
typeof window.addEventListener === 'function'
);
}

public init(): void {
this.mpInstance.Logger.verbose(
'mParticle APV: [init] PageViewTracker Init'
);
if (!this.isSupportedEnvironment()) {
this.mpInstance.Logger.verbose(
'mParticle APV: [init] unsupported environment (no History API), not starting'
);
return;
}

if (this.isActive) {
this.mpInstance.Logger.verbose(
'mParticle APV: [init] starting (teardown-first for idempotency)'
);
this.teardown();
}

this.isActive = true;

this.lastPath = this.getCurrentKey();

this.mpInstance.Logger.verbose(
`mParticle APV: [init] seeded lastPath: ${this.lastPath}`
);

this.patchHistoryMethods();
this.addNavigationListeners();

this.mpInstance.Logger.verbose(
'mParticle APV: [init] patched pushState/replaceState + listening for popstate'
);
}

private patchHistoryMethods(): void {
const self = this;

Check failure on line 74 in src/pageViewTracker.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not assign `this` to `self`.

See more on https://sonarcloud.io/project/issues?id=mParticle_mparticle-web-sdk&issues=AZ_T25u25PSIVTcN6Knf&open=AZ_T25u25PSIVTcN6Knf&pullRequest=1308

const installed = window.history.pushState as MarkedHistoryMethod;
if (installed[WRAPPED_MARKER]) {
this.mpInstance.Logger.verbose(
'mParticle APV: [patch] history already wrapped, skipping to avoid double-wrap'
);
return;
}

const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
this.originalPushState = originalPushState;
this.originalReplaceState = originalReplaceState;

const pushStateWrapper = function(
this: History,
...args: Parameters<HistoryStateMethod>
): void {
const result = originalPushState.apply(this, args);
self.safeHandleNavigation('pushState');
return result;
};

const replaceStateWrapper = function(
this: History,
...args: Parameters<HistoryStateMethod>
): void {
const result = originalReplaceState.apply(this, args);
self.safeHandleNavigation('replaceState');
return result;
};

Object.defineProperty(pushStateWrapper, WRAPPED_MARKER, {
value: true,
enumerable: false,
});
Object.defineProperty(replaceStateWrapper, WRAPPED_MARKER, {
value: true,
enumerable: false,
});

this.pushStateWrapper = pushStateWrapper;
this.replaceStateWrapper = replaceStateWrapper;

try {
window.history.pushState = pushStateWrapper;
window.history.replaceState = replaceStateWrapper;
} catch (e) {
this.mpInstance.Logger.verbose(
`mParticle APV: [error] failed to patch history methods (frozen/sealed), rolling back: ${e}`
);
try {
if (window.history.pushState === pushStateWrapper) {
window.history.pushState = originalPushState;
}
if (window.history.replaceState === replaceStateWrapper) {
window.history.replaceState = originalReplaceState;
}
} catch (restoreError) {
this.mpInstance.Logger.verbose(
`mParticle APV: [error] failed to restore history methods after patch failure: ${restoreError}`
);
}
this.pushStateWrapper = null;
this.replaceStateWrapper = null;
this.originalPushState = null;
this.originalReplaceState = null;
}
}

private addNavigationListeners(): void {
this.popStateListener = () => this.safeHandleNavigation('popstate');
window.addEventListener('popstate', this.popStateListener);
}

private safeHandleNavigation(source: string): void {
try {
this.handleNavigation(source);
} catch (e) {
this.mpInstance.Logger.verbose(
`mParticle APV: [error] navigation handler threw (${source}), page view skipped: ${e}`
);
}
}

private getCurrentKey(): string {
return window.location.pathname;
}

private handleNavigation(source: string): void {
const candidatePath = this.getCurrentKey();

this.mpInstance.Logger.verbose(
`mParticle APV: [detect] navigation signal (source: ${source}, candidatePath: ${candidatePath}, lastPath: ${this.lastPath})`
);

if (candidatePath === this.lastPath) {
this.mpInstance.Logger.verbose(
`mParticle APV: [dedupe] pathname unchanged, skipping (source: ${source}, path: ${candidatePath})`
);
return;
}

this.mpInstance.Logger.verbose(
`mParticle APV: [accept] pathname changed, scheduling fire (source: ${source}, from: ${this.lastPath}, to: ${candidatePath})`
);
this.lastPath = candidatePath;

// Snapshot the path now: it is already settled at this point, and a
// same-tick navigation would otherwise overwrite window.location
// before the deferred flush reads it. The title is intentionally read
// later (in the deferred flush) so the router's post-navigation render
// commit has a chance to update document.title first.
const capturedPath = candidatePath;

setTimeout(() => {
if (!this.isActive) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the check for isActive should probably be higher up right when the navigation is happening as opposed to after all these checks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I follow your reasoning. This is a set timeout that runs after a navigation event so we just need to check if the pagetracker is still running. The guard is likely unnecessary since we can't turn it off in runtime but I think it's a good safety check.

this.mpInstance.Logger.verbose(
'mParticle APV: [defer] fire aborted, tracker inactive (torn down before flush)'
);
return;
}

this.mpInstance._SessionManager.resetSessionTimer();
this.firePageView(capturedPath);
}, 0);
Comment on lines +190 to +200

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C3: session expiry silently drops SPA page views — open question Q2 must be resolved as Opt 2.

When the session times out, performSessionEnd()nullifySession() clears _Store.sessionId, and serverModel.createEventObject returns null when there's no session (serverModel.ts:272–277, :389). So a user who idles past the 30-minute timeout and then navigates gets a page view that is dropped entirely, not just misattributed. That's a core SPA pattern (tab left open, user returns).

Fix: call this.mpInstance._SessionManager.startNewSessionIfNeeded() before _Events.logPageView() in this deferred callback.

Note the POC's console-warn validation structurally cannot surface this: [fire] logs before logEvent, which then drops the event downstream — the console looks healthy while data goes missing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good callout actually and I'll see if we can wire in session logic just so we can respect that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the POC — you're right that it went straight to logPageView() with no session handling, so an idled-out session would've dropped the page view. This is wired up now (the deferred flush calls resetSessionTimer() before logPageView()), which actually covers a bit more than startNewSessionIfNeeded() alone:

this.resetSessionTimer = function () {
    if (!mpInstance._Store.webviewBridgeEnabled) {
        if (!mpInstance._Store.sessionId) {
            self.startNewSession();      // recreates the nullified session
        }
        self.clearSessionTimeout();
        self.setSessionTimer();          // also resets the timeout clock
    }
    self.startNewSessionIfNeeded();      // your suggested call
};

I confirmed the drop you described: after performSessionEnd() -> nullifySession(), createEventObject returns null because _Store.sessionId is falsy (serverModel.ts:273-277, :388). startNewSession() sets sessionId synchronously (sessionManager.ts:96) before logPageView() reads it, so the returning-user navigation now starts a fresh session — matching what a full page reload would do — instead of dropping the event.

Comment thread
cursor[bot] marked this conversation as resolved.
}

// Mirrors the event shape of the public mParticle.logPageView(), but carries
// the captured SPA path rather than reading the live location.
private firePageView(path: string): void {
const title = window.document.title;

this.mpInstance.Logger.verbose(
`mParticle APV: [fire] deferred flush -> _Events.logEvent(PageView) (path: ${path}, title: ${title})`
);

this.mpInstance._Events.logEvent({
messageType: MessageType.PageView,
name: 'PageView',
data: {
hostname: window.location.hostname,
title,
path,
},
eventType: EventType.Unknown,
});
}

public teardown(): void {
if (this.popStateListener) {
window.removeEventListener('popstate', this.popStateListener);
this.popStateListener = null;
}
const pushStateStillOurs =
this.pushStateWrapper !== null &&
window.history.pushState === this.pushStateWrapper;
if (this.originalPushState) {
if (pushStateStillOurs) {
window.history.pushState = this.originalPushState;
this.mpInstance.Logger.verbose(
'mParticle APV: [teardown] restored original pushState'
);
} else {
this.mpInstance.Logger.verbose(
'mParticle APV: [teardown] pushState no longer ours; leaving in place, gating callback to no-op'
);
}
this.originalPushState = null;
this.pushStateWrapper = null;
}

const replaceStateStillOurs =
this.replaceStateWrapper !== null &&
window.history.replaceState === this.replaceStateWrapper;
if (this.originalReplaceState) {
if (replaceStateStillOurs) {
window.history.replaceState = this.originalReplaceState;
this.mpInstance.Logger.verbose(
'mParticle APV: [teardown] restored original replaceState'
);
} else {
this.mpInstance.Logger.verbose(
'mParticle APV: [teardown] replaceState no longer ours; leaving in place, gating callback to no-op'
);
}
this.originalReplaceState = null;
this.replaceStateWrapper = null;
Comment thread
cursor[bot] marked this conversation as resolved.
}

this.isActive = false;
}
}
Loading
Loading