diff --git a/.changeset/heavy-taxis-suffer.md b/.changeset/heavy-taxis-suffer.md new file mode 100644 index 000000000..a4dd5c15c --- /dev/null +++ b/.changeset/heavy-taxis-suffer.md @@ -0,0 +1,25 @@ +--- +'@segment/analytics-next': minor +--- +- Make Segment.io config type-safe +- Add new `headers` setting, along with `priority`. + +```ts +analytics.load("", + { + integrations: { + 'Segment.io': { + deliveryStrategy: { + strategy: "standard" // also works for 'batching' + config: { + headers: { 'x-api-key': 'foo' } or () => {...} + priority: 'low', + }, + }, + }, + }, + } +) +``` + + diff --git a/.changeset/nasty-peaches-watch.md b/.changeset/nasty-peaches-watch.md new file mode 100644 index 000000000..8911d1ac9 --- /dev/null +++ b/.changeset/nasty-peaches-watch.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-consent-tools': patch +'@segment/analytics-signals': patch +--- + +Update types diff --git a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts index 325e275a3..2a426e2d3 100644 --- a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts @@ -1,5 +1,6 @@ import { CorePlugin, PluginType, sleep } from '@segment/analytics-core' import { + cdnSettingsMinimal, createMockFetchImplementation, createRemotePlugin, getBufferedPageCtxFixture, @@ -92,7 +93,9 @@ describe('Lazy destination loading', () => { beforeEach(() => { jest.mocked(unfetch).mockImplementation( createMockFetchImplementation({ + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, braze: {}, google: {}, }, diff --git a/packages/browser/src/browser/__tests__/anon-id-and-reset.integration.test.ts b/packages/browser/src/browser/__tests__/anon-id-and-reset.integration.test.ts index 4fa9cc63f..1fcdc33b9 100644 --- a/packages/browser/src/browser/__tests__/anon-id-and-reset.integration.test.ts +++ b/packages/browser/src/browser/__tests__/anon-id-and-reset.integration.test.ts @@ -14,7 +14,14 @@ const helpers = { .mockImplementation(() => createSuccess({ integrations: {} })) }, loadAnalytics() { - return AnalyticsBrowser.load({ writeKey: 'foo' }) + return AnalyticsBrowser.load( + { writeKey: 'foo' }, + { + integrations: { + 'Segment.io': {}, + }, + } + ) }, } diff --git a/packages/browser/src/browser/__tests__/csp-detection.test.ts b/packages/browser/src/browser/__tests__/csp-detection.test.ts index 78eb0a8ae..c3efa8bcc 100644 --- a/packages/browser/src/browser/__tests__/csp-detection.test.ts +++ b/packages/browser/src/browser/__tests__/csp-detection.test.ts @@ -4,9 +4,12 @@ import { CDNSettings } from '..' import { pWhile } from '../../lib/p-while' import { snippet } from '../../tester/__fixtures__/segment-snippet' import * as Factory from '../../test-helpers/factories' +import { cdnSettingsMinimal } from '../../test-helpers/fixtures' const cdnResponse: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Zapier: { type: 'server', }, diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index 9d99855d1..222573a65 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -11,7 +11,7 @@ import { Analytics, InitOptions } from '../../core/analytics' import { LegacyDestination } from '../../plugins/ajs-destination' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' // @ts-ignore loadCDNSettings mocked dependency is accused as unused -import { AnalyticsBrowser, loadCDNSettings } from '..' +import { AnalyticsBrowser, CDNSettings, loadCDNSettings } from '..' // @ts-ignore isOffline mocked dependency is accused as unused import { isOffline } from '../../core/connection' import * as SegmentPlugin from '../../plugins/segmentio' @@ -376,7 +376,7 @@ describe('Initialization', () => { it('does not fetch source settings if cdnSettings is set', async () => { await AnalyticsBrowser.load({ writeKey, - cdnSettings: { integrations: {} }, + cdnSettings: cdnSettingsMinimal, }) expect(fetchCalls.length).toBe(0) @@ -666,8 +666,10 @@ describe('Dispatch', () => { { writeKey, cdnSettings: { + ...cdnSettingsMinimal, integrations: { 'Segment.io': { + ...cdnSettingsMinimal.integrations['Segment.io'], apiHost: 'cdnSettings.api.io', }, }, @@ -1334,6 +1336,7 @@ describe('Segment.io overrides', () => { integrations: { 'Segment.io': { apiHost: 'https://my.endpoint.com', + // @ts-ignore anotherSettings: '👻', }, }, @@ -1529,13 +1532,47 @@ describe('Options', () => { const disableSpy = jest.fn().mockReturnValue(true) const [analytics] = await AnalyticsBrowser.load( { - cdnSettings: { integrations: {}, foo: 123 }, + cdnSettings: cdnSettingsMinimal, writeKey, }, { disable: disableSpy } ) expect(analytics).toBeInstanceOf(NullAnalytics) - expect(disableSpy).toBeCalledWith({ integrations: {}, foo: 123 }) + expect(disableSpy).toHaveBeenCalledWith(cdnSettingsMinimal) + }) + }) +}) + +describe('setting headers', () => { + it('allows setting headers', async () => { + const [ajs] = await AnalyticsBrowser.load( + { + writeKey, + }, + { + integrations: { + 'Segment.io': { + deliveryStrategy: { + config: { + headers: { + 'X-Test': 'foo', + }, + }, + }, + }, + }, + } + ) + + await ajs.track('sup') + + await sleep(10) + const [call] = fetchCalls.filter((el) => + el.url.toString().includes('api.segment.io') + ) + expect(call.headers).toEqual({ + 'Content-Type': 'text/plain', + 'X-Test': 'foo', }) }) }) diff --git a/packages/browser/src/browser/__tests__/standalone-errors.test.ts b/packages/browser/src/browser/__tests__/standalone-errors.test.ts index ef2aac38a..1ac45f349 100644 --- a/packages/browser/src/browser/__tests__/standalone-errors.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-errors.test.ts @@ -5,9 +5,12 @@ import { pWhile } from '../../lib/p-while' import unfetch from 'unfetch' import { RemoteMetrics } from '../../core/stats/remote-metrics' import * as Factory from '../../test-helpers/factories' +import { cdnSettingsMinimal } from '../../test-helpers/fixtures' const cdnResponse: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Zapier: { type: 'server', }, diff --git a/packages/browser/src/browser/__tests__/standalone.test.ts b/packages/browser/src/browser/__tests__/standalone.test.ts index bc58d8803..f5fed0fa1 100644 --- a/packages/browser/src/browser/__tests__/standalone.test.ts +++ b/packages/browser/src/browser/__tests__/standalone.test.ts @@ -5,9 +5,12 @@ import { pWhile } from '../../lib/p-while' import { snippet } from '../../tester/__fixtures__/segment-snippet' import * as Factory from '../../test-helpers/factories' import { getGlobalAnalytics } from '../..' +import { cdnSettingsMinimal } from '../../test-helpers/fixtures' const cdnResponse: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Zapier: { type: 'server', }, diff --git a/packages/browser/src/browser/__tests__/update-cdn-settings.test.ts b/packages/browser/src/browser/__tests__/update-cdn-settings.test.ts index 44493e1b1..93e8ff298 100644 --- a/packages/browser/src/browser/__tests__/update-cdn-settings.test.ts +++ b/packages/browser/src/browser/__tests__/update-cdn-settings.test.ts @@ -3,12 +3,15 @@ import { setGlobalCDNUrl } from '../../lib/parse-cdn' import { remoteLoader } from '../../plugins/remote-loader' import unfetch from 'unfetch' import { createSuccess } from '../../test-helpers/factories' +import { cdnSettingsMinimal } from '../../test-helpers/fixtures' jest.mock('unfetch') const INTG_TO_DELETE = 'deleteMe' const cdnSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, [INTG_TO_DELETE]: { bar: true }, otherIntegration: { foo: true }, }, diff --git a/packages/browser/src/browser/settings.ts b/packages/browser/src/browser/settings.ts index 1947e48f6..0c92bed9a 100644 --- a/packages/browser/src/browser/settings.ts +++ b/packages/browser/src/browser/settings.ts @@ -1,7 +1,7 @@ /** * These settings will be exposed via the public API */ -import { IntegrationsOptions, Plan } from '../core/events' +import { Plan } from '../core/events' import { MetricsOptions } from '../core/stats/remote-metrics' import { ClassicIntegrationSource } from '../plugins/ajs-destination/types' import { PluginFactory, RemotePlugin } from '../plugins/remote-loader' @@ -10,17 +10,20 @@ import { RoutingRule } from '../plugins/routing-middleware' import { CookieOptions, StorageSettings } from '../core/storage' import { UserOptions } from '../core/user' import { HighEntropyHint } from '../lib/client-hints/interfaces' +import { IntegrationsOptions } from '@segment/analytics-core' +import { SegmentioSettings } from '../plugins/segmentio' + +interface VersionSettings { + version?: string + override?: string + componentTypes?: ('browser' | 'android' | 'ios' | 'server')[] +} export interface RemoteIntegrationSettings { /* @deprecated - This does not indicate browser types anymore */ type?: string - versionSettings?: { - version?: string - override?: string - componentTypes?: ('browser' | 'android' | 'ios' | 'server')[] - } - + versionSettings?: VersionSettings /** * We know if an integration is device mode if it has `bundlingStatus: 'bundled'` and the `browser` componentType in `versionSettings`. * History: The term 'bundle' is left over from before action destinations, when a device mode destinations were 'bundled' in a custom bundle for every analytics.js source. @@ -38,20 +41,48 @@ export interface RemoteIntegrationSettings { categories: string[] } - // Segment.io specific - retryQueue?: boolean - // any extra unknown settings // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any } +export interface RemoteSegmentIOIntegrationSettings + extends RemoteIntegrationSettings { + /** + * Segment write key + */ + apiKey: string + + /** + * Whether or not offline events are stored and retried. + * + * Originally, each plugin was concieved to use global retry logic (e.g. throwing magic errors would result in retries), + * but this was not really used and is no longer encouraged. Instead, we favor of per-plugin retry logic, since it's confusing to have multiple levels of retries, and most device mode destinations contain their own retry behavior. + * The segmentio plugin itself has its own internal retry queue and is not affected by this setting. + * + * @default true + */ + retryQueue?: boolean + + /** + * Host of the segment cdn - this may need to be manually enabled on the source. + * @default 'api.segment.io/v1' + */ + apiHost?: string + addBundledMetadata?: boolean + unbundledIntegrations?: string[] + bundledConfigIds?: string[] + unbundledConfigIds?: string[] + maybeBundledConfigIds?: Record +} + /** * The remote settings object for a source, typically fetched from the Segment CDN. * Warning: this is an *unstable* object. */ export interface CDNSettings { integrations: { + 'Segment.io': RemoteSegmentIOIntegrationSettings [creationName: string]: RemoteIntegrationSettings } @@ -108,6 +139,11 @@ export interface CDNSettings { autoInstrumentationSettings?: { sampleRate: number } + + /** + * Allow misc settings to be passed through, but + */ + [key: string]: unknown } /** @@ -121,7 +157,7 @@ export interface AnalyticsBrowserSettings { * If provided, `AnalyticsBrowser` will not fetch remote settings * for the source. */ - cdnSettings?: CDNSettings & Record + cdnSettings?: CDNSettings /** * If provided, will override the default Segment CDN (https://cdn.segment.com) for this application. */ @@ -145,6 +181,26 @@ export interface AnalyticsSettings { cdnURL?: string } +/** + * Public Segment.io integration options. + * We don't expose all the settings for Segment.io, only the ones that are overridable + * (For example, we don't want `maybeBundledConfigIds to be exposed to the public API.) + */ +export type SegmentioIntegrationInitOptions = Pick< + SegmentioSettings, + 'apiHost' | 'protocol' | 'deliveryStrategy' +> + +/** + * Configurable Integrations Options -- these are the settings that are passed to the `analytics` instance. + */ +export interface IntegrationsInitOptions extends IntegrationsOptions { + /** + * Segment.io integration options -- note: Segment.io is not overridable OR disableable + */ + 'Segment.io'?: SegmentioIntegrationInitOptions | boolean +} + export interface InitOptions { /** * Disables storing any data on the client-side via cookies or localstorage. @@ -164,7 +220,7 @@ export interface InitOptions { storage?: StorageSettings user?: UserOptions group?: UserOptions - integrations?: IntegrationsOptions + integrations?: IntegrationsInitOptions plan?: Plan retryQueue?: boolean obfuscate?: boolean diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 8ac5461d6..5a9034578 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -18,7 +18,6 @@ import { Emitter } from '@segment/analytics-generic-utils' import { Callback, EventFactory, - IntegrationsOptions, EventProperties, SegmentEvent, } from '../events' @@ -52,7 +51,11 @@ import { isSegmentPlugin, SegmentIOPluginMetadata, } from '../../plugins/segmentio' -import { AnalyticsSettings, InitOptions } from '../../browser/settings' +import { + AnalyticsSettings, + IntegrationsInitOptions, + InitOptions, +} from '../../browser/settings' export type { InitOptions, AnalyticsSettings } @@ -99,10 +102,17 @@ export class AnalyticsInstanceSettings { this._getSegmentPluginMetadata = () => queue.plugins.find(isSegmentPlugin)?.metadata this.writeKey = settings.writeKey - this.cdnSettings = settings.cdnSettings ?? { - integrations: {}, - edgeFunction: {}, + + // this is basically just to satisfy typescript / so we don't need to change the function sig of every test. + // when loadAnalytics is called, cdnSettings will always be available. + const emptyCDNSettings: CDNSettings = { + integrations: { + 'Segment.io': { + apiKey: '', + }, + }, } + this.cdnSettings = settings.cdnSettings ?? emptyCDNSettings this.cdnURL = settings.cdnURL } } @@ -124,7 +134,7 @@ export class Analytics private _universalStorage: UniversalStorage initialized = false - integrations: IntegrationsOptions + integrations: IntegrationsInitOptions options: InitOptions queue: EventQueue diff --git a/packages/browser/src/core/events/index.ts b/packages/browser/src/core/events/index.ts index bf342524f..a3ea256e0 100644 --- a/packages/browser/src/core/events/index.ts +++ b/packages/browser/src/core/events/index.ts @@ -1,14 +1,8 @@ import { v4 as uuid } from '@lukeed/uuid' import { ID, User } from '../user' -import { - Options, - IntegrationsOptions, - EventProperties, - Traits, - SegmentEvent, -} from './interfaces' +import { Options, EventProperties, Traits, SegmentEvent } from './interfaces' import { addPageContext, PageContext } from '../page' -import { CoreEventFactory } from '@segment/analytics-core' +import { CoreEventFactory, IntegrationsOptions } from '@segment/analytics-core' export * from './interfaces' @@ -53,10 +47,10 @@ export class EventFactory extends CoreEventFactory { event: string, properties?: EventProperties, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { - const ev = super.track(event, properties, options, globalIntegrations) + const ev = super.track(event, properties, options, integrationsOptions) addPageContext(ev, pageCtx) return ev } @@ -66,7 +60,7 @@ export class EventFactory extends CoreEventFactory { page: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { const ev = super.page( @@ -74,7 +68,7 @@ export class EventFactory extends CoreEventFactory { page, properties, options, - globalIntegrations + integrationsOptions ) addPageContext(ev, pageCtx) return ev @@ -85,7 +79,7 @@ export class EventFactory extends CoreEventFactory { screen: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { const ev = super.screen( @@ -93,7 +87,7 @@ export class EventFactory extends CoreEventFactory { screen, properties, options, - globalIntegrations + integrationsOptions ) addPageContext(ev, pageCtx) return ev @@ -103,10 +97,10 @@ export class EventFactory extends CoreEventFactory { userId: ID, traits?: Traits, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { - const ev = super.identify(userId, traits, options, globalIntegrations) + const ev = super.identify(userId, traits, options, integrationsOptions) addPageContext(ev, pageCtx) return ev } @@ -115,10 +109,10 @@ export class EventFactory extends CoreEventFactory { groupId: ID, traits?: Traits, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { - const ev = super.group(groupId, traits, options, globalIntegrations) + const ev = super.group(groupId, traits, options, integrationsOptions) addPageContext(ev, pageCtx) return ev } @@ -127,10 +121,10 @@ export class EventFactory extends CoreEventFactory { to: string, from: string | null, options?: Options, - globalIntegrations?: IntegrationsOptions, + integrationsOptions?: IntegrationsOptions, pageCtx?: PageContext ): SegmentEvent { - const ev = super.alias(to, from, options, globalIntegrations) + const ev = super.alias(to, from, options, integrationsOptions) addPageContext(ev, pageCtx) return ev } diff --git a/packages/browser/src/core/events/interfaces.ts b/packages/browser/src/core/events/interfaces.ts index 401406cb7..ef87d9e76 100644 --- a/packages/browser/src/core/events/interfaces.ts +++ b/packages/browser/src/core/events/interfaces.ts @@ -2,7 +2,6 @@ import { CoreOptions, CoreSegmentEvent, Callback, - IntegrationsOptions, Plan, TrackPlan, PlanEvent, @@ -24,7 +23,6 @@ export type EventProperties = Record export interface SegmentEvent extends CoreSegmentEvent {} export type { - IntegrationsOptions, Plan, TrackPlan, PlanEvent, diff --git a/packages/browser/src/lib/__tests__/merged-options.test.ts b/packages/browser/src/lib/__tests__/merged-options.test.ts index 6f1a96f0d..f89bd8dd8 100644 --- a/packages/browser/src/lib/__tests__/merged-options.test.ts +++ b/packages/browser/src/lib/__tests__/merged-options.test.ts @@ -1,10 +1,13 @@ +import { cdnSettingsMinimal } from '../../test-helpers/fixtures' import { mergedOptions } from '../merged-options' describe(mergedOptions, () => { it('merges options', () => { const merged = mergedOptions( { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, CustomerIO: {}, Amplitude: { apiKey: '🍌', @@ -28,6 +31,10 @@ describe(mergedOptions, () => { "CustomerIO": { "ghost": "👻", }, + "Fake": {}, + "Segment.io": { + "apiKey": "my-writekey", + }, } `) }) @@ -35,7 +42,9 @@ describe(mergedOptions, () => { it('ignores options for integrations that arent returned by CDN', () => { const merged = mergedOptions( { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Amplitude: { apiKey: '🍌', }, @@ -56,6 +65,10 @@ describe(mergedOptions, () => { "Amplitude": { "apiKey": "🍌", }, + "Fake": {}, + "Segment.io": { + "apiKey": "my-writekey", + }, } `) }) @@ -63,7 +76,9 @@ describe(mergedOptions, () => { it('does not attempt to merge non objects', () => { const merged = mergedOptions( { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, CustomerIO: { ghost: '👻', }, @@ -88,14 +103,20 @@ describe(mergedOptions, () => { "CustomerIO": { "ghost": "👻", }, + "Fake": {}, + "Segment.io": { + "apiKey": "my-writekey", + }, } `) }) it('works with boolean overrides', () => { const cdn = { + ...cdnSettingsMinimal, integrations: { - 'Segment.io': { apiHost: 'api.segment.io' }, + ...cdnSettingsMinimal.integrations, + 'Segment.io': { apiHost: 'api.segment.io', apiKey: 'foo' }, 'Google Tag Manager': { ghost: '👻', }, @@ -111,11 +132,13 @@ describe(mergedOptions, () => { expect(mergedOptions(cdn, overrides)).toMatchInlineSnapshot(` { + "Fake": {}, "Google Tag Manager": { "ghost": "👻", }, "Segment.io": { "apiHost": "mgs.instacart.com/v2", + "apiKey": "foo", }, } `) diff --git a/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts b/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts index ae144d2a8..6affc6670 100644 --- a/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts +++ b/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts @@ -10,9 +10,12 @@ import { tsubMiddleware } from '../../routing-middleware' import { AMPLITUDE_WRITEKEY } from '../../../test-helpers/test-writekeys' import { PersistedPriorityQueue } from '../../../lib/priority-queue/persisted' import * as Factory from '../../../test-helpers/factories' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' const cdnResponse: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Zapier: { type: 'server', }, @@ -114,7 +117,9 @@ describe('loading ajsDestinations', () => { const destinations = ajsDestinations( writeKey, { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, 'Some server destination': { versionSettings: { componentTypes: ['server'], @@ -145,7 +150,9 @@ describe('loading ajsDestinations', () => { const destinations = ajsDestinations( writeKey, { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, 'Some server destination': { versionSettings: { componentTypes: ['server'], @@ -182,7 +189,9 @@ describe('loading ajsDestinations', () => { const destinations = ajsDestinations( writeKey, { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, 'Some server destination': { type: 'server', bundlingStatus: 'bundled', // this combination will never happen @@ -207,7 +216,9 @@ describe('loading ajsDestinations', () => { const destinations = ajsDestinations( writeKey, { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, 'Some server destination': { type: 'server', bundlingStatus: 'bundled', // this combination will never happen @@ -781,7 +792,9 @@ describe('option overrides', () => { it('accepts settings overrides from options', async () => { const cdnSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, Amplitude: { type: 'browser', apiKey: '123', diff --git a/packages/browser/src/plugins/ajs-destination/index.ts b/packages/browser/src/plugins/ajs-destination/index.ts index 1fc02a5df..b8340a11e 100644 --- a/packages/browser/src/plugins/ajs-destination/index.ts +++ b/packages/browser/src/plugins/ajs-destination/index.ts @@ -1,4 +1,4 @@ -import { IntegrationsOptions, JSONObject } from '../../core/events' +import { JSONObject } from '../../core/events' import { Alias, Facade, Group, Identify, Page, Track } from '@segment/facade' import { Analytics, InitOptions } from '../../core/analytics' import { CDNSettings } from '../../browser' @@ -31,6 +31,7 @@ import { } from './utils' import { recordIntegrationMetric } from '../../core/stats/metric-helpers' import { createDeferred } from '@segment/analytics-generic-utils' +import { IntegrationsInitOptions } from '../../browser/settings' export type ClassType = new (...args: unknown[]) => T @@ -332,7 +333,7 @@ export class LegacyDestination implements InternalPluginWithAddMiddleware { export function ajsDestinations( writeKey: string, settings: CDNSettings, - globalIntegrations: IntegrationsOptions = {}, + integrations: IntegrationsInitOptions = {}, options: InitOptions = {}, routingMiddleware?: DestinationMiddlewareFunction, legacyIntegrationSources?: ClassicIntegrationSource[] @@ -378,7 +379,7 @@ export function ajsDestinations( ]) return Array.from(installableIntegrations) - .filter((name) => !shouldSkipIntegration(name, globalIntegrations)) + .filter((name) => !shouldSkipIntegration(name, integrations)) .map((name) => { const integrationSettings = remoteIntegrationsConfig[name] const version = resolveVersion(integrationSettings) diff --git a/packages/browser/src/plugins/ajs-destination/utils.ts b/packages/browser/src/plugins/ajs-destination/utils.ts index e448c3852..ab8eccaee 100644 --- a/packages/browser/src/plugins/ajs-destination/utils.ts +++ b/packages/browser/src/plugins/ajs-destination/utils.ts @@ -1,5 +1,5 @@ -import { IntegrationsOptions } from '@segment/analytics-core' import { RemoteIntegrationSettings } from '../..' +import { IntegrationsInitOptions } from '../../browser/settings' export const isInstallableIntegration = ( name: string, @@ -20,13 +20,10 @@ export const isInstallableIntegration = ( export const isDisabledIntegration = ( integrationName: string, - globalIntegrations: IntegrationsOptions + integrations: IntegrationsInitOptions ) => { const allDisableAndNotDefined = - globalIntegrations.All === false && - globalIntegrations[integrationName] === undefined + integrations.All === false && integrations[integrationName] === undefined - return ( - globalIntegrations[integrationName] === false || allDisableAndNotDefined - ) + return integrations[integrationName] === false || allDisableAndNotDefined } diff --git a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts index b65ed31a4..0a8aa9710 100644 --- a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts +++ b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts @@ -6,6 +6,7 @@ import { AnalyticsBrowser, CDNSettings } from '../../../browser' import { InitOptions } from '../../../core/analytics' import { Context } from '../../../core/context' import { tsubMiddleware } from '../../routing-middleware' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' const pluginFactory = jest.fn() @@ -26,7 +27,7 @@ describe('Remote Loader', () => { it('should attempt to load a script from the url of each remotePlugin', async () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -47,7 +48,7 @@ describe('Remote Loader', () => { it('should attempt to load a script from the obfuscated url of each remotePlugin', async () => { await remoteLoader( { - integrations: {}, + integrations: cdnSettingsMinimal.integrations, remotePlugins: [ { name: 'remote plugin', @@ -73,7 +74,7 @@ describe('Remote Loader', () => { window.analytics._cdn = 'foo.com' await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -98,7 +99,7 @@ describe('Remote Loader', () => { window.analytics._cdn = 'foo.com' await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -119,7 +120,7 @@ describe('Remote Loader', () => { it('should attempt calling the library', async () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -150,7 +151,7 @@ describe('Remote Loader', () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'Braze Web Mode (Actions)', @@ -201,7 +202,7 @@ describe('Remote Loader', () => { it('should not load remote plugins when integrations object contains all: false', async () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -224,7 +225,7 @@ describe('Remote Loader', () => { it('should load remote plugins when integrations object contains all: false but plugin: true', async () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -252,7 +253,7 @@ describe('Remote Loader', () => { it('should load remote plugin when integrations object contains plugin: false', async () => { await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -275,7 +276,7 @@ describe('Remote Loader', () => { it('should skip remote plugins that arent callable functions', async () => { const plugins = await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'remote plugin', @@ -332,7 +333,7 @@ describe('Remote Loader', () => { const plugins = await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'multiple plugins', @@ -421,7 +422,7 @@ describe('Remote Loader', () => { const plugins = await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'flaky plugin', @@ -475,7 +476,7 @@ describe('Remote Loader', () => { const plugins = await remoteLoader( { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'valid plugin', @@ -521,7 +522,9 @@ describe('Remote Loader', () => { it('accepts settings overrides from merged integrations', async () => { const cdnSettings: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, remotePlugin: { name: 'Charlie Brown', version: '1.0', @@ -549,6 +552,7 @@ describe('Remote Loader', () => { } await remoteLoader(cdnSettings, userOverrides, { + // @ts-ignore remotePlugin: { ...cdnSettings.integrations.remotePlugin, ...userOverrides.remotePlugin, @@ -567,7 +571,9 @@ describe('Remote Loader', () => { it('accepts settings overrides from options (AnalyticsBrowser)', async () => { const cdnSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, remotePlugin: { name: 'Charlie Brown', version: '1.0', @@ -620,6 +626,7 @@ describe('Remote Loader', () => { it('loads destinations when `All: false` but is enabled (pluginName)', async () => { const cdnSettings = { integrations: { + ...cdnSettingsMinimal.integrations, oldValidName: { versionSettings: { componentTypes: [], @@ -660,7 +667,9 @@ describe('Remote Loader', () => { it('loads destinations when `All: false` but is enabled (creationName)', async () => { const cdnSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, oldValidName: { versionSettings: { componentTypes: [], @@ -701,7 +710,9 @@ describe('Remote Loader', () => { it('does not load destinations when disabled via pluginName', async () => { const cdnSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, oldValidName: { versionSettings: { componentTypes: [], @@ -740,6 +751,7 @@ describe('Remote Loader', () => { it('does not load destinations when disabled via creationName', async () => { const cdnSettings = { integrations: { + ...cdnSettingsMinimal.integrations, oldValidName: { versionSettings: { componentTypes: [], @@ -786,7 +798,7 @@ describe('Remote Loader', () => { } const cdnSettings: CDNSettings = { - integrations: {}, + ...cdnSettingsMinimal, middlewareSettings: { routingRules: [ { @@ -857,7 +869,7 @@ describe('Remote Loader', () => { } const cdnSettings: CDNSettings = { - integrations: {}, + ...cdnSettingsMinimal, middlewareSettings: { routingRules: [ { @@ -924,7 +936,7 @@ describe('Remote Loader', () => { } const cdnSettings: CDNSettings = { - integrations: {}, + ...cdnSettingsMinimal, remotePlugins: [ { name: 'valid', diff --git a/packages/browser/src/plugins/remote-loader/index.ts b/packages/browser/src/plugins/remote-loader/index.ts index 4e7a12da3..617272715 100644 --- a/packages/browser/src/plugins/remote-loader/index.ts +++ b/packages/browser/src/plugins/remote-loader/index.ts @@ -1,4 +1,3 @@ -import type { IntegrationsOptions } from '../../core/events/interfaces' import { CDNSettings } from '../../browser' import { JSONObject, JSONValue } from '../../core/events' import { Plugin, InternalPluginWithAddMiddleware } from '../../core/plugin' @@ -12,6 +11,7 @@ import { Context, ContextCancelation } from '../../core/context' import { recordIntegrationMetric } from '../../core/stats/metric-helpers' import { Analytics, InitOptions } from '../../core/analytics' import { createDeferred } from '@segment/analytics-generic-utils' +import { IntegrationsInitOptions } from '../../browser/settings' export interface RemotePlugin { /** The name of the remote plugin */ @@ -198,7 +198,7 @@ function validate(pluginLike: unknown): pluginLike is Plugin[] { } function isPluginDisabled( - userIntegrations: IntegrationsOptions, + userIntegrations: IntegrationsInitOptions, remotePlugin: RemotePlugin ) { const creationNameEnabled = userIntegrations[remotePlugin.creationName] @@ -260,7 +260,7 @@ async function loadPluginFactory( export async function remoteLoader( settings: CDNSettings, - userIntegrations: IntegrationsOptions, + integrations: IntegrationsInitOptions, mergedIntegrations: Record, options?: InitOptions, routingMiddleware?: DestinationMiddlewareFunction, @@ -272,7 +272,7 @@ export async function remoteLoader( const pluginPromises = (settings.remotePlugins ?? []).map( async (remotePlugin) => { - if (isPluginDisabled(userIntegrations, remotePlugin)) return + if (isPluginDisabled(integrations, remotePlugin)) return try { const pluginFactory = diff --git a/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts b/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts index 1c77dc3e4..2c53c1dc4 100644 --- a/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts +++ b/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts @@ -1,6 +1,7 @@ import { remoteMiddlewares } from '..' import { Context } from '../../../core/context' import jsdom from 'jsdom' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' describe('Remote Middleware', () => { beforeEach(async () => { @@ -30,7 +31,7 @@ describe('Remote Middleware', () => { it('ignores empty dictionaries', async () => { const md = await remoteMiddlewares(Context.system(), { - integrations: {}, + integrations: cdnSettingsMinimal.integrations, }) expect(md).toEqual([]) @@ -38,7 +39,7 @@ describe('Remote Middleware', () => { it('doesnt load entries if their value is false', async () => { const md = await remoteMiddlewares(Context.system(), { - integrations: {}, + ...cdnSettingsMinimal, enabledMiddleware: { '@segment/analytics.js-middleware-braze-deduplicate': false, }, @@ -49,7 +50,7 @@ describe('Remote Middleware', () => { it('loads middleware that exist', async () => { const md = await remoteMiddlewares(Context.system(), { - integrations: {}, + ...cdnSettingsMinimal, enabledMiddleware: { '@segment/analytics.js-middleware-braze-deduplicate': true, }, @@ -61,7 +62,7 @@ describe('Remote Middleware', () => { it('ignores segment namespace', async () => { const md = await remoteMiddlewares(Context.system(), { - integrations: {}, + ...cdnSettingsMinimal, enabledMiddleware: { '@segment/analytics.js-middleware-braze-deduplicate': true, 'analytics.js-middleware-braze-deduplicate': true, @@ -74,7 +75,7 @@ describe('Remote Middleware', () => { it('loads middleware through remote script tags', async () => { await remoteMiddlewares(Context.system(), { - integrations: {}, + ...cdnSettingsMinimal, enabledMiddleware: { '@segment/analytics.js-middleware-braze-deduplicate': true, }, @@ -96,7 +97,7 @@ describe('Remote Middleware', () => { const ctx = Context.system() const md = await remoteMiddlewares(ctx, { - integrations: {}, + ...cdnSettingsMinimal, enabledMiddleware: { '@segment/analytics.js-middleware-braze-deduplicate': true, '@segment/analytics.js-middleware-that-does-not-exist': true, diff --git a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts index a022e357b..3a6b2a8eb 100644 --- a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts +++ b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts @@ -4,15 +4,17 @@ import { Context } from '../../../core/context' import { schemaFilter } from '..' import { CDNSettings } from '../../../browser' import { segmentio, SegmentioSettings } from '../../segmentio' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' const settings: CDNSettings = { + ...cdnSettingsMinimal, integrations: { + ...cdnSettingsMinimal.integrations, 'Braze Web Mode (Actions)': {}, // note that Fullstory's name here doesn't contain 'Actions' Fullstory: {}, 'Google Analytics': {}, 'Google Analytics 4 Web': {}, - 'Segment.io': {}, }, remotePlugins: [ { @@ -124,7 +126,7 @@ describe('schema filter', () => { options = { apiKey: 'foo' } ajs = new Analytics({ writeKey: options.apiKey }) - segment = await segmentio(ajs, options, {}) + segment = await segmentio(ajs, options, settings.integrations) filterXt = schemaFilter({}, settings) jest.spyOn(segment, 'track') diff --git a/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts b/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts index 93c6c5e4d..b6433153a 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts @@ -102,6 +102,7 @@ describe('Batching', () => { }, "keepalive": false, "method": "post", + "priority": undefined, }, ] `) @@ -185,6 +186,7 @@ describe('Batching', () => { }, "keepalive": false, "method": "post", + "priority": undefined, }, ] `) @@ -220,6 +222,7 @@ describe('Batching', () => { }, "keepalive": false, "method": "post", + "priority": undefined, }, ] `) @@ -234,6 +237,7 @@ describe('Batching', () => { }, "keepalive": false, "method": "post", + "priority": undefined, }, ] `) @@ -265,6 +269,7 @@ describe('Batching', () => { }, "keepalive": false, "method": "post", + "priority": undefined, }, ] `) diff --git a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts index ec60ad350..04d69b596 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts @@ -5,6 +5,7 @@ import { Analytics } from '../../../core/analytics' import { Plugin } from '../../../core/plugin' import { envEnrichment } from '../../env-enrichment' import cookie from 'js-cookie' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' jest.mock('unfetch', () => { return jest.fn() @@ -22,7 +23,11 @@ describe('Segment.io', () => { options = { apiKey: 'foo' } analytics = new Analytics({ writeKey: options.apiKey }) - segment = await segmentio(analytics, options, {}) + segment = await segmentio( + analytics, + options, + cdnSettingsMinimal.integrations + ) await analytics.register(segment, envEnrichment) @@ -54,7 +59,11 @@ describe('Segment.io', () => { protocol: 'http', } const analytics = new Analytics({ writeKey: options.apiKey }) - const segment = await segmentio(analytics, options, {}) + const segment = await segmentio( + analytics, + options, + cdnSettingsMinimal.integrations + ) await analytics.register(segment, envEnrichment) // @ts-ignore test a valid ajsc page call @@ -65,7 +74,95 @@ describe('Segment.io', () => { }) }) - describe('configuring a keep alive', () => { + describe('configuring headers', () => { + it('should accept additional headers', async () => { + const analytics = new Analytics({ writeKey: 'foo' }) + + await analytics.register( + await segmentio(analytics, { + apiKey: '', + deliveryStrategy: { + config: { + headers: { + 'X-My-Header': 'foo', + }, + }, + }, + }) + ) + + await analytics.track('foo') + const [_, params] = spyMock.mock.lastCall + expect(params.headers['X-My-Header']).toBe('foo') + expect(params.headers['Content-Type']).toBe('text/plain') + }) + + it('should allow additional headers to be a function', async () => { + const analytics = new Analytics({ writeKey: 'foo' }) + + await analytics.register( + await segmentio(analytics, { + apiKey: '', + deliveryStrategy: { + config: { + headers: () => ({ + 'X-My-Header': 'foo', + }), + }, + }, + }) + ) + + await analytics.track('foo') + const [_, params] = spyMock.mock.lastCall + expect(params.headers['X-My-Header']).toBe('foo') + expect(params.headers['Content-Type']).toBe('text/plain') + }) + + it('should allow content type to be overridden', async () => { + const analytics = new Analytics({ writeKey: 'foo' }) + + await analytics.register( + await segmentio(analytics, { + apiKey: '', + deliveryStrategy: { + config: { + headers: () => ({ + 'Content-Type': 'bar', + }), + }, + }, + }) + ) + + await analytics.track('foo') + const [_, params] = spyMock.mock.lastCall + expect(params.headers['Content-Type']).toBe('bar') + }) + }) + + describe('configuring fetch priority', () => { + it('should accept fetch priority configuration', async () => { + const analytics = new Analytics({ writeKey: 'foo' }) + + await analytics.register( + await segmentio(analytics, { + apiKey: '', + deliveryStrategy: { + config: { + priority: 'high', + }, + }, + }) + ) + + await analytics.track('foo') + const [_, params] = spyMock.mock.lastCall + expect(params.priority).toBe('high') + }) + }) + + describe('configuring keepalive', () => { it('should accept keepalive configuration', async () => { const analytics = new Analytics({ writeKey: 'foo' }) diff --git a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts index b6225c344..b4c90d5af 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts @@ -5,7 +5,7 @@ import { normalize } from '../normalize' import { Analytics } from '../../../core/analytics' import { SegmentEvent } from '../../../core/events' import { JSDOM } from 'jsdom' - +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' describe('before loading', () => { let jsdom: JSDOM @@ -70,39 +70,39 @@ describe('before loading', () => { it('should add .anonymousId', () => { analytics.user().anonymousId('anon-id') - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.anonymousId === 'anon-id') }) it('should add .sentAt', () => { - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.sentAt) // assert(type(object.sentAt) === 'date') }) it('should add .userId', () => { analytics.user().id('user-id') - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.userId === 'user-id') }) it('should not replace the .timestamp', () => { const timestamp = new Date() object.timestamp = timestamp - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.timestamp === timestamp) }) it('should not replace the .userId', () => { analytics.user().id('user-id') object.userId = 'existing-id' - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.userId === 'existing-id') }) it('should always add .anonymousId even if .userId is given', () => { object.userId = 'baz' - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.anonymousId?.length === 36) }) @@ -110,18 +110,19 @@ describe('before loading', () => { object.userId = 'baz' object.anonymousId = '👻' - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) expect(object.anonymousId).toEqual('👻') }) it('should add .writeKey', () => { - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert(object.writeKey === options.apiKey) }) describe('unbundling', () => { it('should add a list of bundled integrations', () => { normalize(analytics, object, options, { + // @ts-ignore 'Segment.io': {}, other: { bundlingStatus: 'bundled', @@ -144,7 +145,7 @@ describe('before loading', () => { }, }, { - 'Segment.io': {}, + ...cdnSettingsMinimal.integrations, other: { bundlingStatus: 'bundled', }, @@ -159,6 +160,7 @@ describe('before loading', () => { it('should add a list of unbundled integrations when `unbundledIntegrations` is set', () => { options.unbundledIntegrations = ['other2'] normalize(analytics, object, options, { + ...cdnSettingsMinimal.integrations, other2: { bundlingStatus: 'unbundled', }, @@ -171,10 +173,10 @@ describe('before loading', () => { }) it('should pick up messageId from AJS', () => { - normalize(analytics, object, options, {}) // ajs core generates the message ID here + normalize(analytics, object, options, cdnSettingsMinimal.integrations) // ajs core generates the message ID here const messageId = object.messageId - normalize(analytics, object, options, {}) + normalize(analytics, object, options, cdnSettingsMinimal.integrations) assert.equal(object.messageId, messageId) }) }) diff --git a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts index 20fe9452c..5a49e4a7c 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts @@ -5,19 +5,11 @@ jest.mock('unfetch', () => { import { segmentio, SegmentioSettings } from '..' import { Analytics } from '../../../core/analytics' -// @ts-ignore isOffline mocked dependency is accused as unused -import { isOffline } from '../../../core/connection' import { Plugin } from '../../../core/plugin' import { envEnrichment } from '../../env-enrichment' -import { scheduleFlush } from '../schedule-flush' -import * as PPQ from '../../../lib/priority-queue/persisted' import * as PQ from '../../../lib/priority-queue' -import { Context } from '../../../core/context' import { createError, createSuccess } from '../../../test-helpers/factories' - -//jest.mock('../schedule-flush') - -type QueueType = 'priority' | 'persisted' +import { cdnSettingsMinimal } from '../../../test-helpers/fixtures' describe('Segment.io retries 500s and 429', () => { let options: SegmentioSettings @@ -29,20 +21,18 @@ describe('Segment.io retries 500s and 429', () => { jest.restoreAllMocks() options = { apiKey: 'foo' } - analytics = new Analytics( - { writeKey: options.apiKey }, - { - retryQueue: true, - } + analytics = new Analytics({ writeKey: options.apiKey }) + segment = await segmentio( + analytics, + options, + cdnSettingsMinimal.integrations ) - segment = await segmentio(analytics, options, {}) await analytics.register(segment, envEnrichment) }) test('retries on 500', async () => { jest.useFakeTimers({ advanceTimers: true }) fetch.mockReturnValue(createError({ status: 500 })) - // .mockReturnValue(createSuccess({})) const ctx = await analytics.track('event') jest.runAllTimers() @@ -88,13 +78,12 @@ describe('Batches retry 500s and 429', () => { config: { size: 3, timeout: 1, maxRetries: 2 }, }, } - analytics = new Analytics( - { writeKey: options.apiKey }, - { - retryQueue: true, - } + analytics = new Analytics({ writeKey: options.apiKey }) + segment = await segmentio( + analytics, + options, + cdnSettingsMinimal.integrations ) - segment = await segmentio(analytics, options, {}) await analytics.register(segment, envEnrichment) }) @@ -142,73 +131,3 @@ describe('Batches retry 500s and 429', () => { expect(fetch.mock.lastCall[1].body).toContain('"retryCount":2') }) }) - -describe('Segment.io retries', () => { - let options: SegmentioSettings - let analytics: Analytics - let segment: Plugin - let queue: (PPQ.PersistedPriorityQueue | PQ.PriorityQueue) & { - __type?: QueueType - } - ;[false, true].forEach((persistenceIsDisabled) => { - describe(`disableClientPersistence: ${persistenceIsDisabled}`, () => { - beforeEach(async () => { - jest.useRealTimers() - jest.resetAllMocks() - jest.restoreAllMocks() - - // @ts-expect-error reassign import - isOffline = jest.fn().mockImplementation(() => true) - // @ts-expect-error reassign import - scheduleFlush = jest.fn().mockImplementation(() => {}) - - options = { apiKey: 'foo' } - analytics = new Analytics( - { writeKey: options.apiKey }, - { - retryQueue: true, - disableClientPersistence: persistenceIsDisabled, - } - ) - - if (persistenceIsDisabled) { - queue = new PQ.PriorityQueue(3, []) - queue['__type'] = 'priority' - Object.defineProperty(PQ, 'PriorityQueue', { - writable: true, - value: jest.fn().mockImplementation(() => queue), - }) - } else { - queue = new PPQ.PersistedPriorityQueue( - 3, - `${options.apiKey}:test-Segment.io` - ) - queue['__type'] = 'persisted' - Object.defineProperty(PPQ, 'PersistedPriorityQueue', { - writable: true, - value: jest.fn().mockImplementation(() => queue), - }) - } - - segment = await segmentio(analytics, options, {}) - await analytics.register(segment, envEnrichment) - }) - - test(`add events to the queue`, async () => { - jest.spyOn(queue, 'push') - - const ctx = await analytics.track('event') - - expect(scheduleFlush).toHaveBeenCalled() - /* eslint-disable @typescript-eslint/unbound-method */ - expect(queue.push).toHaveBeenCalled() - expect(queue.length).toBe(1) - expect(ctx.attempts).toBe(1) - expect(isOffline).toHaveBeenCalledTimes(2) - expect(queue.__type).toBe( - persistenceIsDisabled ? 'priority' : 'persisted' - ) - }) - }) - }) -}) diff --git a/packages/browser/src/plugins/segmentio/batched-dispatcher.ts b/packages/browser/src/plugins/segmentio/batched-dispatcher.ts index 6ab8b8769..1baf7f527 100644 --- a/packages/browser/src/plugins/segmentio/batched-dispatcher.ts +++ b/packages/browser/src/plugins/segmentio/batched-dispatcher.ts @@ -4,13 +4,7 @@ import { onPageChange } from '../../lib/on-page-change' import { SegmentFacade } from '../../lib/to-facade' import { RateLimitError } from './ratelimit-error' import { Context } from '../../core/context' - -export type BatchingDispatchConfig = { - size?: number - timeout?: number - maxRetries?: number - keepalive?: boolean -} +import { BatchingDispatchConfig, createHeaders } from './shared-dispatcher' const MAX_PAYLOAD_SIZE = 500 const MAX_KEEPALIVE_SIZE = 64 @@ -84,15 +78,15 @@ export default function batch( return fetch(`https://${apiHost}/b`, { keepalive: config?.keepalive || pageUnloaded, - headers: { - 'Content-Type': 'text/plain', - }, + headers: createHeaders(config?.headers), method: 'post', body: JSON.stringify({ writeKey, batch: updatedBatch, sentAt: new Date().toISOString(), }), + // @ts-ignore - not in the ts lib yet + priority: config?.priority, }).then((res) => { if (res.status >= 500) { throw new Error(`Bad response from server: ${res.status}`) diff --git a/packages/browser/src/plugins/segmentio/fetch-dispatcher.ts b/packages/browser/src/plugins/segmentio/fetch-dispatcher.ts index 0beddcc10..ae2d2a5f6 100644 --- a/packages/browser/src/plugins/segmentio/fetch-dispatcher.ts +++ b/packages/browser/src/plugins/segmentio/fetch-dispatcher.ts @@ -1,21 +1,19 @@ import { fetch } from '../../lib/fetch' import { RateLimitError } from './ratelimit-error' - +import { createHeaders, StandardDispatcherConfig } from './shared-dispatcher' export type Dispatcher = (url: string, body: object) => Promise -export type StandardDispatcherConfig = { - keepalive?: boolean -} - export default function (config?: StandardDispatcherConfig): { dispatch: Dispatcher } { function dispatch(url: string, body: object): Promise { return fetch(url, { keepalive: config?.keepalive, - headers: { 'Content-Type': 'text/plain' }, + headers: createHeaders(config?.headers), method: 'post', body: JSON.stringify(body), + // @ts-ignore - not in the ts lib yet + priority: config?.priority, }).then((res) => { if (res.status >= 500) { throw new Error(`Bad response from server: ${res.status}`) diff --git a/packages/browser/src/plugins/segmentio/index.ts b/packages/browser/src/plugins/segmentio/index.ts index 20186b9bb..f3e40bf26 100644 --- a/packages/browser/src/plugins/segmentio/index.ts +++ b/packages/browser/src/plugins/segmentio/index.ts @@ -7,21 +7,12 @@ import { Plugin } from '../../core/plugin' import { PriorityQueue } from '../../lib/priority-queue' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import { toFacade } from '../../lib/to-facade' -import batch, { BatchingDispatchConfig } from './batched-dispatcher' -import standard, { StandardDispatcherConfig } from './fetch-dispatcher' +import batch from './batched-dispatcher' +import standard from './fetch-dispatcher' import { normalize } from './normalize' import { scheduleFlush } from './schedule-flush' import { SEGMENT_API_HOST } from '../../core/constants' - -type DeliveryStrategy = - | { - strategy?: 'standard' - config?: StandardDispatcherConfig - } - | { - strategy?: 'batching' - config?: BatchingDispatchConfig - } +import { DeliveryStrategy } from './shared-dispatcher' export type SegmentioSettings = { apiKey: string @@ -93,9 +84,11 @@ export function segmentio( const deliveryStrategy = settings?.deliveryStrategy const client = - deliveryStrategy?.strategy === 'batching' + deliveryStrategy && + 'strategy' in deliveryStrategy && + deliveryStrategy.strategy === 'batching' ? batch(apiHost, deliveryStrategy.config) - : standard(deliveryStrategy?.config as StandardDispatcherConfig) + : standard(deliveryStrategy?.config) async function send(ctx: Context): Promise { if (isOffline()) { diff --git a/packages/browser/src/plugins/segmentio/shared-dispatcher.ts b/packages/browser/src/plugins/segmentio/shared-dispatcher.ts new file mode 100644 index 000000000..07bbe0275 --- /dev/null +++ b/packages/browser/src/plugins/segmentio/shared-dispatcher.ts @@ -0,0 +1,82 @@ +export const createHeaders = ( + headerSettings: AdditionalHeaders | undefined +): Record => { + return { + 'Content-Type': 'text/plain', + ...(typeof headerSettings === 'function' + ? headerSettings() + : headerSettings), + } +} + +/** + * Additional headers to be sent with the request. + * Default is `Content-Type: text/plain`. This can be overridden. + * If a function is provided, it will be called before each request. + */ +export type AdditionalHeaders = + | Record + | (() => Record) + +export type RequestPriority = 'high' | 'low' | 'auto' + +/** + * These are the options that can be passed to the fetch dispatcher. + * They more/less map to the Fetch RequestInit type. + */ +interface DispatchFetchConfig { + /** + * This is useful for ensuring that an event is sent even if the user navigates away from the page. + * However, it may increase the likelihood of events being lost, as there is a 64kb limit for *all* fetch requests (not just ones to segment) with keepalive (which is why it's disabled by default). So, if you're sending a lot of data, this will likely cause events to be dropped. + + * @default false + */ + keepalive?: boolean + /** + * Additional headers to be sent with the request. + * Default is `Content-Type: text/plain`. This can be overridden. + * If a function is provided, it will be called before each request. + * @example { 'Content-Type': 'application/json' } or () => { 'Content-Type': 'application/json' } + */ + headers?: AdditionalHeaders + /** + * 'Request Priority' of the request + * @see https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#priority + */ + priority?: RequestPriority +} + +export interface BatchingDispatchConfig extends DispatchFetchConfig { + /** + * If strategy = 'batching', the maximum number of events to send in a single request. If the batch reaches this size, a request will automatically be sent. + * + * @default 10 + */ + size?: number + /** + * If strategy = 'batching', the maximum time, in milliseconds, to wait before sending a request. + * This won't alaways be relevant, as the request will be sent when the size is reached. + * However, if the size is never reached, the request will be sent after this time. + * When it comes to retries, if there is a rate limit timeout header, that will be respected over the value here. + * + * @default 5000 + */ + timeout?: number + /** + * If strategy = 'batching', the maximum number of retries to attempt before giving up. + * @default 10 + */ + maxRetries?: number +} + +export interface StandardDispatcherConfig extends DispatchFetchConfig {} + +export type DeliveryStrategy = + | { + strategy?: 'standard' + config: StandardDispatcherConfig + } + | { + strategy: 'batching' + config?: BatchingDispatchConfig + } diff --git a/packages/browser/src/test-helpers/fixtures/cdn-settings.ts b/packages/browser/src/test-helpers/fixtures/cdn-settings.ts index 0b2406a71..761a7c548 100644 --- a/packages/browser/src/test-helpers/fixtures/cdn-settings.ts +++ b/packages/browser/src/test-helpers/fixtures/cdn-settings.ts @@ -1,8 +1,15 @@ -import { AnalyticsBrowserSettings } from '../..' +import { CDNSettings } from '../../browser/settings' import type { RemotePlugin } from '../../plugins/remote-loader' import { mockIntegrationName } from './classic-destination' -type CDNSettings = NonNullable +export const cdnSettingsMinimal: CDNSettings = { + integrations: { + 'Segment.io': { + apiKey: 'my-writekey', + }, + [mockIntegrationName]: {}, + }, +} export const cdnSettingsKitchenSink: CDNSettings = { integrations: { @@ -295,12 +302,6 @@ export const cdnSettingsKitchenSink: CDNSettings = { remotePlugins: [], } -export const cdnSettingsMinimal: CDNSettings = { - integrations: { - [mockIntegrationName]: {}, - }, -} - export const createRemotePlugin = ( name: string, creationName?: string diff --git a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts index 13a1fb43c..4ad128e06 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts @@ -15,7 +15,7 @@ import { AnalyticsService } from '../analytics' const DEFAULT_LOAD_SETTINGS = { writeKey: 'foo', - cdnSettings: { integrations: {} }, + cdnSettings: new CDNSettingsBuilder().build(), } const mockGetCategories: jest.MockedFn = diff --git a/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts b/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts index e32c18843..5ab2614de 100644 --- a/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts +++ b/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts @@ -2,7 +2,9 @@ import { AnalyticsService, getInitializedAnalytics } from '../analytics-service' import { analyticsMock } from '../../../test-helpers/mocks' import { ValidationError } from '../../validation/validation-error' import { Context } from '@segment/analytics-next' +import { CDNSettingsBuilder } from '@internal/test-helpers' +const minimalCDNSettings = new CDNSettingsBuilder().build() describe(AnalyticsService, () => { let analyticsService: AnalyticsService @@ -292,11 +294,13 @@ describe(AnalyticsService, () => { }) analyticsService.configureBlockingMiddlewareForOptOut() analyticsService['cdnSettingsDeferred'].resolve({ + ...minimalCDNSettings, consentSettings: { allCategories: ['Foo'], hasUnmappedDestinations: false, }, integrations: { + ...minimalCDNSettings.integrations, foo: { consentSettings: { categories: ['Foo'], // @@ -333,11 +337,13 @@ describe(AnalyticsService, () => { }) analyticsService.configureBlockingMiddlewareForOptOut() analyticsService['cdnSettingsDeferred'].resolve({ + ...minimalCDNSettings, consentSettings: { allCategories: ['C0001'], hasUnmappedDestinations: false, }, integrations: { + ...minimalCDNSettings.integrations, foo: { consentSettings: { categories: ['C0001'], // @@ -384,7 +390,9 @@ describe(AnalyticsService, () => { }) analyticsService.configureBlockingMiddlewareForOptOut() analyticsService['cdnSettingsDeferred'].resolve({ + ...minimalCDNSettings, integrations: { + ...minimalCDNSettings.integrations, foo: { consentSettings: { categories: ['Foo'], @@ -424,7 +432,7 @@ describe(AnalyticsService, () => { }) analyticsService['cdnSettingsDeferred'].resolve({ - integrations: {}, + integrations: minimalCDNSettings.integrations, }) analyticsService.configureBlockingMiddlewareForOptOut() expect(analyticsMock.addDestinationMiddleware).toHaveBeenCalledTimes(1) @@ -463,9 +471,7 @@ describe(AnalyticsService, () => { }, }) - analyticsService['cdnSettingsDeferred'].resolve({ - integrations: {}, - }) + analyticsService['cdnSettingsDeferred'].resolve(minimalCDNSettings) analyticsService.configureBlockingMiddlewareForOptOut() const destinationMw = analyticsMock.addDestinationMiddleware.mock.lastCall![1] diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 5573dd7c0..5b3711ed4 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -136,6 +136,7 @@ export interface CDNSettings { integrations: CDNSettingsIntegrations remotePlugins?: CDNSettingsRemotePlugin[] consentSettings?: CDNSettingsConsent + [key: string]: unknown } /** @@ -144,7 +145,8 @@ export interface CDNSettings { * { "Fullstory": {...}, "Braze Web Mode (Actions)": {...}} */ export interface CDNSettingsIntegrations { - [integrationName: string]: { [key: string]: any } + 'Segment.io': any // This key isn't actually used, but it's here to loosely match the expected type + [integrationName: string]: Record } export interface CDNSettingsRemotePlugin { diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index a3f3c56e1..0f12198c1 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -67,7 +67,7 @@ export abstract class CoreEventFactory { event: string, properties?: EventProperties, options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationOptions?: IntegrationsOptions ) { this.settings.onEventMethodCall({ type: 'track', options }) return this.normalize({ @@ -76,7 +76,7 @@ export abstract class CoreEventFactory { type: 'track', properties: properties ?? {}, // TODO: why is this not a shallow copy like everywhere else? options: { ...options }, - integrations: { ...globalIntegrations }, + integrations: { ...integrationOptions }, }) } @@ -85,14 +85,14 @@ export abstract class CoreEventFactory { page: string | null, properties?: EventProperties, options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationOptions?: IntegrationsOptions ): CoreSegmentEvent { this.settings.onEventMethodCall({ type: 'page', options }) const event: CoreSegmentEvent = { type: 'page', properties: { ...properties }, options: { ...options }, - integrations: { ...globalIntegrations }, + integrations: { ...integrationOptions }, } if (category !== null) { @@ -116,14 +116,14 @@ export abstract class CoreEventFactory { screen: string | null, properties?: EventProperties, options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationOptions?: IntegrationsOptions ): CoreSegmentEvent { this.settings.onEventMethodCall({ type: 'screen', options }) const event: CoreSegmentEvent = { type: 'screen', properties: { ...properties }, options: { ...options }, - integrations: { ...globalIntegrations }, + integrations: { ...integrationOptions }, } if (category !== null) { @@ -144,7 +144,7 @@ export abstract class CoreEventFactory { userId: ID, traits?: UserTraits, options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationsOptions?: IntegrationsOptions ): CoreSegmentEvent { this.settings.onEventMethodCall({ type: 'identify', options }) return this.normalize({ @@ -153,7 +153,7 @@ export abstract class CoreEventFactory { userId, traits: traits ?? {}, options: { ...options }, - integrations: globalIntegrations, + integrations: integrationsOptions, }) } @@ -161,7 +161,7 @@ export abstract class CoreEventFactory { groupId: ID, traits?: GroupTraits, options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationOptions?: IntegrationsOptions ): CoreSegmentEvent { this.settings.onEventMethodCall({ type: 'group', options }) return this.normalize({ @@ -169,7 +169,7 @@ export abstract class CoreEventFactory { type: 'group', traits: traits ?? {}, options: { ...options }, // this spreading is intentional - integrations: { ...globalIntegrations }, // + integrations: { ...integrationOptions }, // groupId, }) } @@ -178,14 +178,14 @@ export abstract class CoreEventFactory { to: string, from: string | null, // TODO: can we make this undefined? options?: CoreOptions, - globalIntegrations?: IntegrationsOptions + integrationOptions?: IntegrationsOptions ): CoreSegmentEvent { this.settings.onEventMethodCall({ type: 'alias', options }) const base: CoreSegmentEvent = { userId: to, type: 'alias', options: { ...options }, - integrations: { ...globalIntegrations }, + integrations: { ...integrationOptions }, } if (from !== null) { diff --git a/packages/signals/signals/src/core/analytics-service/index.ts b/packages/signals/signals/src/core/analytics-service/index.ts index 8efdce74d..8d3b51320 100644 --- a/packages/signals/signals/src/core/analytics-service/index.ts +++ b/packages/signals/signals/src/core/analytics-service/index.ts @@ -1,5 +1,4 @@ -import { CDNSettings } from '@segment/analytics-next' -import { AnyAnalytics } from '../../types' +import { AnyAnalytics, CDNSettings } from '../../types' import { SignalGenerator } from '../signal-generators/types' import { createInstrumentationSignal } from '../../types/factories' diff --git a/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts b/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts index d3eb24a8e..302b05641 100644 --- a/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts +++ b/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts @@ -10,7 +10,11 @@ export const analyticsMock: jest.Mocked = { writeKey: 'test', cdnSettings: { edgeFunction: edgeFnSettings, - integrations: {}, + integrations: { + 'Segment.io': { + apiKey: '', + }, + }, }, }, alias: jest.fn(), diff --git a/packages/signals/signals/src/types/analytics-api.ts b/packages/signals/signals/src/types/analytics-api.ts index c201f1a11..a0dc168fb 100644 --- a/packages/signals/signals/src/types/analytics-api.ts +++ b/packages/signals/signals/src/types/analytics-api.ts @@ -13,9 +13,9 @@ export type AutoInstrumentationCDNSettings = { } export interface CDNSettings { - integrations: CDNSettingsIntegrations - edgeFunction?: EdgeFnCDNSettings | { [key: string]: never } + edgeFunction?: EdgeFnCDNSettings | {} autoInstrumentationSettings?: AutoInstrumentationCDNSettings + [key: string]: unknown } export interface SegmentEventStub { @@ -76,7 +76,8 @@ export interface AnyAnalytics { * { "Fullstory": {...}, "Braze Web Mode (Actions)": {...}} */ export interface CDNSettingsIntegrations { - [integrationName: string]: { [key: string]: any } + 'Segment.io': any + [integrationName: string]: Record } export type PluginType = 'before' | 'after' | 'destination' diff --git a/packages/test-helpers/src/analytics/cdn-settings-builder.ts b/packages/test-helpers/src/analytics/cdn-settings-builder.ts index 86cd126e4..cb3e46aa0 100644 --- a/packages/test-helpers/src/analytics/cdn-settings-builder.ts +++ b/packages/test-helpers/src/analytics/cdn-settings-builder.ts @@ -22,7 +22,7 @@ export class CDNSettingsBuilder { const settings: CDNSettings = { integrations: { 'Segment.io': { - apiKey: writeKey, + apiKey: writeKey || '', unbundledIntegrations: [], addBundledMetadata: true, maybeBundledConfigIds: {},