diff --git a/examples/AnalyticsReactNativeExample/ios/Podfile.lock b/examples/AnalyticsReactNativeExample/ios/Podfile.lock index 7769bc67..f6ab11e6 100644 --- a/examples/AnalyticsReactNativeExample/ios/Podfile.lock +++ b/examples/AnalyticsReactNativeExample/ios/Podfile.lock @@ -499,7 +499,7 @@ PODS: - RNScreens (3.27.0): - RCT-Folly (= 2021.07.22.00) - React-Core - - segment-analytics-react-native (2.19.4): + - segment-analytics-react-native (2.19.5): - React-Core - sovran-react-native - SocketRocket (0.6.1) @@ -752,7 +752,7 @@ SPEC CHECKSUMS: RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 RNGestureHandler: 32a01c29ecc9bb0b5bf7bc0a33547f61b4dc2741 RNScreens: 3c2d122f5e08c192e254c510b212306da97d2581 - segment-analytics-react-native: 49ce29a68e86b38c084f1ce07b0c128273d169f9 + segment-analytics-react-native: 4bac3da03dd4a1eed178786b1d7025cd2c0ed6c9 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 sovran-react-native: 5f02bd2d111ffe226d00c7b0435290eae6f10934 Yoga: eddf2bbe4a896454c248a8f23b4355891eb720a6 @@ -760,4 +760,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 329f06ebb76294acf15c298d0af45530e2797740 -COCOAPODS: 1.11.3 +COCOAPODS: 1.15.2 diff --git a/packages/core/src/__tests__/internal/fetchSettings.test.ts b/packages/core/src/__tests__/internal/fetchSettings.test.ts index 643d2f53..c36d8e84 100644 --- a/packages/core/src/__tests__/internal/fetchSettings.test.ts +++ b/packages/core/src/__tests__/internal/fetchSettings.test.ts @@ -50,7 +50,12 @@ describe('internal #getSettings', () => { await client.fetchSettings(); expect(fetch).toHaveBeenCalledWith( - `${settingsCDN}/${clientArgs.config.writeKey}/settings` + `${settingsCDN}/${clientArgs.config.writeKey}/settings`, + { + headers: { + 'Cache-Control': 'no-cache', + }, + } ); expect(setSettingsSpy).toHaveBeenCalledWith(mockJSONResponse.integrations); @@ -66,7 +71,12 @@ describe('internal #getSettings', () => { await client.fetchSettings(); expect(fetch).toHaveBeenCalledWith( - `${settingsCDN}/${clientArgs.config.writeKey}/settings` + `${settingsCDN}/${clientArgs.config.writeKey}/settings`, + { + headers: { + 'Cache-Control': 'no-cache', + }, + } ); expect(setSettingsSpy).toHaveBeenCalledWith( @@ -92,7 +102,12 @@ describe('internal #getSettings', () => { await anotherClient.fetchSettings(); expect(fetch).toHaveBeenCalledWith( - `${settingsCDN}/${clientArgs.config.writeKey}/settings` + `${settingsCDN}/${clientArgs.config.writeKey}/settings`, + { + headers: { + 'Cache-Control': 'no-cache', + }, + } ); expect(setSettingsSpy).not.toHaveBeenCalled(); }); @@ -113,7 +128,12 @@ describe('internal #getSettings', () => { await anotherClient.fetchSettings(); expect(fetch).toHaveBeenCalledWith( - `${settingsCDN}/${clientArgs.config.writeKey}/settings` + `${settingsCDN}/${clientArgs.config.writeKey}/settings`, + { + headers: { + 'Cache-Control': 'no-cache', + }, + } ); expect(setSettingsSpy).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/__tests__/methods/group.test.ts b/packages/core/src/__tests__/methods/group.test.ts index d8d8984a..9c7721b0 100644 --- a/packages/core/src/__tests__/methods/group.test.ts +++ b/packages/core/src/__tests__/methods/group.test.ts @@ -43,6 +43,6 @@ describe('methods #group', () => { }; expect(client.process).toHaveBeenCalledTimes(1); - expect(client.process).toHaveBeenCalledWith(expectedEvent); + expect(client.process).toHaveBeenCalledWith(expectedEvent, undefined); }); }); diff --git a/packages/core/src/__tests__/methods/process.test.ts b/packages/core/src/__tests__/methods/process.test.ts index bebece19..1b8beb58 100644 --- a/packages/core/src/__tests__/methods/process.test.ts +++ b/packages/core/src/__tests__/methods/process.test.ts @@ -112,4 +112,49 @@ describe('process', () => { expect.objectContaining(expectedEvent) ); }); + + it('enrichment closure gets applied', async () => { + const client = new SegmentClient(clientArgs); + jest.spyOn(client.isReady, 'value', 'get').mockReturnValue(true); + + // @ts-ignore + const timeline = client.timeline; + jest.spyOn(timeline, 'process'); + + await client.track('Some Event', { id: 1 }, (event) => { + if (event.context == null) { + event.context = {}; + } + event.context.__eventOrigin = { + type: 'signals', + }; + event.anonymousId = 'foo'; + + return event; + }); + + const expectedEvent = { + event: 'Some Event', + properties: { + id: 1, + }, + type: EventType.TrackEvent, + context: { + __eventOrigin: { + type: 'signals', + }, + ...store.context.get(), + }, + userId: store.userInfo.get().userId, + anonymousId: 'foo', + } as SegmentEvent; + + // @ts-ignore + const pendingEvents = client.store.pendingEvents.get(); + expect(pendingEvents.length).toBe(0); + + expect(timeline.process).toHaveBeenCalledWith( + expect.objectContaining(expectedEvent) + ); + }); }); diff --git a/packages/core/src/__tests__/methods/screen.test.ts b/packages/core/src/__tests__/methods/screen.test.ts index 1f3cf1bd..176f0fbd 100644 --- a/packages/core/src/__tests__/methods/screen.test.ts +++ b/packages/core/src/__tests__/methods/screen.test.ts @@ -43,6 +43,6 @@ describe('methods #screen', () => { }; expect(client.process).toHaveBeenCalledTimes(1); - expect(client.process).toHaveBeenCalledWith(expectedEvent); + expect(client.process).toHaveBeenCalledWith(expectedEvent, undefined); }); }); diff --git a/packages/core/src/__tests__/methods/track.test.ts b/packages/core/src/__tests__/methods/track.test.ts index dea47988..e978dbd9 100644 --- a/packages/core/src/__tests__/methods/track.test.ts +++ b/packages/core/src/__tests__/methods/track.test.ts @@ -44,6 +44,6 @@ describe('methods #track', () => { }; expect(client.process).toHaveBeenCalledTimes(1); - expect(client.process).toHaveBeenCalledWith(expectedEvent); + expect(client.process).toHaveBeenCalledWith(expectedEvent, undefined); }); }); diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index eca23788..0d2c7e72 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -35,7 +35,14 @@ import { Watchable, } from './storage'; import { Timeline } from './timeline'; -import { DestinationFilters, EventType, SegmentAPISettings } from './types'; +import { + DestinationFilters, + EventType, + SegmentAPISettings, + SegmentAPIConsentSettings, + EdgeFunctionSettings, + EnrichmentClosure, +} from './types'; import { Config, Context, @@ -59,7 +66,6 @@ import { SegmentError, translateHTTPError, } from './errors'; -import type { SegmentAPIConsentSettings } from '.'; type OnPluginAddedCallback = (plugin: Plugin) => void; @@ -125,6 +131,11 @@ export class SegmentClient { */ readonly consentSettings: Watchable; + /** + * Access or subscribe to edge functions settings + */ + readonly edgeFunctionSettings: Watchable; + /** * Access or subscribe to destination filter settings */ @@ -212,6 +223,11 @@ export class SegmentClient { onChange: this.store.consentSettings.onChange, }; + this.edgeFunctionSettings = { + get: this.store.edgeFunctionSettings.get, + onChange: this.store.edgeFunctionSettings.onChange, + }; + this.filters = { get: this.store.filters.get, onChange: this.store.filters.onChange, @@ -307,13 +323,18 @@ export class SegmentClient { const settingsEndpoint = `${settingsPrefix}/${this.config.writeKey}/settings`; try { - const res = await fetch(settingsEndpoint); + const res = await fetch(settingsEndpoint, { + headers: { + 'Cache-Control': 'no-cache', + }, + }); checkResponseForErrors(res); const resJson: SegmentAPISettings = (await res.json()) as SegmentAPISettings; const integrations = resJson.integrations; const consentSettings = resJson.consentSettings; + const edgeFunctionSettings = resJson.edgeFunction; const filters = this.generateFiltersMap( resJson.middlewareSettings?.routingRules ?? [] ); @@ -321,6 +342,7 @@ export class SegmentClient { await Promise.all([ this.store.settings.set(integrations), this.store.consentSettings.set(consentSettings), + this.store.edgeFunctionSettings.set(edgeFunctionSettings), this.store.filters.set(filters), ]); } catch (e) { @@ -422,8 +444,9 @@ export class SegmentClient { this.timeline.remove(plugin); } - async process(incomingEvent: SegmentEvent) { + async process(incomingEvent: SegmentEvent, enrichment?: EnrichmentClosure) { const event = this.applyRawEventData(incomingEvent); + event.enrichment = enrichment; if (this.isReady.value) { return this.startTimelineProcessing(event); @@ -536,47 +559,63 @@ export class SegmentClient { } } - async screen(name: string, options?: JsonMap) { + async screen( + name: string, + options?: JsonMap, + enrichment?: EnrichmentClosure + ) { const event = createScreenEvent({ name, properties: options, }); - await this.process(event); + await this.process(event, enrichment); this.logger.info('SCREEN event saved', event); } - async track(eventName: string, options?: JsonMap) { + async track( + eventName: string, + options?: JsonMap, + enrichment?: EnrichmentClosure + ) { const event = createTrackEvent({ event: eventName, properties: options, }); - await this.process(event); + await this.process(event, enrichment); this.logger.info('TRACK event saved', event); } - async identify(userId?: string, userTraits?: UserTraits) { + async identify( + userId?: string, + userTraits?: UserTraits, + enrichment?: EnrichmentClosure + ) { const event = createIdentifyEvent({ userId: userId, userTraits: userTraits, }); - await this.process(event); + await this.process(event, enrichment); this.logger.info('IDENTIFY event saved', event); } - async group(groupId: string, groupTraits?: GroupTraits) { + async group( + groupId: string, + groupTraits?: GroupTraits, + enrichment?: EnrichmentClosure + ) { const event = createGroupEvent({ groupId, groupTraits, }); - await this.process(event); + await this.process(event, enrichment); this.logger.info('GROUP event saved', event); } - async alias(newUserId: string) { + async alias(newUserId: string, enrichment?: EnrichmentClosure) { // We don't use a concurrency safe version of get here as we don't want to lock the values yet, // we will update the values correctly when InjectUserInfo processes the change const { anonymousId, userId: previousUserId } = this.store.userInfo.get(); @@ -587,7 +626,7 @@ export class SegmentClient { newUserId, }); - await this.process(event); + await this.process(event, enrichment); this.logger.info('ALIAS event saved', event); } @@ -721,7 +760,11 @@ export class SegmentClient { * @param callback Function to call */ onPluginLoaded(callback: OnPluginAddedCallback) { - this.onPluginAddedObservers.push(callback); + const i = this.onPluginAddedObservers.push(callback); + + return () => { + this.onPluginAddedObservers.splice(i, 1); + }; } private triggerOnPluginLoaded(plugin: Plugin) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 363fb7f0..8263f169 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export { defaultConfig } from './constants'; export * from './client'; export * from './plugin'; export * from './types'; @@ -12,8 +13,12 @@ export { objectToString, unknownToString, deepCompare, + chunk, } from './util'; export { SegmentClient } from './analytics'; +export { QueueFlushingPlugin } from './plugins/QueueFlushingPlugin'; +export { createTrackEvent } from './events'; +export { uploadEvents } from './api'; export { SegmentDestination } from './plugins/SegmentDestination'; export { type CategoryConsentStatusProvider, diff --git a/packages/core/src/storage/sovranStorage.ts b/packages/core/src/storage/sovranStorage.ts index 1fdef161..a4e1c98f 100644 --- a/packages/core/src/storage/sovranStorage.ts +++ b/packages/core/src/storage/sovranStorage.ts @@ -15,6 +15,7 @@ import type { RoutingRule, DestinationFilters, SegmentAPIConsentSettings, + EdgeFunctionSettings, } from '..'; import { getUUID } from '../uuid'; import { createGetter } from './helpers'; @@ -35,6 +36,7 @@ type Data = { context: DeepPartial; settings: SegmentAPIIntegrations; consentSettings: SegmentAPIConsentSettings | undefined; + edgeFunctionSettings: EdgeFunctionSettings | undefined; userInfo: UserInfoState; filters: DestinationFilters; pendingEvents: SegmentEvent[]; @@ -44,6 +46,7 @@ const INITIAL_VALUES: Data = { context: {}, settings: {}, consentSettings: undefined, + edgeFunctionSettings: undefined, filters: {}, userInfo: { anonymousId: getUUID(), @@ -146,6 +149,9 @@ export class SovranStorage implements Storage { private consentSettingsStore: Store<{ consentSettings: SegmentAPIConsentSettings | undefined; }>; + private edgeFunctionSettingsStore: Store<{ + edgeFunctionSettings: EdgeFunctionSettings | undefined; + }>; private settingsStore: Store<{ settings: SegmentAPIIntegrations }>; private userInfoStore: Store<{ userInfo: UserInfoState }>; private deepLinkStore: Store = deepLinkStore; @@ -164,6 +170,9 @@ export class SovranStorage implements Storage { readonly consentSettings: Watchable & Settable; + readonly edgeFunctionSettings: Watchable & + Settable; + readonly filters: Watchable & Settable & Dictionary; @@ -323,6 +332,46 @@ export class SovranStorage implements Storage { }, }; + // Edge function settings + + this.edgeFunctionSettingsStore = createStore( + { edgeFunctionSettings: INITIAL_VALUES.edgeFunctionSettings }, + { + persist: { + storeId: `${this.storeId}-edgeFunctionSettings`, + persistor: this.storePersistor, + saveDelay: this.storePersistorSaveDelay, + onInitialized: markAsReadyGenerator('hasRestoredSettings'), + }, + } + ); + + this.edgeFunctionSettings = { + get: createStoreGetter( + this.edgeFunctionSettingsStore, + 'edgeFunctionSettings' + ), + onChange: ( + callback: (value?: EdgeFunctionSettings | undefined) => void + ) => + this.edgeFunctionSettingsStore.subscribe((store) => + callback(store.edgeFunctionSettings) + ), + set: async (value) => { + const { edgeFunctionSettings } = + await this.edgeFunctionSettingsStore.dispatch((state) => { + let newState: typeof state.edgeFunctionSettings; + if (value instanceof Function) { + newState = value(state.edgeFunctionSettings); + } else { + newState = Object.assign({}, state.edgeFunctionSettings, value); + } + return { edgeFunctionSettings: newState }; + }); + return edgeFunctionSettings; + }, + }; + // Filters this.filtersStore = createStore(INITIAL_VALUES.filters, { diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index 2d83f5ab..dcf2b7fe 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -1,5 +1,5 @@ import type { Unsubscribe, Persistor } from '@segment/sovran-react-native'; -import type { SegmentAPIConsentSettings } from '..'; +import type { SegmentAPIConsentSettings, EdgeFunctionSettings } from '..'; import type { Context, DeepPartial, @@ -77,6 +77,9 @@ export interface Storage { readonly consentSettings: Watchable & Settable; + readonly edgeFunctionSettings: Watchable & + Settable; + readonly filters: Watchable & Settable & Dictionary; diff --git a/packages/core/src/test-helpers/mockSegmentStore.ts b/packages/core/src/test-helpers/mockSegmentStore.ts index ff7a5080..983507e1 100644 --- a/packages/core/src/test-helpers/mockSegmentStore.ts +++ b/packages/core/src/test-helpers/mockSegmentStore.ts @@ -11,6 +11,7 @@ import type { Context, DeepPartial, DestinationFilters, + EdgeFunctionSettings, IntegrationSettings, RoutingRule, SegmentAPIConsentSettings, @@ -26,6 +27,7 @@ export type StoreData = { context?: DeepPartial; settings: SegmentAPIIntegrations; consentSettings?: SegmentAPIConsentSettings; + edgeFunctionSettings?: EdgeFunctionSettings; filters: DestinationFilters; userInfo: UserInfoState; deepLinkData: DeepLinkData; @@ -81,6 +83,9 @@ export class MockSegmentStore implements Storage { consentSettings: createCallbackManager< SegmentAPIConsentSettings | undefined >(), + edgeFunctionSettings: createCallbackManager< + EdgeFunctionSettings | undefined + >(), filters: createCallbackManager(), userInfo: createCallbackManager(), deepLinkData: createCallbackManager(), @@ -147,6 +152,21 @@ export class MockSegmentStore implements Storage { }, }; + readonly edgeFunctionSettings: Watchable & + Settable = { + get: createMockStoreGetter(() => this.data.edgeFunctionSettings), + onChange: (callback: (value?: EdgeFunctionSettings) => void) => + this.callbacks.edgeFunctionSettings.register(callback), + set: (value) => { + this.data.edgeFunctionSettings = + value instanceof Function + ? value(this.data.edgeFunctionSettings) + : value; + this.callbacks.edgeFunctionSettings.run(this.data.edgeFunctionSettings); + return this.data.edgeFunctionSettings; + }, + }; + readonly filters: Watchable & Settable & Dictionary = { diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts index 1169b73d..533fc77a 100644 --- a/packages/core/src/timeline.ts +++ b/packages/core/src/timeline.ts @@ -15,7 +15,12 @@ const PLUGIN_ORDER = [ ]; export class Timeline { - plugins: TimelinePlugins = {}; + plugins: TimelinePlugins = { + before: [], + enrichment: [], + destination: [], + after: [], + }; add(plugin: Plugin) { const { type } = plugin; @@ -70,6 +75,8 @@ export class Timeline { if (key !== PluginType.destination) { if (result === undefined) { return; + } else if (key === PluginType.enrichment && pluginResult?.enrichment) { + result = pluginResult.enrichment(pluginResult); } else { result = pluginResult; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 141ca3c4..65d51377 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -34,6 +34,7 @@ interface BaseEventType { context?: PartialContext; integrations?: SegmentAPIIntegrations; _metadata?: DestinationMetadata; + enrichment?: EnrichmentClosure; } export interface TrackEventType extends BaseEventType { @@ -216,6 +217,10 @@ export type Context = { consent?: { categoryPreferences: Record; }; + __eventOrigin?: { + type: string; + version?: string; + }; }; /** @@ -308,8 +313,14 @@ export interface DestinationFilters { [key: string]: RoutingRule; } +export interface EdgeFunctionSettings { + downloadURL: string; + version: string; +} + export type SegmentAPISettings = { integrations: SegmentAPIIntegrations; + edgeFunction?: EdgeFunctionSettings; middlewareSettings?: { routingRules: RoutingRule[]; }; @@ -363,3 +374,5 @@ export interface GetContextConfig { export type AnalyticsReactNativeModule = NativeModule & { getContextInfo: (config: GetContextConfig) => Promise; }; + +export type EnrichmentClosure = (event: SegmentEvent) => SegmentEvent; diff --git a/packages/plugins/plugin-facebook-app-events/src/FacebookAppEventsPlugin.ts b/packages/plugins/plugin-facebook-app-events/src/FacebookAppEventsPlugin.ts index 7486835c..cddc2560 100644 --- a/packages/plugins/plugin-facebook-app-events/src/FacebookAppEventsPlugin.ts +++ b/packages/plugins/plugin-facebook-app-events/src/FacebookAppEventsPlugin.ts @@ -168,7 +168,7 @@ export class FacebookAppEventsPlugin extends DestinationPlugin { const purchasePrice = safeProps._valueToSum as number; AppEventsLogger.logPurchase(purchasePrice, currency, safeProps); - } else if (typeof safeProps._valueToSum === "number") { + } else if (typeof safeProps._valueToSum === 'number') { AppEventsLogger.logEvent(safeName, safeProps._valueToSum, safeProps); } else { AppEventsLogger.logEvent(safeName, safeProps);