From fd38a8fa8560dad0c6721c2eaeed2f3f5c674900 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:41:54 -0700 Subject: [PATCH] feat: Implement goals for client-side SDKs. (#585) Implements goals. Additionally adds browser specific configuration as it is required by goals. Also addresses SDK-563 --- .../__tests__/goals/GoalManager.test.ts | 114 ++++++++++ .../goals/GoalTracker.matchesUrl.test.ts | 115 ++++++++++ .../__tests__/goals/GoalTracker.test.ts | 213 ++++++++++++++++++ .../__tests__/goals/LocationWatcher.test.ts | 87 +++++++ .../sdk/browser/__tests__/options.test.ts | 77 +++++++ .../__tests__/platform/LocalStorage.test.ts | 2 + packages/sdk/browser/jest.config.js | 11 +- packages/sdk/browser/package.json | 6 +- packages/sdk/browser/src/BrowserClient.ts | 68 +++++- packages/sdk/browser/src/goals/GoalManager.ts | 57 +++++ packages/sdk/browser/src/goals/GoalTracker.ts | 100 ++++++++ packages/sdk/browser/src/goals/Goals.ts | 44 ++++ .../sdk/browser/src/goals/LocationWatcher.ts | 60 +++++ packages/sdk/browser/src/index.ts | 6 +- packages/sdk/browser/src/options.ts | 71 ++++++ .../src/internal/events/EventProcessor.ts | 40 ++++ .../src/internal/events/InputClickEvent.ts | 11 + .../common/src/internal/events/InputEvent.ts | 10 +- .../src/internal/events/InputPageViewEvent.ts | 10 + .../shared/sdk-client/src/LDClientImpl.ts | 8 + 20 files changed, 1096 insertions(+), 14 deletions(-) create mode 100644 packages/sdk/browser/__tests__/goals/GoalManager.test.ts create mode 100644 packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts create mode 100644 packages/sdk/browser/__tests__/goals/GoalTracker.test.ts create mode 100644 packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts create mode 100644 packages/sdk/browser/__tests__/options.test.ts create mode 100644 packages/sdk/browser/src/goals/GoalManager.ts create mode 100644 packages/sdk/browser/src/goals/GoalTracker.ts create mode 100644 packages/sdk/browser/src/goals/Goals.ts create mode 100644 packages/sdk/browser/src/goals/LocationWatcher.ts create mode 100644 packages/sdk/browser/src/options.ts create mode 100644 packages/shared/common/src/internal/events/InputClickEvent.ts create mode 100644 packages/shared/common/src/internal/events/InputPageViewEvent.ts diff --git a/packages/sdk/browser/__tests__/goals/GoalManager.test.ts b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts new file mode 100644 index 000000000..819528aa6 --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/GoalManager.test.ts @@ -0,0 +1,114 @@ +import { jest } from '@jest/globals'; + +import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; + +import GoalManager from '../../src/goals/GoalManager'; +import { Goal } from '../../src/goals/Goals'; +import { LocationWatcher } from '../../src/goals/LocationWatcher'; + +describe('given a GoalManager with mocked dependencies', () => { + let mockRequests: jest.Mocked; + let mockReportError: jest.Mock; + let mockReportGoal: jest.Mock; + let mockLocationWatcherFactory: () => { cb?: () => void } & LocationWatcher; + let mockLocationWatcher: { cb?: () => void } & LocationWatcher; + let goalManager: GoalManager; + const mockCredential = 'test-credential'; + + beforeEach(() => { + mockRequests = { fetch: jest.fn() } as any; + mockReportError = jest.fn(); + mockReportGoal = jest.fn(); + mockLocationWatcher = { close: jest.fn() }; + // @ts-expect-error The type is correct, but TS cannot handle the jest.fn typing + mockLocationWatcherFactory = jest.fn((cb: () => void) => { + mockLocationWatcher.cb = cb; + return mockLocationWatcher; + }); + + goalManager = new GoalManager( + mockCredential, + mockRequests, + 'polling', + mockReportError, + mockReportGoal, + mockLocationWatcherFactory, + ); + }); + + it('should fetch goals and set up the location watcher', async () => { + const mockGoals: Goal[] = [ + { key: 'goal1', kind: 'click', selector: '#button1' }, + { key: 'goal2', kind: 'click', selector: '#button2' }, + ]; + + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); + + await goalManager.initialize(); + + expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential'); + expect(mockLocationWatcherFactory).toHaveBeenCalled(); + }); + + it('should handle failed initial fetch by reporting an unexpected response error', async () => { + const error = new Error('Fetch failed'); + + mockRequests.fetch.mockRejectedValue(error); + + await goalManager.initialize(); + + expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError)); + }); + + it('should close the watcher and tracker when closed', () => { + goalManager.close(); + + expect(mockLocationWatcher.close).toHaveBeenCalled(); + }); + + it('should not emit a goal on initial for a non-matching URL, but should emit after URL change to a matching URL', async () => { + const mockGoals: Goal[] = [ + { + key: 'goal1', + kind: 'pageview', + urls: [ + { + kind: 'exact', + url: 'https://example.com/target', + }, + ], + }, + ]; + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/not-target' }, + writable: true, + }); + + mockRequests.fetch.mockResolvedValue({ + json: () => Promise.resolve(mockGoals), + } as any); + await goalManager.initialize(); + + // Check that no goal was emitted on initial load + expect(mockReportGoal).not.toHaveBeenCalled(); + + // Simulate URL change to match the goal + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/target' }, + writable: true, + }); + + // Trigger the location change callback + mockLocationWatcher.cb?.(); + + // Check that the goal was emitted after URL change + expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', { + key: 'goal1', + kind: 'pageview', + urls: [{ kind: 'exact', url: 'https://example.com/target' }], + }); + }); +}); diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts new file mode 100644 index 000000000..08d18410e --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts @@ -0,0 +1,115 @@ +import { Matcher } from '../../src/goals/Goals'; +import { matchesUrl } from '../../src/goals/GoalTracker'; + +it.each([ + ['https://example.com', '', '', 'https://example.com'], + [ + 'https://example.com?potato=true#hash', + '?potato=true', + '#hash', + 'https://example.com?potato=true#hash', + ], +])('returns true for exact match with "exact" matcher kind', (href, query, hash, matcherUrl) => { + const matcher: Matcher = { kind: 'exact', url: matcherUrl }; + const result = matchesUrl(matcher, href, query, hash); + expect(result).toBe(true); +}); + +it.each([ + ['https://example.com/potato', '', '', 'https://example.com'], + [ + 'https://example.com?potato=true#hash', + '?potato=true', + '#hash', + 'https://example.com?potato=true#brown', + ], +])('returns false for non-matching "exact" matcher kind', (href, query, hash, matcherUrl) => { + const matcher: Matcher = { kind: 'exact', url: matcherUrl }; + const result = matchesUrl(matcher, href, query, hash); + expect(result).toBe(false); +}); + +it('returns true for canonical match with "canonical" matcher kind', () => { + // For this type of match the hash and query parameters are not included. + const matcher: Matcher = { kind: 'canonical', url: 'https://example.com/some-path' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns true for substring match with "substring" matcher kind', () => { + const matcher: Matcher = { kind: 'substring', substring: 'example' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns false for non-matching substring with "substring" matcher kind', () => { + const matcher: Matcher = { kind: 'substring', substring: 'nonexistent' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(false); +}); + +it('returns true for regex match with "regex" matcher kind', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'example\\.com' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(true); +}); + +it('returns false for non-matching regex with "regex" matcher kind', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'nonexistent\\.com' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#hash', + '?query=1', + '#hash', + ); + expect(result).toBe(false); +}); + +it('includes the hash for "path-like" hashes for "substring" matchers', () => { + const matcher: Matcher = { kind: 'substring', substring: 'example' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#/hash/path', + '?query=1', + '#/hash/path', + ); + expect(result).toBe(true); +}); + +it('includes the hash for "path-like" hashes for "regex" matchers', () => { + const matcher: Matcher = { kind: 'regex', pattern: 'hash' }; + const result = matchesUrl( + matcher, + 'https://example.com/some-path?query=1#/hash/path', + '?query=1', + '#/hash/path', + ); + expect(result).toBe(true); +}); + +it('returns false for unsupported matcher kind', () => { + // @ts-expect-error + const matcher: Matcher = { kind: 'unsupported' }; + const result = matchesUrl(matcher, 'https://example.com', '', ''); + expect(result).toBe(false); +}); diff --git a/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts new file mode 100644 index 000000000..23491abf2 --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/GoalTracker.test.ts @@ -0,0 +1,213 @@ +/* eslint-disable no-new */ +// The tracker depends on side effects to test, so we need to disable no-new. +// The URL matching functionality is tested in GoalTracker.urlMatches.test.ts so this file does not +// exhaustively test URL matching. It instead tests the functionality of the tracker. +import { jest } from '@jest/globals'; + +import { Goal } from '../../src/goals/Goals'; +import GoalTracker from '../../src/goals/GoalTracker'; + +let mockOnEvent: jest.Mock; + +beforeEach(() => { + mockOnEvent = jest.fn(); + jest.spyOn(document, 'addEventListener'); + jest.spyOn(document, 'removeEventListener'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should trigger pageview goals on initialization', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + expect(mockOnEvent).toHaveBeenCalledWith(goals[0]); +}); + +it('should not trigger pageview goals for non-matching URLs', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://other.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + expect(mockOnEvent).not.toHaveBeenCalled(); +}); + +it('should add click event listener for click goals', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); +}); + +it('should not add click event listener if no click goals', () => { + const goals: Goal[] = [ + { key: 'page1', kind: 'pageview', urls: [{ kind: 'exact', url: 'http://example.com' }] }, + ]; + + new GoalTracker(goals, mockOnEvent); + + expect(document.addEventListener).not.toHaveBeenCalled(); +}); + +it('should trigger click goals when matching element is clicked', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + const button = document.createElement('button'); + button.className = 'button'; + document.body.appendChild(button); + button.click(); + + expect(mockOnEvent).toHaveBeenCalledWith(goals[0]); + + document.body.removeChild(button); +}); + +it('should not trigger click goals when matching element is clicked but URL does not match', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://other.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + const button = document.createElement('button'); + button.className = 'button'; + document.body.appendChild(button); + button.click(); + + expect(mockOnEvent).not.toHaveBeenCalled(); + + document.body.removeChild(button); +}); + +it('should remove click event listener on close', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.button', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + const tracker = new GoalTracker(goals, mockOnEvent); + tracker.close(); + + expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); +}); + +it('should trigger the click goal for parent elements which match the selector', () => { + const goals: Goal[] = [ + { + key: 'click1', + kind: 'click', + selector: '.my-selector', + urls: [{ kind: 'exact', url: 'http://example.com' }], + }, + ]; + + jest.spyOn(window, 'location', 'get').mockImplementation( + () => + ({ + href: 'http://example.com', + search: '', + hash: '', + }) as Location, + ); + + new GoalTracker(goals, mockOnEvent); + + const parent = document.createElement('div'); + parent.className = 'my-selector'; + document.body.appendChild(parent); + + const button = document.createElement('button'); + button.className = 'my-selector'; + parent.appendChild(button); + + button.click(); + + expect(mockOnEvent).toHaveBeenCalledTimes(2); +}); diff --git a/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts b/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts new file mode 100644 index 000000000..561419d07 --- /dev/null +++ b/packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts @@ -0,0 +1,87 @@ +import { jest } from '@jest/globals'; + +import { DefaultLocationWatcher, LOCATION_WATCHER_INTERVAL } from '../../src/goals/LocationWatcher'; + +let mockCallback: jest.Mock; + +beforeEach(() => { + mockCallback = jest.fn(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +it('should call callback when URL changes', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + watcher.close(); +}); + +it('should not call callback when URL remains the same', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL * 2); + + expect(mockCallback).not.toHaveBeenCalled(); + + watcher.close(); +}); + +it('should call callback on popstate event', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + window.dispatchEvent(new Event('popstate')); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + watcher.close(); +}); + +it('should stop watching when close is called', () => { + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true, + }); + + const watcher = new DefaultLocationWatcher(mockCallback); + + watcher.close(); + + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com/new-page' }, + writable: true, + }); + jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL); + window.dispatchEvent(new Event('popstate')); + + expect(mockCallback).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts new file mode 100644 index 000000000..bbeee2fde --- /dev/null +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -0,0 +1,77 @@ +import { jest } from '@jest/globals'; + +import { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import validateOptions, { filterToBaseOptions } from '../src/options'; + +let logger: LDLogger; + +beforeEach(() => { + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +}); + +it('logs no warnings when all configuration is valid', () => { + validateOptions( + { + fetchGoals: true, + eventUrlTransformer: (url: string) => url, + }, + logger, + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('warns for invalid configuration', () => { + validateOptions( + { + // @ts-ignore + fetchGoals: 'yes', + // @ts-ignore + eventUrlTransformer: 'not a function', + }, + logger, + ); + + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "fetchGoals" should be of type boolean, got string, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "eventUrlTransformer" should be of type function, got string, using default value', + ); +}); + +it('applies default options', () => { + const opts = validateOptions({}, logger); + + expect(opts.fetchGoals).toBe(true); + expect(opts.eventUrlTransformer).toBeUndefined(); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('filters to base options', () => { + const opts = { + debug: false, + fetchGoals: true, + eventUrlTransformer: (url: string) => url, + }; + + const baseOpts = filterToBaseOptions(opts); + expect(baseOpts.debug).toBe(false); + expect(Object.keys(baseOpts).length).toEqual(1); + expect(baseOpts).not.toHaveProperty('fetchGoals'); + expect(baseOpts).not.toHaveProperty('eventUrlTransformer'); +}); diff --git a/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts index 477b348f0..6ee6618a5 100644 --- a/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts +++ b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts @@ -1,3 +1,5 @@ +import { jest } from '@jest/globals'; + import LocalStorage from '../../src/platform/LocalStorage'; it('can set values', async () => { diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 1f0bda372..523d4a99d 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -1,11 +1,10 @@ export default { - preset: 'ts-jest', + extensionsToTreatAsEsm: ['.ts'], + verbose: true, + preset: 'ts-jest/presets/default-esm', testEnvironment: 'jest-environment-jsdom', transform: { - '^.+\\.tsx?$': 'ts-jest', - // process `*.tsx` files with `ts-jest` - }, - moduleNameMapper: { - '\\.(gif|ttf|eot|svg|png)$': '/test/__ mocks __/fileMock.js', + '^.+\\.tsx?$': ['ts-jest', { useESM: true }], }, + testPathIgnorePatterns: ['./dist'], }; diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 3f6f646b6..367528e2b 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -30,14 +30,16 @@ "build": "rollup -c rollup.config.js", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", - "test": "jest", + "test": "NODE_OPTIONS=--experimental-vm-modules npx jest", "coverage": "yarn test --coverage", "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.7.0" + "@launchdarkly/js-client-sdk-common": "1.7.0", + "escape-string-regexp": "^5.0.0" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@launchdarkly/private-js-mocks": "0.0.1", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 79153a220..f24811901 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -1,14 +1,17 @@ import { AutoEnvAttributes, base64UrlEncode, + BasicLogger, LDClient as CommonClient, DataSourcePaths, Encoding, LDClientImpl, LDContext, - LDOptions, } from '@launchdarkly/js-client-sdk-common'; +import GoalManager from './goals/GoalManager'; +import { Goal, isClick } from './goals/Goals'; +import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; /** @@ -17,18 +20,77 @@ import BrowserPlatform from './platform/BrowserPlatform'; export type LDClient = Omit; export class BrowserClient extends LDClientImpl { + private readonly goalManager?: GoalManager; constructor( private readonly clientSideId: string, autoEnvAttributes: AutoEnvAttributes, - options: LDOptions = {}, + options: BrowserOptions = {}, ) { - super(clientSideId, autoEnvAttributes, new BrowserPlatform(options), options, { + const { logger: customLogger, debug } = options; + const logger = + customLogger ?? + new BasicLogger({ + level: debug ? 'debug' : 'info', + // eslint-disable-next-line no-console + destination: console.log, + }); + + // TODO: Use the already-configured baseUri from the SDK config. SDK-560 + const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; + + const platform = new BrowserPlatform(options); + const ValidatedBrowserOptions = validateOptions(options, logger); + super(clientSideId, autoEnvAttributes, platform, filterToBaseOptions(options), { analyticsEventPath: `/events/bulk/${clientSideId}`, diagnosticEventPath: `/events/diagnostic/${clientSideId}`, includeAuthorizationHeader: false, highTimeoutThreshold: 5, userAgentHeaderName: 'x-launchdarkly-user-agent', }); + + if (ValidatedBrowserOptions.fetchGoals) { + this.goalManager = new GoalManager( + clientSideId, + platform.requests, + baseUrl, + (err) => { + // TODO: May need to emit. SDK-561 + logger.error(err.message); + }, + (url: string, goal: Goal) => { + const context = this.getInternalContext(); + if (!context) { + return; + } + if (isClick(goal)) { + this.sendEvent({ + kind: 'click', + url, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + selector: goal.selector, + }); + } else { + this.sendEvent({ + kind: 'pageview', + url, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + }); + } + }, + ); + + // This is intentionally not awaited. If we want to add a "goalsready" event, or + // "waitForGoalsReady", then we would make an async immediately invoked function expression + // which emits the event, and assign its promise to a member. The "waitForGoalsReady" function + // would return that promise. + this.goalManager.initialize(); + } } private encodeContext(context: LDContext) { diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts new file mode 100644 index 000000000..eecd21f92 --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -0,0 +1,57 @@ +import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; + +import { Goal } from './Goals'; +import GoalTracker from './GoalTracker'; +import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher'; + +export default class GoalManager { + private goals?: Goal[] = []; + private url: string; + private watcher?: LocationWatcher; + private tracker?: GoalTracker; + + constructor( + credential: string, + private readonly requests: Requests, + baseUrl: string, + private readonly reportError: (err: Error) => void, + private readonly reportGoal: (url: string, goal: Goal) => void, + locationWatcherFactory: (cb: () => void) => LocationWatcher = (cb) => + new DefaultLocationWatcher(cb), + ) { + // TODO: Generate URL in a better way. + this.url = `${baseUrl}/sdk/goals/${credential}`; + + this.watcher = locationWatcherFactory(() => { + this.createTracker(); + }); + } + + public async initialize(): Promise { + await this.fetchGoals(); + this.createTracker(); + } + + private createTracker() { + this.tracker?.close(); + if (this.goals && this.goals.length) { + this.tracker = new GoalTracker(this.goals, (goal) => { + this.reportGoal(window.location.href, goal); + }); + } + } + + private async fetchGoals(): Promise { + try { + const res = await this.requests.fetch(this.url); + this.goals = await res.json(); + } catch (err) { + this.reportError(new LDUnexpectedResponseError(`Encountered error fetching goals: ${err}`)); + } + } + + close(): void { + this.watcher?.close(); + this.tracker?.close(); + } +} diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts new file mode 100644 index 000000000..cac85079c --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -0,0 +1,100 @@ +import escapeStringRegexp from 'escape-string-regexp'; + +import { ClickGoal, Goal, Matcher } from './Goals'; + +type EventHandler = (goal: Goal) => void; + +export function matchesUrl(matcher: Matcher, href: string, search: string, hash: string) { + /** + * Hash fragments are included when they include forward slashes to allow for applications that + * use path-like hashes. (http://example.com/url/path#/additional/path) + * + * When they do not include a forward slash they are considered anchors and are not included + * in matching. + */ + const keepHash = (matcher.kind === 'substring' || matcher.kind === 'regex') && hash.includes('/'); + // For most matching purposes we want the "canonical" URL, which in this context means the + // excluding the query parameters and hash (unless the hash is path-like). + const canonicalUrl = (keepHash ? href : href.replace(hash, '')).replace(search, ''); + + switch (matcher.kind) { + case 'exact': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(href); + case 'canonical': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(canonicalUrl); + case 'substring': + return new RegExp(`.*${escapeStringRegexp(matcher.substring)}.*$`).test(canonicalUrl); + case 'regex': + return new RegExp(matcher.pattern).test(canonicalUrl); + default: + return false; + } +} + +function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { + const matches: ClickGoal[] = []; + + clickGoals.forEach((goal) => { + let target: Node | null = event.target as Node; + const { selector } = goal; + const elements = document.querySelectorAll(selector); + + // Traverse from the target of the event up the page hierarchy. + // If there are no element that match the selector, then no need to check anything. + while (target && elements.length) { + // The elements are a NodeList, so it doesn't have the array functions. For performance we + // do not convert it to an array. + for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { + if (target === elements[elementIndex]) { + matches.push(goal); + // The same element should not be in the list multiple times. + // Multiple objects in the hierarchy can match the selector, so we don't break the outer + // loop. + break; + } + } + target = target.parentNode as Node; + } + }); + + return matches; +} + +/** + * Tracks the goals on an individual "page" (combination of route, query params, and hash). + */ +export default class GoalTracker { + private clickHandler?: (event: Event) => void; + constructor(goals: Goal[], onEvent: EventHandler) { + const goalsMatchingUrl = goals.filter((goal) => + goal.urls?.some((matcher) => + matchesUrl(matcher, window.location.href, window.location.search, window.location.hash), + ), + ); + + const pageviewGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'pageview'); + const clickGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'click'); + + pageviewGoals.forEach((event) => onEvent(event)); + + if (clickGoals.length) { + // Click handler is not a member function in order to avoid having to bind it for the event + // handler and then track a reference to that bound handler. + this.clickHandler = (event: Event) => { + findGoalsForClick(event, clickGoals).forEach((clickGoal) => { + onEvent(clickGoal); + }); + }; + document.addEventListener('click', this.clickHandler); + } + } + + /** + * Close the tracker which stops listening to any events. + */ + close() { + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler); + } + } +} diff --git a/packages/sdk/browser/src/goals/Goals.ts b/packages/sdk/browser/src/goals/Goals.ts new file mode 100644 index 000000000..6b74a43dc --- /dev/null +++ b/packages/sdk/browser/src/goals/Goals.ts @@ -0,0 +1,44 @@ +export type GoalKind = 'click' | 'pageview'; + +export type MatcherKind = 'exact' | 'canonical' | 'substring' | 'regex'; + +export interface ExactMatcher { + kind: 'exact'; + url: string; +} + +export interface SubstringMatcher { + kind: 'substring'; + substring: string; +} + +export interface CanonicalMatcher { + kind: 'canonical'; + url: string; +} + +export interface RegexMatcher { + kind: 'regex'; + pattern: string; +} + +export type Matcher = ExactMatcher | SubstringMatcher | CanonicalMatcher | RegexMatcher; + +export interface PageViewGoal { + key: string; + kind: 'pageview'; + urls?: Matcher[]; +} + +export interface ClickGoal { + key: string; + kind: 'click'; + urls?: Matcher[]; + selector: string; +} + +export type Goal = PageViewGoal | ClickGoal; + +export function isClick(goal: Goal): goal is ClickGoal { + return goal.kind === 'click'; +} diff --git a/packages/sdk/browser/src/goals/LocationWatcher.ts b/packages/sdk/browser/src/goals/LocationWatcher.ts new file mode 100644 index 000000000..4343f644e --- /dev/null +++ b/packages/sdk/browser/src/goals/LocationWatcher.ts @@ -0,0 +1,60 @@ +export const LOCATION_WATCHER_INTERVAL = 300; + +// Using any for the timer handle because the type is not the same for all +// platforms and we only need to use it opaquely. +export type IntervalHandle = any; + +export interface LocationWatcher { + close(): void; +} + +/** + * Watches the browser URL and detects changes. + * + * This is used to detect URL changes for generating pageview events. + * + * @internal + */ +export class DefaultLocationWatcher { + private previousLocation?: string; + private watcherHandle: IntervalHandle; + private cleanupListeners?: () => void; + + /** + * @param callback Callback that is executed whenever a URL change is detected. + */ + constructor(callback: () => void) { + this.previousLocation = window.location.href; + const checkUrl = () => { + const currentLocation = window.location.href; + + if (currentLocation !== this.previousLocation) { + this.previousLocation = currentLocation; + callback(); + } + }; + /** The location is watched via polling and popstate events because it is possible to miss + * navigation at certain points with just popstate. It is also to miss events with polling + * because they can happen within the polling interval. + * Details on when popstate is called: + * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#when_popstate_is_sent + */ + this.watcherHandle = setInterval(checkUrl, LOCATION_WATCHER_INTERVAL); + + window.addEventListener('popstate', checkUrl); + + this.cleanupListeners = () => { + window.removeEventListener('popstate', checkUrl); + }; + } + + /** + * Stop watching for location changes. + */ + close(): void { + if (this.watcherHandle) { + clearInterval(this.watcherHandle); + } + this.cleanupListeners?.(); + } +} diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 597439a38..26015a674 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -10,17 +10,18 @@ import { LDLogger, LDLogLevel, LDMultiKindContext, - LDOptions, LDSingleKindContext, } from '@launchdarkly/js-client-sdk-common'; +// The exported LDClient and LDOptions are the browser specific implementations. +// These shadow the common implementations. import { BrowserClient, LDClient } from './BrowserClient'; +import { BrowserOptions as LDOptions } from './options'; // TODO: Export and use browser specific options. export { LDClient, AutoEnvAttributes, - LDOptions, LDFlagSet, LDContext, LDContextCommon, @@ -29,6 +30,7 @@ export { LDSingleKindContext, LDLogLevel, LDLogger, + LDOptions, LDEvaluationDetail, LDEvaluationDetailTyped, LDEvaluationReason, diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts new file mode 100644 index 000000000..c0d62c549 --- /dev/null +++ b/packages/sdk/browser/src/options.ts @@ -0,0 +1,71 @@ +import { + LDLogger, + LDOptions as LDOptionsBase, + OptionMessages, + TypeValidator, + TypeValidators, +} from '@launchdarkly/js-client-sdk-common'; + +/** + * Initialization options for the LaunchDarkly browser SDK. + */ +export interface BrowserOptions extends LDOptionsBase { + /** + * Whether the client should make a request to LaunchDarkly for Experimentation metrics (goals). + * + * This is true by default, meaning that this request will be made on every page load. + * Set it to false if you are not using Experimentation and want to skip the request. + */ + fetchGoals?: boolean; + + /** + * A function which, if present, can change the URL in analytics events to something other + * than the actual browser URL. It will be called with the current browser URL as a parameter, + * and returns the value that should be stored in the event's `url` property. + */ + eventUrlTransformer?: (url: string) => string; +} + +export interface ValidatedOptions { + fetchGoals: boolean; + eventUrlTransformer?: (url: string) => string; +} + +const optDefaults = { + fetchGoals: true, + eventUrlTransformer: undefined, +}; + +const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { + fetchGoals: TypeValidators.Boolean, + eventUrlTransformer: TypeValidators.Function, +}; + +export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = { ...opts }; + + // Remove any browser specific configuration keys so we don't get warnings from + // the base implementation for unknown configuration. + Object.keys(optDefaults).forEach((key) => { + delete (baseOptions as any)[key]; + }); + return baseOptions; +} + +export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions { + const output: ValidatedOptions = { ...optDefaults }; + + Object.entries(validators).forEach((entry) => { + const [key, validator] = entry as [keyof BrowserOptions, TypeValidator]; + const value = opts[key]; + if (value !== undefined) { + if (validator.is(value)) { + output[key as keyof ValidatedOptions] = value as any; + } else { + logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); + } + } + }); + + return output; +} diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 874349421..ef4a6e490 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -57,6 +57,25 @@ interface IndexInputEvent extends Omit { kind: 'index'; } +interface ClickOutputEvent { + kind: 'click'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + selector: string; + samplingRatio?: number; +} + +interface PageviewOutputEvent { + kind: 'pageview'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + samplingRatio?: number; +} + /** * The event processor doesn't need to know anything about the shape of the * diagnostic events. @@ -327,6 +346,27 @@ export default class EventProcessor implements LDEventProcessor { return out; } + case 'click': { + const out: ClickOutputEvent = { + kind: 'click', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + selector: event.selector, + }; + return out; + } + case 'pageview': { + const out: PageviewOutputEvent = { + kind: 'pageview', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + }; + return out; + } default: // This would happen during the addition of a new event type to the SDK. return event; diff --git a/packages/shared/common/src/internal/events/InputClickEvent.ts b/packages/shared/common/src/internal/events/InputClickEvent.ts new file mode 100644 index 000000000..a5812176d --- /dev/null +++ b/packages/shared/common/src/internal/events/InputClickEvent.ts @@ -0,0 +1,11 @@ +import Context from '../../Context'; + +export default interface InputClickEvent { + kind: 'click'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; + selector: string; +} diff --git a/packages/shared/common/src/internal/events/InputEvent.ts b/packages/shared/common/src/internal/events/InputEvent.ts index 2ffa15f51..9a1ab7e4c 100644 --- a/packages/shared/common/src/internal/events/InputEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvent.ts @@ -1,7 +1,15 @@ +import InputClickEvent from './InputClickEvent'; import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import InputPageViewEvent from './InputPageViewEvent'; -type InputEvent = InputEvalEvent | InputCustomEvent | InputIdentifyEvent | InputMigrationEvent; +type InputEvent = + | InputEvalEvent + | InputCustomEvent + | InputIdentifyEvent + | InputMigrationEvent + | InputClickEvent + | InputPageViewEvent; export default InputEvent; diff --git a/packages/shared/common/src/internal/events/InputPageViewEvent.ts b/packages/shared/common/src/internal/events/InputPageViewEvent.ts new file mode 100644 index 000000000..f01500742 --- /dev/null +++ b/packages/shared/common/src/internal/events/InputPageViewEvent.ts @@ -0,0 +1,10 @@ +import Context from '../../Context'; + +export default interface InputPageViewEvent { + kind: 'pageview'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 984eda484..1392db206 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -212,6 +212,10 @@ export default class LDClientImpl implements LDClient { return this.uncheckedContext ? clone(this.uncheckedContext) : undefined; } + protected getInternalContext(): Context | undefined { + return this.checkedContext; + } + private createStreamListeners( context: Context, identifyResolve: any, @@ -679,4 +683,8 @@ export default class LDClientImpl implements LDClient { this.eventProcessor?.close(); } } + + protected sendEvent(event: internal.InputEvent): void { + this.eventProcessor?.sendEvent(event); + } }