diff --git a/.changeset/eight-adults-type.md b/.changeset/eight-adults-type.md new file mode 100644 index 000000000..dc3607413 --- /dev/null +++ b/.changeset/eight-adults-type.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-signals': minor +--- + +Fix CSP errors with sandboxStrategy: global diff --git a/.changeset/empty-eagles-buy.md b/.changeset/empty-eagles-buy.md new file mode 100644 index 000000000..5945ef926 --- /dev/null +++ b/.changeset/empty-eagles-buy.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-signals': minor +--- + +Update max signals in buffer to 100 diff --git a/packages/signals/signals-example/public/index.html b/packages/signals/signals-example/public/index.html index aa72dbc9f..d34c48e56 100644 --- a/packages/signals/signals-example/public/index.html +++ b/packages/signals/signals-example/public/index.html @@ -5,10 +5,17 @@ React TypeScript App + + -
- \ No newline at end of file + diff --git a/packages/signals/signals-example/src/lib/analytics.ts b/packages/signals/signals-example/src/lib/analytics.ts index 821965552..bd591b41b 100644 --- a/packages/signals/signals-example/src/lib/analytics.ts +++ b/packages/signals/signals-example/src/lib/analytics.ts @@ -33,7 +33,7 @@ const isStage = process.env.STAGE === 'true' const signalsPlugin = new SignalsPlugin({ ...(isStage ? { apiHost: 'signals.segment.build/v1' } : {}), - // enableDebugLogging: true, + sandboxStrategy: 'global', // processSignal: processSignalExample, }) diff --git a/packages/signals/signals-integration-tests/package.json b/packages/signals/signals-integration-tests/package.json index fc53d72e5..17b92ea26 100644 --- a/packages/signals/signals-integration-tests/package.json +++ b/packages/signals/signals-integration-tests/package.json @@ -8,10 +8,11 @@ "scripts": { ".": "yarn run -T turbo run --filter=@internal/signals-integration-tests...", "build": "webpack", - "test:int": "playwright test", + "test:int": "playwright test && SKIP_BUILD=true yarn test:global-sandbox", "test:vanilla": "playwright test src/tests/signals-vanilla", "test:perf": "playwright test src/tests/performance", "test:custom": "playwright test src/tests/custom", + "test:global-sandbox": "SANDBOX_STRATEGY=global playwright test src/tests/signals-vanilla src/tests/custom", "watch": "webpack -w", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "concurrently": "yarn run -T concurrently", diff --git a/packages/signals/signals-integration-tests/playwright.global-setup.ts b/packages/signals/signals-integration-tests/playwright.global-setup.ts index 6a3fec2b1..2fb62b2f9 100644 --- a/packages/signals/signals-integration-tests/playwright.global-setup.ts +++ b/packages/signals/signals-integration-tests/playwright.global-setup.ts @@ -1,8 +1,13 @@ import type { FullConfig } from '@playwright/test' import { execSync } from 'child_process' +import { envConfig } from './src/helpers/env-config' export default function globalSetup(_cfg: FullConfig) { - console.log('Executing global setup...') - execSync('yarn build', { stdio: 'inherit' }) - console.log('Finished global setup.') + console.log(`Executing playwright.global-setup.ts...\n`) + console.log(`Using envConfig: ${JSON.stringify(envConfig, undefined, 2)}\n`) + if (process.env.SKIP_BUILD !== 'true') { + console.log(`Executing yarn build:\n`) + execSync('yarn build', { stdio: 'inherit' }) + } + console.log('Finished global setup. Should start running tests.\n') } diff --git a/packages/signals/signals-integration-tests/src/helpers/base-page-object.ts b/packages/signals/signals-integration-tests/src/helpers/base-page-object.ts index de5361aac..13960dec6 100644 --- a/packages/signals/signals-integration-tests/src/helpers/base-page-object.ts +++ b/packages/signals/signals-integration-tests/src/helpers/base-page-object.ts @@ -7,6 +7,7 @@ import { SignalAPIRequestBuffer, TrackingAPIRequestBuffer, } from './network-utils' +import { envConfig } from './env-config' export class BasePage { protected page!: Page @@ -63,6 +64,7 @@ export class BasePage { await this.page.goto(url, { waitUntil: 'domcontentloaded' }) if (!options.skipSignalsPluginInit) { void this.invokeAnalyticsLoad({ + sandboxStrategy: envConfig.SANDBOX_STRATEGY, flushInterval: 500, ...signalSettings, }) diff --git a/packages/signals/signals-integration-tests/src/helpers/env-config.ts b/packages/signals/signals-integration-tests/src/helpers/env-config.ts new file mode 100644 index 000000000..880918dc6 --- /dev/null +++ b/packages/signals/signals-integration-tests/src/helpers/env-config.ts @@ -0,0 +1,10 @@ +import { SignalsPluginSettingsConfig } from '@segment/analytics-signals' + +// This is for testing with the global sandbox strategy with an npm script, that executes processSignal in the global scope +// If we change this to be the default, this can be rejiggered +const SANDBOX_STRATEGY = (process.env.SANDBOX_STRATEGY ?? + 'iframe') as SignalsPluginSettingsConfig['sandboxStrategy'] + +export const envConfig = { + SANDBOX_STRATEGY, +} diff --git a/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-select.test.ts b/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-select.test.ts index 9851eb719..03b2ed2d7 100644 --- a/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-select.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-select.test.ts @@ -3,7 +3,7 @@ import { waitForCondition } from '../../helpers/playwright-utils' import { IndexPage } from './index-page' import type { SegmentEvent } from '@segment/analytics-next' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('Collecting signals whenever a user selects an item', async ({ page }) => { const indexPage = await new IndexPage().loadAndWait(page, basicEdgeFn, { diff --git a/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-textfield.test.ts b/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-textfield.test.ts index 827c8b9cf..b7199fbeb 100644 --- a/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-textfield.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/custom-elements/custom-textfield.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { waitForCondition } from '../../helpers/playwright-utils' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('Collecting signals whenever a user enters text input and focuses out', async ({ page, diff --git a/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts b/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts index a8ecb23e1..2d1a7e891 100644 --- a/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts @@ -16,7 +16,7 @@ declare global { const basicEdgeFn = ` // this is a process signal function - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { if (signal.type === 'interaction') { const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']' analytics.track(eventName, signal.data) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/all-segment-events.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/all-segment-events.test.ts index 53b45f360..58b3cafdf 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/all-segment-events.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/all-segment-events.test.ts @@ -37,7 +37,7 @@ const snapshot = ( test('Segment events', async ({ page }) => { const basicEdgeFn = ` // this is a process signal function - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { if (signal.type === 'interaction' && signal.data.eventType === 'click') { analytics.identify('john', { found: true }) analytics.group('foo', { hello: 'world' }) @@ -65,7 +65,7 @@ test('Should dispatch events from signals that occurred before analytics was ins page, }) => { const edgeFn = ` - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { if (signal.type === 'navigation' && signal.data.action === 'pageLoad') { analytics.page('dispatched from signals - navigation') } diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/basic.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/basic.test.ts index cbb0993ff..ff618682e 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/basic.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/basic.test.ts @@ -4,7 +4,7 @@ import { IndexPage } from './index-page' const basicEdgeFn = ` // this is a process signal function - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { if (signal.type === 'interaction') { const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']' analytics.track(eventName, signal.data) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/button-click-complex.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/button-click-complex.test.ts index 28eb61c32..864dec3b1 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/button-click-complex.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/button-click-complex.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` let indexPage: IndexPage test.beforeEach(async ({ page }) => { indexPage = await new IndexPage().loadAndWait(page, basicEdgeFn) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/change-input.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/change-input.test.ts index 94c0044c4..df297f8b6 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/change-input.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/change-input.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { waitForCondition } from '../../helpers/playwright-utils' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('Collecting signals whenever a user enters text input', async ({ page, diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/middleware.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/middleware.test.ts index 3799bc0b5..244a787d7 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/middleware.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/middleware.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` let indexPage: IndexPage diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-allow-list.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-allow-list.test.ts index 84323ac04..097813b6e 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-allow-list.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-allow-list.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('network signals allow and disallow list', async ({ page }) => { const indexPage = await new IndexPage().loadAndWait(page, basicEdgeFn, { diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-fetch.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-fetch.test.ts index 868b089d4..6d92d67bc 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-fetch.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-fetch.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { commonSignalData } from '../../helpers/fixtures' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test.describe('network signals - fetch', () => { let indexPage: IndexPage diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-xhr.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-xhr.test.ts index 7fb9b8ecd..13729c583 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-xhr.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/network-signals-xhr.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test.describe('network signals - XHR', () => { let indexPage: IndexPage diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/reset.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/reset.test.ts index bdeb9aa5f..5c42651c1 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/reset.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/reset.test.ts @@ -7,7 +7,7 @@ import { pTimeout } from '@segment/analytics-core' * If a signal is generated, the signal buffer should be reset * when the user clicks on the complex button. */ -const edgeFn = `const processSignal = (signal) => { +const edgeFn = `globalThis.processSignal = (signal) => { // create a custom signal to echo out the current signal buffer if (signal.type === 'userDefined') { analytics.track('current signal buffer', { signalBuffer: signals.signalBuffer }) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/runtime-constants.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/runtime-constants.test.ts index c0ea50242..644d18366 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/runtime-constants.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/runtime-constants.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' const basicEdgeFn = ` - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { // test that constants are properly injected if (typeof EventType !== 'object') { throw new Error('EventType is missing?') diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-find.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-find.test.ts index 5018db7f0..d9099f337 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-find.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-find.test.ts @@ -4,7 +4,7 @@ import { IndexPage } from './index-page' const indexPage = new IndexPage() test('should find the most recent signal', async ({ page }) => { - const basicEdgeFn = `const processSignal = (signal) => { + const basicEdgeFn = `globalThis.processSignal = (signal) => { if (signal.type === 'interaction' && signal.data.target.id === 'complex-button') { const mostRecentSignal = signals.find(signal, 'userDefined') if (mostRecentSignal.data.num === 2) { diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts index e0f7262c0..25f25d4b3 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts @@ -4,7 +4,7 @@ import { waitForCondition } from '../../helpers/playwright-utils' const indexPage = new IndexPage() -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('debug ingestion disabled and sample rate 0 -> will not send the signal', async ({ page, diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-redaction.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-redaction.test.ts index a03d82b42..88e09b54d 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-redaction.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-redaction.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { waitForCondition } from '../../helpers/playwright-utils' import { IndexPage } from './index-page' -const basicEdgeFn = `const processSignal = (signal) => {}` +const basicEdgeFn = `globalThis.processSignal = (signal) => {}` test('redaction enabled -> will XXX the value of text input', async ({ page, diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts index f956d3e37..02f22caea 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts @@ -4,7 +4,7 @@ import { IndexPage } from './index-page' const basicEdgeFn = ` // this is a process signal function - const processSignal = (signal) => { + globalThis.processSignal = (signal) => { if (signal.type === 'interaction') { analytics.track('hello', { myAnonId: signal.anonymousId, myTimestamp: signal.timestamp }) } diff --git a/packages/signals/signals/README.md b/packages/signals/signals/README.md index dec38da7c..92a8ba23a 100644 --- a/packages/signals/signals/README.md +++ b/packages/signals/signals/README.md @@ -86,6 +86,14 @@ signalsPlugin.addSignal({ someData: 'foo' }) } ``` +### Sandbox Strategies +If getting CSP errors, you can use the experimental 'global' sandbox strategy. + +```ts +new SignalsPlugin({ sandboxStrategy: 'global' }) +``` + + ### Debugging Debug mode **MUST** be enabled on the client to VIEW signals on segment.com. diff --git a/packages/signals/signals/src/core/buffer/index.ts b/packages/signals/signals/src/core/buffer/index.ts index a1967d355..8f85c8ad3 100644 --- a/packages/signals/signals/src/core/buffer/index.ts +++ b/packages/signals/signals/src/core/buffer/index.ts @@ -25,7 +25,7 @@ interface IDBPObjectStoreSignals 'readonly' | 'readwrite' | 'versionchange' > {} -const MAX_BUFFER_SIZE_DEFAULT = 50 +const MAX_BUFFER_SIZE_DEFAULT = 100 interface StoreSettings { maxBufferSize?: number diff --git a/packages/signals/signals/src/core/middleware/event-processor/index.ts b/packages/signals/signals/src/core/middleware/event-processor/index.ts index 33e93ae1b..f51f52c73 100644 --- a/packages/signals/signals/src/core/middleware/event-processor/index.ts +++ b/packages/signals/signals/src/core/middleware/event-processor/index.ts @@ -1,18 +1,55 @@ import { Signal } from '@segment/analytics-signals-runtime' +import { logger } from '../../../lib/logger' import { SignalBuffer } from '../../buffer' import { SignalsSubscriber, SignalsMiddlewareContext } from '../../emitter' import { SignalEventProcessor } from '../../processor/processor' -import { Sandbox, SandboxSettings } from '../../processor/sandbox' +import { + normalizeEdgeFunctionURL, + GlobalScopeSandbox, + WorkerSandbox, + IframeSandboxSettings, + SignalSandbox, + NoopSandbox, +} from '../../processor/sandbox' export class SignalsEventProcessorSubscriber implements SignalsSubscriber { processor!: SignalEventProcessor buffer!: SignalBuffer load(ctx: SignalsMiddlewareContext) { this.buffer = ctx.buffer - this.processor = new SignalEventProcessor( - ctx.analyticsInstance, - new Sandbox(new SandboxSettings(ctx.unstableGlobalSettings.sandbox)) + const sandboxSettings = ctx.unstableGlobalSettings.sandbox + const normalizedEdgeFunctionURL = normalizeEdgeFunctionURL( + sandboxSettings.functionHost, + sandboxSettings.edgeFnDownloadURL ) + + let sandbox: SignalSandbox + + if (!normalizedEdgeFunctionURL) { + console.warn( + `No processSignal function found. Have you written a processSignal function on app.segment.com?` + ) + logger.debug('Initializing sandbox: noop') + sandbox = new NoopSandbox() + } else if ( + sandboxSettings.sandboxStrategy === 'iframe' || + sandboxSettings.processSignal + ) { + logger.debug('Initializing sandbox: iframe') + sandbox = new WorkerSandbox( + new IframeSandboxSettings({ + processSignal: sandboxSettings.processSignal, + edgeFnDownloadURL: normalizedEdgeFunctionURL, + }) + ) + } else { + logger.debug('Initializing sandbox: global scope') + sandbox = new GlobalScopeSandbox({ + edgeFnDownloadURL: normalizedEdgeFunctionURL, + }) + } + + this.processor = new SignalEventProcessor(ctx.analyticsInstance, sandbox) } async process(signal: Signal) { return this.processor.process(signal, await this.buffer.getAll()) diff --git a/packages/signals/signals/src/core/processor/__tests__/sandbox-settings.test.ts b/packages/signals/signals/src/core/processor/__tests__/sandbox-settings.test.ts index b1912ec49..f5c5e9c02 100644 --- a/packages/signals/signals/src/core/processor/__tests__/sandbox-settings.test.ts +++ b/packages/signals/signals/src/core/processor/__tests__/sandbox-settings.test.ts @@ -1,9 +1,8 @@ -import { SandboxSettings, SandboxSettingsConfig } from '../sandbox' +import { IframeSandboxSettings, IframeSandboxSettingsConfig } from '../sandbox' -describe(SandboxSettings, () => { +describe(IframeSandboxSettings, () => { const edgeFnResponseBody = `function processSignal() { console.log('hello world') }` - const baseSettings: SandboxSettingsConfig = { - functionHost: undefined, + const baseSettings: IframeSandboxSettingsConfig = { processSignal: undefined, edgeFnDownloadURL: 'http://example.com/download', edgeFnFetchClient: jest.fn().mockReturnValue( @@ -13,23 +12,22 @@ describe(SandboxSettings, () => { ), } test('initializes with provided settings', async () => { - const sandboxSettings = new SandboxSettings({ ...baseSettings }) + const sandboxSettings = new IframeSandboxSettings({ ...baseSettings }) expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith( baseSettings.edgeFnDownloadURL ) expect(await sandboxSettings.processSignal).toEqual(edgeFnResponseBody) }) - test('normalizes edgeFnDownloadURL when functionHost is provided', async () => { - const settings: SandboxSettingsConfig = { + test('should call edgeFnDownloadURL', async () => { + const settings: IframeSandboxSettingsConfig = { ...baseSettings, processSignal: undefined, - functionHost: 'newHost.com', - edgeFnDownloadURL: 'https://original.com/download', + edgeFnDownloadURL: 'https://foo.com/download', } - new SandboxSettings(settings) + new IframeSandboxSettings(settings) expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith( - 'https://newHost.com/download' + 'https://foo.com/download' ) }) @@ -37,14 +35,14 @@ describe(SandboxSettings, () => { const consoleWarnSpy = jest .spyOn(console, 'warn') .mockImplementation(() => {}) - const settings: SandboxSettingsConfig = { + const settings: IframeSandboxSettingsConfig = { ...baseSettings, processSignal: undefined, edgeFnDownloadURL: undefined, } - const sandboxSettings = new SandboxSettings(settings) - expect(await sandboxSettings.processSignal).toEqual( - 'globalThis.processSignal = function processSignal() {}' + const sandboxSettings = new IframeSandboxSettings(settings) + expect(await sandboxSettings.processSignal).toMatchInlineSnapshot( + `"globalThis.processSignal = function() {}"` ) expect(baseSettings.edgeFnFetchClient).not.toHaveBeenCalled() expect(consoleWarnSpy).toHaveBeenCalledWith( diff --git a/packages/signals/signals/src/core/processor/processor.ts b/packages/signals/signals/src/core/processor/processor.ts index 5a9aee4a7..6f4d567f2 100644 --- a/packages/signals/signals/src/core/processor/processor.ts +++ b/packages/signals/signals/src/core/processor/processor.ts @@ -1,20 +1,20 @@ import { logger } from '../../lib/logger' import { Signal } from '@segment/analytics-signals-runtime' import { AnyAnalytics } from '../../types' -import { AnalyticsMethodCalls, MethodName, Sandbox } from './sandbox' +import { AnalyticsMethodCalls, MethodName, SignalSandbox } from './sandbox' export class SignalEventProcessor { - private sandbox: Sandbox - private analytics: AnyAnalytics - constructor(analytics: AnyAnalytics, sandbox: Sandbox) { + analytics: AnyAnalytics + sandbox: SignalSandbox + constructor(analytics: AnyAnalytics, sandbox: SignalSandbox) { this.analytics = analytics this.sandbox = sandbox } async process(signal: Signal, signals: Signal[]) { - let analyticsMethodCalls: AnalyticsMethodCalls + let analyticsMethodCalls: AnalyticsMethodCalls | undefined try { - analyticsMethodCalls = await this.sandbox.process(signal, signals) + analyticsMethodCalls = await this.sandbox.execute(signal, signals) } catch (err) { // in practice, we should never hit this error, but if we do, we should log it. console.error('Error processing signal', { signal, signals }, err) @@ -34,6 +34,6 @@ export class SignalEventProcessor { } cleanup() { - return this.sandbox.jsSandbox.destroy() + return this.sandbox.destroy() } } diff --git a/packages/signals/signals/src/core/processor/sandbox.ts b/packages/signals/signals/src/core/processor/sandbox.ts index e7f81032b..78c9b3e3f 100644 --- a/packages/signals/signals/src/core/processor/sandbox.ts +++ b/packages/signals/signals/src/core/processor/sandbox.ts @@ -1,11 +1,16 @@ import { logger } from '../../lib/logger' import { createWorkerBox, WorkerBoxAPI } from '../../lib/workerbox' import { resolvers } from './arg-resolvers' -import { AnalyticsRuntimePublicApi } from '../../types' +import { AnalyticsRuntimePublicApi, ProcessSignal } from '../../types' import { replaceBaseUrl } from '../../lib/replace-base-url' -import { Signal } from '@segment/analytics-signals-runtime' +import { + Signal, + WebRuntimeConstants, + WebSignalsRuntime, +} from '@segment/analytics-signals-runtime' import { getRuntimeCode } from '@segment/analytics-signals-runtime' import { polyfills } from './polyfills' +import { loadScript } from '../../lib/load-script' export type MethodName = | 'page' @@ -151,14 +156,36 @@ class JavascriptSandbox implements CodeSandbox { } } +export const normalizeEdgeFunctionURL = ( + functionHost: string | undefined, + edgeFnDownloadURL: string | undefined +) => { + if (functionHost && edgeFnDownloadURL) { + replaceBaseUrl(edgeFnDownloadURL, `https://${functionHost}`) + } else { + return edgeFnDownloadURL + } +} + export type SandboxSettingsConfig = { functionHost: string | undefined processSignal: string | undefined edgeFnDownloadURL: string | undefined edgeFnFetchClient?: typeof fetch + sandboxStrategy: 'iframe' | 'global' } -export class SandboxSettings { +export type IframeSandboxSettingsConfig = Pick< + SandboxSettingsConfig, + 'processSignal' | 'edgeFnFetchClient' | 'edgeFnDownloadURL' +> + +const consoleWarnProcessSignal = () => + console.warn( + 'processSignal is not defined - have you set up auto-instrumentation on app.segment.com?' + ) + +export class IframeSandboxSettings { /** * Should look like: * ```js @@ -168,48 +195,47 @@ export class SandboxSettings { * ``` */ processSignal: Promise - constructor(settings: SandboxSettingsConfig) { - const edgeFnDownloadURLNormalized = - settings.functionHost && settings.edgeFnDownloadURL - ? replaceBaseUrl( - settings.edgeFnDownloadURL, - `https://${settings.functionHost}` - ) - : settings.edgeFnDownloadURL - - if (!edgeFnDownloadURLNormalized && !settings.processSignal) { - // user may be onboarding and not have written a signal -- so do a noop so we can collect signals - this.processSignal = Promise.resolve( - `globalThis.processSignal = function processSignal() {}` + constructor(settings: IframeSandboxSettingsConfig) { + const fetch = settings.edgeFnFetchClient ?? globalThis.fetch + + let processSignalNormalized = Promise.resolve( + `globalThis.processSignal = function() {}` + ) + + if (settings.processSignal) { + processSignalNormalized = Promise.resolve(settings.processSignal).then( + (str) => `globalThis.processSignal = ${str}` ) - console.warn( - `No processSignal function found. Have you written a processSignal function on app.segment.com?` + } else if (settings.edgeFnDownloadURL) { + processSignalNormalized = fetch(settings.edgeFnDownloadURL!).then((res) => + res.text() ) - return + } else { + consoleWarnProcessSignal() } - const fetch = settings.edgeFnFetchClient ?? globalThis.fetch - - const processSignalNormalized = settings.processSignal - ? Promise.resolve(settings.processSignal).then( - (str) => `globalThis.processSignal = ${str}` - ) - : fetch(edgeFnDownloadURLNormalized!).then((res) => res.text()) - this.processSignal = processSignalNormalized } } -export class Sandbox { - settings: SandboxSettings +export interface SignalSandbox { + execute( + signal: Signal, + signals: Signal[] + ): Promise + destroy(): void | Promise +} + +export class WorkerSandbox implements SignalSandbox { + settings: IframeSandboxSettings jsSandbox: CodeSandbox - constructor(settings: SandboxSettings) { + constructor(settings: IframeSandboxSettings) { this.settings = settings this.jsSandbox = new JavascriptSandbox() } - async process( + async execute( signal: Signal, signals: Signal[] ): Promise { @@ -232,4 +258,88 @@ export class Sandbox { const calls = analytics.getCalls() return calls } + destroy(): void { + void this.jsSandbox.destroy() + } +} + +// ProcessSignal unfortunately uses globals. This should change. +// For now, we are setting up the globals between each invocation +const processWithGlobalScopeExecutionEnv = ( + signal: Signal, + signalBuffer: Signal[] +): AnalyticsMethodCalls | undefined => { + const g = globalThis as any + const processSignal: ProcessSignal = g['processSignal'] + + if (typeof processSignal == 'undefined') { + consoleWarnProcessSignal() + return undefined + } + + // Load all constants into the global scope + Object.entries(WebRuntimeConstants).forEach(([key, value]) => { + g[key] = value + }) + + // processSignal expects a global called `signals` -- of course, there can local variable naming conflict on the client, which is why globals were a bad idea. + const analytics = new AnalyticsRuntime() + const signals = new WebSignalsRuntime(signalBuffer) + + const originalAnalytics = g.analytics + if (originalAnalytics instanceof AnalyticsRuntime) { + throw new Error( + 'Invariant: analytics variable was not properly restored on the previous execution. This indicates a concurrency bug' + ) + } + const originalSignals = g.signals + + try { + g['analytics'] = analytics + g['signals'] = signals + processSignal(signal, { + // we eventually want to get rid of globals and processSignal just uses local variables. + // TODO: update processSignal generator to accept params like these for web (mobile currently uses globals for their architecture -- can be changed but hard). + analytics: analytics, + signals: signals, + // constants + EventType: WebRuntimeConstants.EventType, + NavigationAction: WebRuntimeConstants.NavigationAction, + SignalType: WebRuntimeConstants.SignalType, + }) + } finally { + // restore globals + g['analytics'] = originalAnalytics + g['signals'] = originalSignals + } + + return analytics.getCalls() +} + +/** + * Sandbox that avoids CSP errors, but evaluates everything globally + */ +interface GlobalScopeSandboxSettings { + edgeFnDownloadURL: string +} +export class GlobalScopeSandbox implements SignalSandbox { + htmlScriptLoaded: Promise + + constructor(settings: GlobalScopeSandboxSettings) { + logger.debug('Initializing global scope sandbox') + this.htmlScriptLoaded = loadScript(settings.edgeFnDownloadURL) + } + + async execute(signal: Signal, signals: Signal[]) { + await this.htmlScriptLoaded + return processWithGlobalScopeExecutionEnv(signal, signals) + } + destroy(): void {} +} + +export class NoopSandbox implements SignalSandbox { + execute(_signal: Signal, _signals: Signal[]) { + return Promise.resolve(undefined) + } + destroy(): void {} } diff --git a/packages/signals/signals/src/core/signals/settings.ts b/packages/signals/signals/src/core/signals/settings.ts index a77afdae7..ff30ef0a0 100644 --- a/packages/signals/signals/src/core/signals/settings.ts +++ b/packages/signals/signals/src/core/signals/settings.ts @@ -28,6 +28,7 @@ export type SignalsSettingsConfig = Pick< | 'mutationGenPollInterval' | 'mutationGenObservedAttributes' | 'debug' + | 'sandboxStrategy' > & { signalStorage?: SignalPersistentStorage processSignal?: string @@ -89,6 +90,7 @@ export class SignalGlobalSettings { }, } this.sandbox = { + sandboxStrategy: settings.sandboxStrategy ?? 'iframe', functionHost: settings.functionHost, processSignal: settings.processSignal, edgeFnDownloadURL: undefined, diff --git a/packages/signals/signals/src/lib/load-script/index.ts b/packages/signals/signals/src/lib/load-script/index.ts new file mode 100644 index 000000000..118640622 --- /dev/null +++ b/packages/signals/signals/src/lib/load-script/index.ts @@ -0,0 +1,66 @@ +function findScript(src: string): HTMLScriptElement | undefined { + const scripts = Array.prototype.slice.call( + window.document.querySelectorAll('script') + ) + return scripts.find((s) => s.src === src) +} + +/** + * Load a script from a URL and append it to the document head + */ +export function loadScript( + src: string, + attributes?: Record +): Promise { + const found = findScript(src) + + if (found !== undefined) { + const status = found?.getAttribute('status') + + if (status === 'loaded') { + return Promise.resolve(found) + } + + if (status === 'loading') { + return new Promise((resolve, reject) => { + found.addEventListener('load', () => resolve(found)) + found.addEventListener('error', (err) => reject(err)) + }) + } + } + + return new Promise((resolve, reject) => { + const script = window.document.createElement('script') + + script.type = 'text/javascript' + script.src = src + script.async = true + + script.setAttribute('status', 'loading') + for (const [k, v] of Object.entries(attributes ?? {})) { + script.setAttribute(k, v) + } + + script.onload = (): void => { + script.onerror = script.onload = null + script.setAttribute('status', 'loaded') + resolve(script) + } + + script.onerror = (): void => { + script.onerror = script.onload = null + script.setAttribute('status', 'error') + reject(new Error(`Failed to load ${src}`)) + } + + const firstExistingScript = window.document.querySelector('script') + if (!firstExistingScript) { + window.document.head.appendChild(script) + } else { + firstExistingScript.parentElement?.insertBefore( + script, + firstExistingScript + ) + } + }) +} diff --git a/packages/signals/signals/src/plugin/signals-plugin.ts b/packages/signals/signals/src/plugin/signals-plugin.ts index 6d5123300..c4dadec03 100644 --- a/packages/signals/signals/src/plugin/signals-plugin.ts +++ b/packages/signals/signals/src/plugin/signals-plugin.ts @@ -34,24 +34,11 @@ export class SignalsPlugin implements Plugin, SignalsAugmentedFunctionality { Object.assign(window, { SegmentSignalsPlugin: this }) this.signals = new Signals({ - debug: settings.debug, - disableSignalsRedaction: settings.disableSignalsRedaction, - enableSignalsIngestion: settings.enableSignalsIngestion, - flushAt: settings.flushAt, - flushInterval: settings.flushInterval, - functionHost: settings.functionHost, - apiHost: settings.apiHost, - maxBufferSize: settings.maxBufferSize, + ...settings, processSignal: typeof settings.processSignal === 'function' ? settings.processSignal.toString() : settings.processSignal, - networkSignalsAllowSameDomain: settings.networkSignalsAllowSameDomain, - networkSignalsAllowList: settings.networkSignalsAllowList, - networkSignalsDisallowList: settings.networkSignalsDisallowList, - signalStorage: settings.signalStorage, - signalStorageType: settings.signalStorageType, - middleware: settings.middleware, }) logger.debug(`SignalsPlugin v${version} initializing`, { diff --git a/packages/signals/signals/src/types/settings.ts b/packages/signals/signals/src/types/settings.ts index 7c27fc198..6d62f348c 100644 --- a/packages/signals/signals/src/types/settings.ts +++ b/packages/signals/signals/src/types/settings.ts @@ -144,6 +144,14 @@ export interface SignalsPluginSettingsConfig { * (defaultAttributes) => defaultAttributes.filter(attr => attr.toLowerCase() !== 'aria-selected') */ mutationGenObservedAttributes?: (defaultAttributes: string[]) => string[] + + /** + * What sandbox strategy to use + * - global - [EXPERIMENTAL] evaluate everything in the global scope -- use this if you want to avoid CSP errors. + * - iframe - use a web worker and regular evaluation + * @default 'iframe' + */ + sandboxStrategy?: 'iframe' | 'global' } export type RegexLike = RegExp | string