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
2 changes: 2 additions & 0 deletions api-docs/docs/browser-tracker/browser-tracker.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,9 @@ export type TrackerConfiguration = {
plugins?: Array<BrowserPlugin>;
onSessionUpdateCallback?: (updatedSession: ClientSession) => void;
preservePageViewIdForUrl?: PreservePageViewIdForUrl;
preserveOriginalReferrer?: boolean;
synchronousCookieWrite?: boolean;
disableSessionContextWithinWebView?: boolean;
} & EmitterConfigurationBase & LocalStorageEventStoreConfigurationBase;

// @public
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/browser-plugin-webview",
"comment": "Add test coverage for disableSessionContextWithinWebView option suppressing client_session entity in hybrid native+WebView deployments",
"type": "none"
}
],
"packageName": "@snowplow/browser-plugin-webview"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/browser-tracker-core",
"comment": "Add disableSessionContextWithinWebView option to suppress client_session entity in hybrid native+WebView deployments",
"type": "minor"
}
],
"packageName": "@snowplow/browser-tracker-core"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/browser-tracker-core",
"comment": "Add preserveOriginalReferrer tracker configuration option for SPA referrer tracking",
"type": "none"
}
],
"packageName": "@snowplow/browser-tracker-core"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": [
{
"packageName": "@snowplow/browser-tracker-core",
"comment": "Remove forced layout read from tracker initialization: replace init-time getBrowserProperties() call with direct non-layout reads; defer the first readBrowserProperties() to first event build time.",
"type": "patch"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/javascript-tracker",
"comment": "Add WebView plugin as opt-in build-time feature flag (webView, default false)",
"type": "minor"
}
],
"packageName": "@snowplow/javascript-tracker"
}
4 changes: 4 additions & 0 deletions common/config/rush/browser-approved-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@
"name": "@snowplow/browser-plugin-web-vitals",
"allowedCategories": [ "trackers" ]
},
{
"name": "@snowplow/browser-plugin-webview",
"allowedCategories": [ "trackers" ]
},
{
"name": "@snowplow/browser-plugin-youtube-tracking",
"allowedCategories": [ "trackers" ]
Expand Down
3 changes: 3 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion common/config/rush/repo-state.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
"pnpmShrinkwrapHash": "522137187baf312832dbbe14a3a2ef4fafb57ca6",
"pnpmShrinkwrapHash": "abc453596eaf2cd7ff26dca182f8e42042b6326e",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}
13 changes: 13 additions & 0 deletions libraries/browser-tracker-core/src/helpers/browser_props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ function initializeResizeObserver() {

let cachedProperties: BrowserProperties;

/**
* Resets module-level browser property state. Used in tests to prevent state bleed between test cases.
* @internal
*/
export function resetBrowserPropertiesState() {
cachedProperties = undefined as any;
resizeObserverInitialized = false;
readBrowserPropertiesTask = null;
}

/**
* Gets various browser properties (that are expensive to read!)
* - Will use a "ResizeObserver" approach in modern browsers to update cached properties only on change
Expand All @@ -55,6 +65,9 @@ let cachedProperties: BrowserProperties;
*/
export function getBrowserProperties() {
if (!useResizeObserver()) {
// TODO: per-event forced reflow — each call re-reads layout geometry (offsetWidth, scrollHeight,
// etc.) for browsers without ResizeObserver. ResizeObserver has been in all major browsers since
// 2020 so this is an edge-case path. File a separate ticket to address if needed.
return readBrowserProperties();
}

Expand Down
33 changes: 30 additions & 3 deletions libraries/browser-tracker-core/src/tracker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import {
APPLICATION_CONTEXT_SCHEMA,
ACTIVITY_METRICS_SCHEMA,
} from './schemata';
import { getBrowserProperties } from '../helpers/browser_props';
import { getBrowserProperties, makeDimension } from '../helpers/browser_props';
import { asyncCookieStorage, syncCookieStorage } from './cookie_storage';

declare global {
Expand Down Expand Up @@ -316,6 +316,7 @@ export function Tracker(
configurations: {},
},
configSessionContext = trackerConfiguration.contexts?.session ?? false,
configDisableSessionInWebView = trackerConfiguration.disableSessionContextWithinWebView ?? false,
toOptoutByCookie: string | boolean,
onSessionUpdateCallback = trackerConfiguration.onSessionUpdateCallback,
manualSessionUpdateCalled = false,
Expand All @@ -327,7 +328,10 @@ export function Tracker(
configCookieDomain = findRootDomain(configCookieSameSite, configCookieSecure);
}

const { browserLanguage, resolution, colorDepth, cookiesEnabled } = getBrowserProperties();
const cookiesEnabled = window.navigator.cookieEnabled;
const colorDepth = screen.colorDepth;
const browserLanguage = window.navigator.language || (window.navigator as any).userLanguage;
const resolution = makeDimension(screen.width, screen.height);
const timeZone = getTimeZone();

// Set up unchanging name-value pairs
Expand Down Expand Up @@ -363,6 +367,10 @@ export function Tracker(

initializeIdsAndCookies();

if (trackerConfiguration.preserveOriginalReferrer && configReferrerUrl) {
customReferrer = configReferrerUrl;
}

if (trackerConfiguration.crossDomainLinker) {
decorateLinks(trackerConfiguration.crossDomainLinker);
}
Expand Down Expand Up @@ -1015,7 +1023,11 @@ export function Tracker(
configStateStorageStrategy,
configAnonymousTracking
);
if (configSessionContext && (!configAnonymousTracking || configAnonymousSessionTracking)) {
if (
configSessionContext &&
(!configAnonymousTracking || configAnonymousSessionTracking) &&
!(configDisableSessionInWebView && isInWebView())
) {
addSessionContextToPayload(payloadBuilder, clientSession);
}

Expand All @@ -1042,6 +1054,21 @@ export function Tracker(
};
}

/**
* Returns true when the page is running inside a Snowplow V2 WebView interface.
* Mirrors the three-interface check in @snowplow/webview-tracker without introducing
* a package dependency on browser-tracker-core.
*/
function isInWebView(): boolean {
return !!(
(window as any).SnowplowWebInterfaceV2 ||
((window as any).webkit &&
(window as any).webkit.messageHandlers &&
(window as any).webkit.messageHandlers.snowplowV2) ||
(window as any).ReactNativeWebView
);
}

function addSessionContextToPayload(payloadBuilder: PayloadBuilder, clientSession: ClientSession) {
let sessionContext: SelfDescribingJson<ClientSession> = {
schema: CLIENT_SESSION_SCHEMA,
Expand Down
36 changes: 36 additions & 0 deletions libraries/browser-tracker-core/src/tracker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,26 @@ export type TrackerConfiguration = {
*/
preservePageViewIdForUrl?: PreservePageViewIdForUrl;

/**
* When enabled, the original external referrer captured at tracker initialisation is frozen and
* used as the referrer for all subsequent page view events in the same session, including
* client-side navigations in single-page applications (SPAs).
*
* Without this option, each SPA navigation sets the referrer to the previous internal route,
* making it difficult to determine how the user originally arrived at the site across their
* session. Enable this option to preserve the original external referrer (e.g. google.com)
* across all `trackPageView` calls.
*
* If `document.referrer` is empty at initialisation (e.g. direct navigation), this option
* has no effect and the default per-navigation referrer chain behaviour applies.
*
* Setting `setReferrerUrl` after initialisation will override this value, as `customReferrer`
* always takes precedence.
*
* @defaultValue false
*/
preserveOriginalReferrer?: boolean;

/**
* Whether to write the cookies synchronously.
* This can be useful for testing purposes to ensure that the cookies are written before the test continues.
Expand All @@ -205,6 +225,22 @@ export type TrackerConfiguration = {
* @defaultValue false
*/
synchronousCookieWrite?: boolean;
/**
* When set to `true`, the tracker will not attach the `client_session` context entity to events
* when running inside a mobile WebView (i.e. when a Snowplow V2 WebView interface is detected).
*
* In hybrid native+WebView deployments the mobile SDK already contributes its own `client_session`
* entity. Allowing a second one from the JavaScript tracker causes duplicate-session problems in
* downstream modelling (e.g. dbt-snowplow-unified). Setting this option suppresses the JavaScript
* tracker's copy while the `contexts.session` flag can remain `true`.
*
* Detection uses the same three V2 interface checks as `@snowplow/webview-tracker`:
* `window.SnowplowWebInterfaceV2`, `window.webkit?.messageHandlers?.snowplowV2`, and
* `window.ReactNativeWebView`.
*
* @defaultValue false
*/
disableSessionContextWithinWebView?: boolean;
} & EmitterConfigurationBase &
LocalStorageEventStoreConfigurationBase;

Expand Down
123 changes: 122 additions & 1 deletion libraries/browser-tracker-core/test/browser_props.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { makeDimension, getBrowserProperties } from '../src/helpers/browser_props';
import { makeDimension, getBrowserProperties, resetBrowserPropertiesState } from '../src/helpers/browser_props';

describe('Browser props', () => {
it('makeDimension correctly floors dimension type values', () => {
Expand All @@ -16,12 +16,133 @@ describe('Browser props', () => {
});

describe('#getBrowserProperties', () => {
describe('caching behavior (modern browsers with ResizeObserver)', () => {
let savedResizeObserver: any;

beforeEach(() => {
savedResizeObserver = (window as any).ResizeObserver;
// Provide a minimal ResizeObserver so the caching path is exercised
(window as any).ResizeObserver = class MockResizeObserver {
constructor(_cb: ResizeObserverCallback) {}
observe() {}
unobserve() {}
disconnect() {}
};
resetBrowserPropertiesState();
});

afterEach(() => {
(window as any).ResizeObserver = savedResizeObserver;
resetBrowserPropertiesState();
jest.restoreAllMocks();
});

it('returns the same cached reference on successive calls', () => {
const first = getBrowserProperties();
const second = getBrowserProperties();
expect(second).toBe(first);
});

it('reset clears the cache so the next call re-reads browser properties', () => {
getBrowserProperties();
resetBrowserPropertiesState();
const fresh = getBrowserProperties();
expect(fresh).toBeDefined();
expect(fresh.cookiesEnabled).toBeDefined();
});

it('rAF callback triggered by ResizeObserver updates cachedProperties', () => {
let capturedResizeCallback: ResizeObserverCallback | undefined;
let capturedRafCallback: FrameRequestCallback | undefined;

// Override the mock to capture the ResizeObserver callback so we can trigger it manually
(window as any).ResizeObserver = class CaptureResizeObserver {
constructor(cb: ResizeObserverCallback) {
capturedResizeCallback = cb;
}
observe() {}
unobserve() {}
disconnect() {}
};
resetBrowserPropertiesState();

jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
capturedRafCallback = cb;
return 1;
});

const first = getBrowserProperties(); // populates cache + wires up ResizeObserver

// Trigger the ResizeObserver callback to schedule a rAF
expect(capturedResizeCallback).toBeDefined();
capturedResizeCallback!([], {} as ResizeObserver);
expect(capturedRafCallback).toBeDefined();

// rAF has been scheduled but not yet fired — cache still holds the original value
const beforeRaf = getBrowserProperties();
expect(beforeRaf).toBe(first);

// Fire the rAF callback to simulate the cache update
capturedRafCallback!(performance.now());

// Cache was refreshed by the rAF callback
const afterRaf = getBrowserProperties();
expect(afterRaf).toBeDefined();
});
});

describe('old-browser fallback (no ResizeObserver)', () => {
let savedResizeObserver: any;

beforeEach(() => {
savedResizeObserver = (window as any).ResizeObserver;
delete (window as any).ResizeObserver;
resetBrowserPropertiesState();
});

afterEach(() => {
(window as any).ResizeObserver = savedResizeObserver;
resetBrowserPropertiesState();
});

it('calls readBrowserProperties on every invocation — no caching', () => {
const first = getBrowserProperties();
const second = getBrowserProperties();
// Without ResizeObserver caching each call returns a fresh object
expect(second).not.toBe(first);
});
});

describe('with undefined document', () => {
let originalDocument: typeof document;
let savedResizeObserver: any;

beforeAll(() => {
originalDocument = document;
savedResizeObserver = (window as any).ResizeObserver;

// Ensure the caching path is taken so initializeResizeObserver() guards against undefined document
(window as any).ResizeObserver = class MockResizeObserver {
constructor(_cb: ResizeObserverCallback) {}
observe() {}
unobserve() {}
disconnect() {}
};

// Pre-populate cache while document is still available
resetBrowserPropertiesState();
getBrowserProperties();

// @ts-expect-error
document = undefined;
});

afterAll(() => {
document = originalDocument;
(window as any).ResizeObserver = savedResizeObserver;
resetBrowserPropertiesState();
});

it('does not invoke the resize observer if the document is null', () => {
const browserProperties = getBrowserProperties();
expect(browserProperties).not.toEqual(null);
Expand Down
Loading
Loading