diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index 488b68c3..50c1e23f 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -43,8 +43,10 @@ const config = { clientId: process.env.PUBLIC_NINETAILED_CLIENT_ID ?? '', environment: process.env.PUBLIC_NINETAILED_ENVIRONMENT, logLevel: 'debug', - analytics: { baseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL }, + api: { + insightsBaseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + experienceBaseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, + }, }, } as const diff --git a/implementations/node-sdk/src/app.ts b/implementations/node-sdk/src/app.ts index e0ca1787..574c7008 100644 --- a/implementations/node-sdk/src/app.ts +++ b/implementations/node-sdk/src/app.ts @@ -38,8 +38,10 @@ const optimizationConfig: OptimizationNodeConfig = { clientId: process.env.PUBLIC_NINETAILED_CLIENT_ID ?? '', environment: process.env.PUBLIC_NINETAILED_ENVIRONMENT ?? '', logLevel: 'debug', - analytics: { baseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL }, + api: { + insightsBaseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + experienceBaseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, + }, } const sdk = new ContentfulOptimization(optimizationConfig) diff --git a/implementations/react-native-sdk/App.tsx b/implementations/react-native-sdk/App.tsx index 7f8245f4..cb2c9e9b 100644 --- a/implementations/react-native-sdk/App.tsx +++ b/implementations/react-native-sdk/App.tsx @@ -154,13 +154,7 @@ function AppContent(): React.JSX.Element { function App(): React.JSX.Element { return ( - + ) diff --git a/implementations/react-native-sdk/env.config.ts b/implementations/react-native-sdk/env.config.ts index bc465ff2..16489f98 100644 --- a/implementations/react-native-sdk/env.config.ts +++ b/implementations/react-native-sdk/env.config.ts @@ -22,10 +22,10 @@ interface EnvConfig { optimization: { clientId: string environment: string - } - api: { - experienceBaseUrl: string - insightsBaseUrl: string + api: { + experienceBaseUrl: string + insightsBaseUrl: string + } } entries: { personalized: string @@ -56,11 +56,10 @@ export const ENV_CONFIG = { optimization: { clientId: PUBLIC_NINETAILED_CLIENT_ID, environment: PUBLIC_NINETAILED_ENVIRONMENT, - }, - - api: { - experienceBaseUrl: getAndroidCompatibleUrl(PUBLIC_EXPERIENCE_API_BASE_URL), - insightsBaseUrl: getAndroidCompatibleUrl(PUBLIC_INSIGHTS_API_BASE_URL), + api: { + experienceBaseUrl: getAndroidCompatibleUrl(PUBLIC_EXPERIENCE_API_BASE_URL), + insightsBaseUrl: getAndroidCompatibleUrl(PUBLIC_INSIGHTS_API_BASE_URL), + }, }, entries: { diff --git a/implementations/web-sdk/public/index.html b/implementations/web-sdk/public/index.html index 6f21509e..87913e73 100644 --- a/implementations/web-sdk/public/index.html +++ b/implementations/web-sdk/public/index.html @@ -220,8 +220,10 @@

Event Stream

name: document.title, version: '0.0.0', }, - analytics: { baseUrl: '' }, - personalization: { baseUrl: '' }, + api: { + insightsBaseUrl: '', + experienceBaseUrl: '', + }, }) attachOptimizationPreviewPanel({ diff --git a/implementations/web-sdk_react/src/optimization/createOptimization.ts b/implementations/web-sdk_react/src/optimization/createOptimization.ts index 292947fd..90b116b0 100644 --- a/implementations/web-sdk_react/src/optimization/createOptimization.ts +++ b/implementations/web-sdk_react/src/optimization/createOptimization.ts @@ -49,11 +49,9 @@ function createOptimizationConfig(): OptimizationConfig { name: 'ContentfulOptimization SDK - React Web Reference', version: '0.1.0', }, - analytics: { - baseUrl: INSIGHTS_API_BASE_URL, - }, - personalization: { - baseUrl: EXPERIENCE_API_BASE_URL, + api: { + insightsBaseUrl: INSIGHTS_API_BASE_URL, + experienceBaseUrl: EXPERIENCE_API_BASE_URL, }, } } diff --git a/packages/node/node-sdk/server.ts b/packages/node/node-sdk/server.ts index 5c01711e..b38484fe 100644 --- a/packages/node/node-sdk/server.ts +++ b/packages/node/node-sdk/server.ts @@ -24,8 +24,10 @@ const sdk = new ContentfulOptimization({ clientId: process.env.PUBLIC_NINETAILED_CLIENT_ID ?? '', environment: process.env.PUBLIC_NINETAILED_ENVIRONMENT ?? '', logLevel: 'debug', - analytics: { baseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL }, + api: { + insightsBaseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + experienceBaseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, + }, }) const ctfl = contentful.createClient({ diff --git a/packages/node/node-sdk/src/ContentfulOptimization.ts b/packages/node/node-sdk/src/ContentfulOptimization.ts index bb1aac35..3846efd7 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.ts @@ -67,8 +67,8 @@ function mergeConfig(config: OptimizationNodeConfig): CoreStatelessConfig { * @remarks * This class adapts the stateless ContentfulOptimization Core for Node runtimes by * applying environment-appropriate defaults (e.g., server channel, Node SDK - * library metadata). No analytics or personalization behavior is modified— - * only configuration defaults differ. + * library metadata). No core runtime behavior is modified; only configuration + * defaults differ. * * @example * ```ts diff --git a/packages/react-native-sdk/dev/utils/sdkHelpers.ts b/packages/react-native-sdk/dev/utils/sdkHelpers.ts index 84f1bc78..9dde2e11 100644 --- a/packages/react-native-sdk/dev/utils/sdkHelpers.ts +++ b/packages/react-native-sdk/dev/utils/sdkHelpers.ts @@ -24,8 +24,10 @@ export async function initializeSDK( const sdkInstance = await ContentfulOptimization.create({ clientId, environment, - personalization: { baseUrl: experienceBaseUrl }, - analytics: { baseUrl: insightsBaseUrl }, + api: { + experienceBaseUrl, + insightsBaseUrl, + }, logLevel: 'debug', }) diff --git a/packages/react-native-sdk/src/handlers/createOnlineChangeListener.ts b/packages/react-native-sdk/src/handlers/createOnlineChangeListener.ts index eb07dc2f..c21e2a5b 100644 --- a/packages/react-native-sdk/src/handlers/createOnlineChangeListener.ts +++ b/packages/react-native-sdk/src/handlers/createOnlineChangeListener.ts @@ -64,7 +64,7 @@ const loadNetInfoModule = async (): Promise => { * @example * ```ts * const cleanup = createOnlineChangeListener(async (isOnline) => { - * if (isOnline) await sdk.analytics.flush() + * if (isOnline) await sdk.flush() * }) * * // Later: diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index a664b0d8..5260672a 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -4,7 +4,7 @@

-

Contentful Personalization & Analytics

+

Contentful Optimization Core SDK

Optimization Core SDK

@@ -31,16 +31,16 @@ other SDKs descend from the Core SDK. - [Working with Stateful Core](#working-with-stateful-core) - [Configuration](#configuration) - [Top-level Configuration Options](#top-level-configuration-options) - - [Analytics Options](#analytics-options) + - [API Options](#api-options) - [Event Builder Options](#event-builder-options) - [Fetch Options](#fetch-options) - - [Personalization Options](#personalization-options) + - [Queue Policy Options](#queue-policy-options) - [Core Methods](#core-methods) - [Personalization Data Resolution Methods](#personalization-data-resolution-methods) - [`getFlag`](#getflag) - [`personalizeEntry`](#personalizeentry) - [`getMergeTagValue`](#getmergetagvalue) - - [Personalization and Analytics Event Methods](#personalization-and-analytics-event-methods) + - [Event Methods](#event-methods) - [`identify`](#identify) - [`page`](#page) - [`screen`](#screen) @@ -115,15 +115,14 @@ exposed externally as read-only observables. ### Top-level Configuration Options -| Option | Required? | Default | Description | -| ----------------- | --------- | ----------------------------- | ------------------------------------------------------------ | -| `analytics` | No | See "Analytics Options" | Configuration specific to the Analytics/Insights API | -| `clientId` | Yes | N/A | The Optimization API key | -| `environment` | No | `'main'` | The environment identifier | -| `eventBuilder` | No | See "Event Builder Options" | Event builder configuration (channel/library metadata, etc.) | -| `fetchOptions` | No | See "Fetch Options" | Configuration for Fetch timeout and retry functionality | -| `logLevel` | No | `'error'` | Minimum log level for the default console sink | -| `personalization` | No | See "Personalization Options" | Configuration specific to the Personalization/Experience API | +| Option | Required? | Default | Description | +| -------------- | --------- | --------------------------- | ------------------------------------------------------------ | +| `api` | No | See "API Options" | Unified configuration for Insights and Experience endpoints | +| `clientId` | Yes | N/A | The Optimization API key | +| `environment` | No | `'main'` | The environment identifier | +| `eventBuilder` | No | See "Event Builder Options" | Event builder configuration (channel/library metadata, etc.) | +| `fetchOptions` | No | See "Fetch Options" | Configuration for Fetch timeout and retry functionality | +| `logLevel` | No | `'error'` | Minimum log level for the default console sink | The following configuration options apply only in stateful environments: @@ -133,32 +132,45 @@ The following configuration options apply only in stateful environments: | `defaults` | No | `undefined` | Set of default state values applied on initialization | | `getAnonymousId` | No | `undefined` | Function used to obtain an anonymous user identifier | | `onEventBlocked` | No | `undefined` | Callback invoked when an event call is blocked by guards | +| `queuePolicy` | No | See "Queue Policy Options" | Shared queue and retry configuration for stateful delivery | Configuration method signatures: - `getAnonymousId`: `() => string | undefined` - `onEventBlocked`: `(event: BlockedEvent) => void` -### Analytics Options +### API Options -| Option | Required? | Default | Description | -| --------- | --------- | ------------------------------------------ | ----------------------------- | -| `baseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| Option | Required? | Default | Description | +| ------------------- | --------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features the Experience API may use for each request | +| `ip` | No | `undefined` | IP address override used by Experience for location analysis | +| `locale` | No | `'en-US'` (in API) | Locale used to translate `location.city` and `location.country` | +| `plainText` | No | `false` | Sends performance-critical Experience endpoints in plain text | +| `preflight` | No | `false` | Instructs Experience to aggregate a new profile state but not store it | -The following configuration options apply only in stateful environments: +The following configuration option applies only in stateful environments: -| Option | Required? | Default | Description | -| --------------- | --------- | --------------------- | ------------------------------------------------------------------------ | -| `beaconHandler` | No | `undefined` | Handler used to enqueue events via the Beacon API or a similar mechanism | -| `queuePolicy` | No | See method signatures | Queue flush retry/backoff/circuit policy for stateful analytics | +| Option | Required? | Default | Description | +| --------------- | --------- | ----------- | ------------------------------------------------------------------------ | +| `beaconHandler` | No | `undefined` | Handler used to enqueue Insights events via the Beacon API or equivalent | Configuration method signatures: - `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` -- `queuePolicy`: - ```ts - { +### Queue Policy Options + +`queuePolicy` is available only in `CoreStateful` and combines shared flush retry settings with +Experience offline buffering controls. + +Configuration shape: + +```ts +{ + flush?: { baseBackoffMs?: number, maxBackoffMs?: number, jitterRatio?: number, @@ -167,29 +179,42 @@ Configuration method signatures: onFlushFailure?: (context: QueueFlushFailureContext) => void, onCircuitOpen?: (context: QueueFlushFailureContext) => void, onFlushRecovered?: (context: QueueFlushRecoveredContext) => void - } - ``` + }, + offlineMaxEvents?: number, + onOfflineDrop?: (context: ExperienceQueueDropContext) => void +} +``` - Supporting callback payloads: +Supporting callback payloads: - ```ts - type QueueFlushFailureContext = { - consecutiveFailures: number - queuedBatches: number - queuedEvents: number - retryDelayMs: number - } +```ts +type ExperienceQueueDropContext = { + droppedCount: number + droppedEvents: ExperienceEventArray + maxEvents: number + queuedEvents: number +} + +type QueueFlushFailureContext = { + consecutiveFailures: number + queuedBatches: number + queuedEvents: number + retryDelayMs: number +} + +type QueueFlushRecoveredContext = { + consecutiveFailures: number +} +``` - type QueueFlushRecoveredContext = { - consecutiveFailures: number - } - ``` +Notes: - Notes: - - Invalid numeric values fall back to defaults. - - `jitterRatio` is clamped to `[0, 1]`. - - `maxBackoffMs` is normalized to be at least `baseBackoffMs`. - - Failed flush attempts include both `false` responses and thrown send errors. +- `flush` applies the same retry/backoff/circuit policy to both Insights flushing and Experience + offline replay. +- Invalid numeric values fall back to defaults. +- `jitterRatio` is clamped to `[0, 1]`. +- `maxBackoffMs` is normalized to be at least `baseBackoffMs`. +- Failed flush attempts include both `false` responses and thrown send errors. ### Event Builder Options @@ -262,72 +287,6 @@ Configuration method signatures: > and Insights API expectations; do not broaden retry status handling without an explicit API > contract change. -### Personalization Options - -| Option | Required? | Default | Description | -| ----------------- | --------- | ------------------------------------- | ------------------------------------------------------------------- | -| `baseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | -| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features which the API may use for each request | -| `ip` | No | `undefined` | IP address to override the API behavior for IP analysis | -| `locale` | No | `'en-US'` (in API) | Locale used to translate `location.city` and `location.country` | -| `plainText` | No | `false` | Sends performance-critical endpoints in plain text | -| `preflight` | No | `false` | Instructs the API to aggregate a new profile state but not store it | - -The following configuration options apply only in stateful environments: - -| Option | Required? | Default | Description | -| ------------- | --------- | --------------------- | --------------------------------------------------------------------------- | -| `queuePolicy` | No | See method signatures | Queue and flush-retry policy for stateful personalization offline buffering | - -Configuration method signatures: - -- `queuePolicy`: - - ```ts - { - maxEvents?: number, - onDrop?: (context: PersonalizationOfflineQueueDropContext) => void, - flushPolicy?: { - baseBackoffMs?: number, - maxBackoffMs?: number, - jitterRatio?: number, - maxConsecutiveFailures?: number, - circuitOpenMs?: number, - onFlushFailure?: (context: QueueFlushFailureContext) => void, - onCircuitOpen?: (context: QueueFlushFailureContext) => void, - onFlushRecovered?: (context: QueueFlushRecoveredContext) => void - } - } - ``` - - Supporting callback payloads: - - ```ts - type PersonalizationOfflineQueueDropContext = { - droppedCount: number - droppedEvents: ExperienceEventArray - maxEvents: number - queuedEvents: number - } - - type QueueFlushFailureContext = { - consecutiveFailures: number - queuedBatches: number - queuedEvents: number - retryDelayMs: number - } - - type QueueFlushRecoveredContext = { - consecutiveFailures: number - } - ``` - - Notes: - - Default `maxEvents` is `100`. - - If the queue is full while offline, oldest events are dropped first. - - `onDrop` is best-effort; callback errors are swallowed. - - `flushPolicy` uses the same normalization semantics as `analytics.queuePolicy`. - ## Core Methods The methods in this section are available in both stateful and stateless Core classes. However, be @@ -407,7 +366,7 @@ Arguments: > If the `profile` argument is omitted in stateless implementations, the method will return the > merge tag's fallback value. -### Personalization and Analytics Event Methods +### Event Methods Only the following methods may return an `OptimizationData` object: @@ -524,7 +483,7 @@ Resets all internal state _except_ consent. This method expects no arguments and ### `flush` -Flushes queued analytics and personalization events. This method expects no arguments and returns a +Flushes queued Insights and Experience events. This method expects no arguments and returns a `Promise`. ### `destroy` @@ -581,7 +540,7 @@ Available state streams: - `consent`: Current consent state (`boolean | undefined`) - `blockedEventStream`: Latest blocked-call metadata (`BlockedEvent | undefined`) -- `eventStream`: Latest emitted analytics/personalization event +- `eventStream`: Latest emitted Insights or Experience event (`AnalyticsEvent | PersonalizationEvent | undefined`) - `flag(name)`: Key-scoped flag observable (`Observable`) - `canPersonalize`: Whether personalization selections are available (`boolean`; diff --git a/packages/universal/core-sdk/src/BlockedEvent.ts b/packages/universal/core-sdk/src/BlockedEvent.ts index 26231dee..c1b0272a 100644 --- a/packages/universal/core-sdk/src/BlockedEvent.ts +++ b/packages/universal/core-sdk/src/BlockedEvent.ts @@ -5,13 +5,6 @@ */ export type BlockedEventReason = 'consent' -/** - * Product that blocked the event. - * - * @public - */ -export type BlockedEventProduct = 'analytics' | 'personalization' - /** * Payload emitted when event processing is blocked. * @@ -20,8 +13,6 @@ export type BlockedEventProduct = 'analytics' | 'personalization' export interface BlockedEvent { /** Why the event was blocked. */ reason: BlockedEventReason - /** Product that blocked the event. */ - product: BlockedEventProduct /** Method name that was blocked. */ method: string /** Original arguments passed to the blocked method call. */ diff --git a/packages/universal/core-sdk/src/CoreBase.test.ts b/packages/universal/core-sdk/src/CoreBase.test.ts index 5c6d9ea8..f563b0e9 100644 --- a/packages/universal/core-sdk/src/CoreBase.test.ts +++ b/packages/universal/core-sdk/src/CoreBase.test.ts @@ -1,21 +1,48 @@ import { EXPERIENCE_BASE_URL } from '@contentful/optimization-api-client' -import type { ChangeArray } from '@contentful/optimization-api-client/api-schemas' -import { AnalyticsStateless } from './analytics' +import type { ChangeArray, ExperienceEvent, InsightsEvent, PartialProfile } from './api-schemas' import { OPTIMIZATION_CORE_SDK_NAME } from './constants' import CoreBase, { type CoreConfig } from './CoreBase' -import { FlagsResolver, PersonalizationStateless } from './personalization' +import { FlagsResolver } from './resolvers' class TestCore extends CoreBase { - _analytics = new AnalyticsStateless({ - api: this.api, - eventBuilder: this.eventBuilder, - interceptors: this.interceptors, - }) - _personalization = new PersonalizationStateless({ - api: this.api, - eventBuilder: this.eventBuilder, - interceptors: this.interceptors, - }) + lastExperienceCall: + | { + method: string + args: readonly unknown[] + event: ExperienceEvent + profile?: PartialProfile + } + | undefined + + lastInsightsCall: + | { + method: string + args: readonly unknown[] + event: InsightsEvent + profile?: PartialProfile + } + | undefined + + protected override async sendExperienceEvent( + method: string, + args: readonly unknown[], + event: ExperienceEvent, + profile?: PartialProfile, + ): Promise { + await Promise.resolve() + this.lastExperienceCall = { method, args, event, profile } + return undefined + } + + protected override async sendInsightsEvent( + method: string, + args: readonly unknown[], + event: InsightsEvent, + profile?: PartialProfile, + ): Promise { + await Promise.resolve() + this.lastInsightsCall = { method, args, event, profile } + } } const CLIENT_ID = 'key_123' @@ -60,11 +87,13 @@ describe('CoreBase', () => { expect(core.eventBuilder.library.name).toEqual(OPTIMIZATION_CORE_SDK_NAME) }) - it('keeps analytics and personalization client config isolated', () => { + it('keeps insights and Experience client config isolated', () => { const core = new TestCore({ clientId: CLIENT_ID, - analytics: { baseUrl: 'https://ingest.example.test/' }, - personalization: { baseUrl: 'https://experience.example.test/' }, + api: { + insightsBaseUrl: 'https://ingest.example.test/', + experienceBaseUrl: 'https://experience.example.test/', + }, }) expect(Reflect.get(core.api.insights, 'baseUrl')).toBe('https://ingest.example.test/') @@ -74,7 +103,7 @@ describe('CoreBase', () => { it('falls back to default base URLs when only one side is configured', () => { const core = new TestCore({ clientId: CLIENT_ID, - analytics: { baseUrl: 'https://ingest.example.test/' }, + api: { insightsBaseUrl: 'https://ingest.example.test/' }, }) expect(Reflect.get(core.api.insights, 'baseUrl')).toBe('https://ingest.example.test/') @@ -108,4 +137,50 @@ describe('CoreBase', () => { }) expect(trackFlagView).not.toHaveBeenCalled() }) + + it('routes sticky component views through both Experience and Insights paths', async () => { + const core = new TestCore(config) + const profile = { id: 'profile-1' } + + await core.trackView({ + componentId: 'hero', + sticky: true, + viewId: 'hero-view', + viewDurationMs: 1000, + profile, + }) + + expect(core.lastExperienceCall).toEqual( + expect.objectContaining({ + method: 'trackView', + profile, + event: expect.objectContaining({ type: 'component' }), + }), + ) + expect(core.lastInsightsCall).toEqual( + expect.objectContaining({ + method: 'trackView', + profile, + event: expect.objectContaining({ type: 'component' }), + }), + ) + }) + + it('routes non-sticky component views only through Insights', async () => { + const core = new TestCore(config) + + await core.trackView({ + componentId: 'hero', + viewId: 'hero-view', + viewDurationMs: 1000, + }) + + expect(core.lastExperienceCall).toBeUndefined() + expect(core.lastInsightsCall).toEqual( + expect.objectContaining({ + method: 'trackView', + event: expect.objectContaining({ type: 'component' }), + }), + ) + }) }) diff --git a/packages/universal/core-sdk/src/CoreBase.ts b/packages/universal/core-sdk/src/CoreBase.ts index 6cdfbcea..438b60f8 100644 --- a/packages/universal/core-sdk/src/CoreBase.ts +++ b/packages/universal/core-sdk/src/CoreBase.ts @@ -6,20 +6,21 @@ import { type InsightsApiClientConfig, } from '@contentful/optimization-api-client' import type { - InsightsEvent as AnalyticsEvent, ChangeArray, + ExperienceEvent as ExperienceEventPayload, + ExperienceEventType, + InsightsEvent as InsightsEventPayload, + InsightsEventType, Json, MergeTagEntry, OptimizationData, PartialProfile, - ExperienceEvent as PersonalizationEvent, Profile, SelectedPersonalizationArray, } from '@contentful/optimization-api-client/api-schemas' import type { LogLevels } from '@contentful/optimization-api-client/logger' import { ConsoleLogSink, logger } from '@contentful/optimization-api-client/logger' import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' -import type AnalyticsBase from './analytics/AnalyticsBase' import { OPTIMIZATION_CORE_SDK_NAME, OPTIMIZATION_CORE_SDK_VERSION } from './constants' import { type ClickBuilderArgs, @@ -34,14 +35,39 @@ import { type ViewBuilderArgs, } from './events' import { InterceptorManager } from './lib/interceptor' -import type { - FlagsResolver, - MergeTagValueResolver, - PersonalizationBase, - PersonalizedEntryResolver, - ResolvedData, - ResolverMethods, -} from './personalization' +import type { ResolvedData } from './resolvers' +import { FlagsResolver, MergeTagValueResolver, PersonalizedEntryResolver } from './resolvers' + +/** + * Unified API configuration for Core. + * + * @public + */ +export interface CoreApiConfig { + /** Base URL override for Experience API requests. */ + experienceBaseUrl?: ExperienceApiClientConfig['baseUrl'] + /** Base URL override for Insights API requests. */ + insightsBaseUrl?: InsightsApiClientConfig['baseUrl'] + /** Beacon-like handler used by Insights event delivery when available. */ + beaconHandler?: InsightsApiClientConfig['beaconHandler'] + /** Experience API features enabled for outgoing requests. */ + enabledFeatures?: ExperienceApiClientConfig['enabledFeatures'] + /** Experience API IP override. */ + ip?: ExperienceApiClientConfig['ip'] + /** Experience API locale override. */ + locale?: ExperienceApiClientConfig['locale'] + /** Experience API plain-text request toggle. */ + plainText?: ExperienceApiClientConfig['plainText'] + /** Experience API preflight request toggle. */ + preflight?: ExperienceApiClientConfig['preflight'] +} + +/** + * Union of all event type keys that Core may emit. + * + * @public + */ +export type EventType = InsightsEventType | ExperienceEventType /** * Lifecycle container for event and state interceptors. @@ -50,7 +76,7 @@ import type { */ export interface LifecycleInterceptors { /** Interceptors invoked for individual events prior to validation/sending. */ - event: InterceptorManager + event: InterceptorManager /** Interceptors invoked before optimization state updates. */ state: InterceptorManager } @@ -62,14 +88,9 @@ export interface LifecycleInterceptors { */ export interface CoreConfig extends Pick { /** - * Configuration for the personalization (Experience) API client. + * Unified API configuration used by Experience and Insights clients. */ - personalization?: Omit - - /** - * Configuration for the analytics (Insights) API client. - */ - analytics?: Omit + api?: CoreApiConfig /** * Event builder configuration (channel/library metadata, etc.). @@ -85,22 +106,23 @@ export interface CoreConfig extends Pick + /** Resolved core configuration. */ + readonly config: CoreConfig + /** Static resolver for evaluating personalized custom flags. */ + readonly flagsResolver = FlagsResolver + /** Static resolver for merge-tag lookups against profile data. */ + readonly mergeTagValueResolver = MergeTagValueResolver + /** Static resolver for personalized Contentful entries. */ + readonly personalizedEntryResolver = PersonalizedEntryResolver /** Lifecycle interceptors for events and state updates. */ readonly interceptors: LifecycleInterceptors = { - event: new InterceptorManager(), + event: new InterceptorManager(), state: new InterceptorManager(), } @@ -116,15 +138,7 @@ abstract class CoreBase implements ResolverMethods { constructor(config: CoreConfig) { this.config = config - const { - analytics, - personalization, - eventBuilder, - logLevel, - environment, - clientId, - fetchOptions, - } = config + const { api, eventBuilder, logLevel, environment, clientId, fetchOptions } = config logger.addSink(new ConsoleLogSink(logLevel)) @@ -132,8 +146,8 @@ abstract class CoreBase implements ResolverMethods { clientId, environment, fetchOptions, - analytics, - personalization, + analytics: CoreBase.createInsightsApiConfig(api), + personalization: CoreBase.createExperienceApiConfig(api), } this.api = new ApiClient(apiConfig) @@ -146,32 +160,46 @@ abstract class CoreBase implements ResolverMethods { ) } - /** - * Static {@link FlagsResolver | resolver} for evaluating personalized - * custom flags. - */ - get flagsResolver(): typeof FlagsResolver { - return this._personalization.flagsResolver - } + private static createExperienceApiConfig( + api: CoreApiConfig | undefined, + ): ApiClientConfig['personalization'] { + if (api === undefined) return undefined + + const { enabledFeatures, experienceBaseUrl: baseUrl, ip, locale, plainText, preflight } = api + + if ( + baseUrl === undefined && + enabledFeatures === undefined && + ip === undefined && + locale === undefined && + plainText === undefined && + preflight === undefined + ) { + return undefined + } - /** - * Static {@link MergeTagValueResolver | resolver} that returns values - * sourced from a user profile based on a Contentful Merge Tag entry. - */ - get mergeTagValueResolver(): typeof MergeTagValueResolver { - return this._personalization.mergeTagValueResolver + return { + baseUrl, + enabledFeatures, + ip, + locale, + plainText, + preflight, + } } - /** - * Static {@link PersonalizedEntryResolver | resolver } for personalized - * Contentful entries (e.g., entry variants targeted to a profile audience). - * - * @remarks - * Used by higher-level personalization flows to materialize entry content - * prior to event emission. - */ - get personalizedEntryResolver(): typeof PersonalizedEntryResolver { - return this._personalization.personalizedEntryResolver + private static createInsightsApiConfig( + api: CoreApiConfig | undefined, + ): ApiClientConfig['analytics'] { + if (api === undefined) return undefined + + const { beaconHandler, insightsBaseUrl: baseUrl } = api + + if (baseUrl === undefined && beaconHandler === undefined) { + return undefined + } + + return { baseUrl, beaconHandler } } /** @@ -181,14 +209,14 @@ abstract class CoreBase implements ResolverMethods { * @param changes - Optional change list to resolve from. * @returns The resolved JSON value for the flag if available. * @remarks - * This is a convenience wrapper around personalization’s flag resolution. + * This is a convenience wrapper around Core's shared flag resolution. * @example * ```ts * const darkMode = core.getFlag('dark-mode', data.changes) * ``` */ getFlag(name: string, changes?: ChangeArray): Json { - return this._personalization.getFlag(name, changes) + return this.flagsResolver.resolve(changes)[name] } /** @@ -230,7 +258,7 @@ abstract class CoreBase implements ResolverMethods { entry: Entry, selectedPersonalizations?: SelectedPersonalizationArray, ): ResolvedData { - return this._personalization.personalizeEntry(entry, selectedPersonalizations) + return this.personalizedEntryResolver.resolve(entry, selectedPersonalizations) } /** @@ -245,11 +273,25 @@ abstract class CoreBase implements ResolverMethods { * ``` */ getMergeTagValue(embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile): string | undefined { - return this._personalization.getMergeTagValue(embeddedEntryNodeTarget, profile) + return this.mergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) } + protected abstract sendExperienceEvent( + method: string, + args: readonly unknown[], + event: ExperienceEventPayload, + profile?: PartialProfile, + ): Promise + + protected abstract sendInsightsEvent( + method: string, + args: readonly unknown[], + event: InsightsEventPayload, + profile?: PartialProfile, + ): Promise + /** - * Convenience wrapper for sending an `identify` event via personalization. + * Convenience wrapper for sending an `identify` event through the Experience path. * * @param payload - Identify builder arguments. * @returns The resulting {@link OptimizationData} for the identified user. @@ -261,11 +303,18 @@ abstract class CoreBase implements ResolverMethods { async identify( payload: IdentifyBuilderArgs & { profile?: PartialProfile }, ): Promise { - return await this._personalization.identify(payload) + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + 'identify', + [payload], + this.eventBuilder.buildIdentify(builderArgs), + profile, + ) } /** - * Convenience wrapper for sending a `page` event via personalization. + * Convenience wrapper for sending a `page` event through the Experience path. * * @param payload - Page view builder arguments. * @returns The evaluated {@link OptimizationData} for this page view. @@ -275,13 +324,20 @@ abstract class CoreBase implements ResolverMethods { * ``` */ async page( - payload: PageViewBuilderArgs & { profile?: PartialProfile }, + payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, ): Promise { - return await this._personalization.page(payload) + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + 'page', + [payload], + this.eventBuilder.buildPageView(builderArgs), + profile, + ) } /** - * Convenience wrapper for sending a `screen` event via personalization. + * Convenience wrapper for sending a `screen` event through the Experience path. * * @param payload - Screen view builder arguments. * @returns The evaluated {@link OptimizationData} for this screen view. @@ -293,11 +349,18 @@ abstract class CoreBase implements ResolverMethods { async screen( payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, ): Promise { - return await this._personalization.screen(payload) + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + 'screen', + [payload], + this.eventBuilder.buildScreenView(builderArgs), + profile, + ) } /** - * Convenience wrapper for sending a custom `track` event via personalization. + * Convenience wrapper for sending a custom `track` event through the Experience path. * * @param payload - Track builder arguments. * @returns The evaluated {@link OptimizationData} for this event. @@ -309,19 +372,26 @@ abstract class CoreBase implements ResolverMethods { async track( payload: TrackBuilderArgs & { profile?: PartialProfile }, ): Promise { - return await this._personalization.track(payload) + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + 'track', + [payload], + this.eventBuilder.buildTrack(builderArgs), + profile, + ) } /** - * Track a component view in both personalization and analytics. + * Track a component view in both Experience and Insights. * * @param payload - Component view builder arguments. When `payload.sticky` is - * `true`, the event will also be sent via personalization as a sticky + * `true`, the event will also be sent through Experience as a sticky * component view. * @returns A promise that resolves when all delegated calls complete. * @remarks - * The sticky behavior is delegated to personalization; analytics is always - * invoked regardless of `sticky`. + * Experience receives sticky views only; Insights is always invoked regardless + * of `sticky`. * @example * ```ts * await core.trackView({ componentId: 'hero-banner', sticky: true }) @@ -330,19 +400,30 @@ abstract class CoreBase implements ResolverMethods { async trackView( payload: ViewBuilderArgs & { profile?: PartialProfile }, ): Promise { + const { profile, ...builderArgs } = payload let result = undefined if (payload.sticky) { - result = await this._personalization.trackView(payload) + result = await this.sendExperienceEvent( + 'trackView', + [payload], + this.eventBuilder.buildView(builderArgs), + profile, + ) } - await this._analytics.trackView(payload) + await this.sendInsightsEvent( + 'trackView', + [payload], + this.eventBuilder.buildView(builderArgs), + profile, + ) return result } /** - * Track a component click via analytics. + * Track a component click through Insights. * * @param payload - Component click builder arguments. * @returns A promise that resolves when processing completes. @@ -352,11 +433,11 @@ abstract class CoreBase implements ResolverMethods { * ``` */ async trackClick(payload: ClickBuilderArgs): Promise { - await this._analytics.trackClick(payload) + await this.sendInsightsEvent('trackClick', [payload], this.eventBuilder.buildClick(payload)) } /** - * Track a component hover via analytics. + * Track a component hover through Insights. * * @param payload - Component hover builder arguments. * @returns A promise that resolves when processing completes. @@ -366,11 +447,11 @@ abstract class CoreBase implements ResolverMethods { * ``` */ async trackHover(payload: HoverBuilderArgs): Promise { - await this._analytics.trackHover(payload) + await this.sendInsightsEvent('trackHover', [payload], this.eventBuilder.buildHover(payload)) } /** - * Track a feature flag view via analytics. + * Track a feature flag view through Insights. * * @param payload - Component view builder arguments used to build the flag view event. * @returns A promise that resolves when processing completes. @@ -380,7 +461,11 @@ abstract class CoreBase implements ResolverMethods { * ``` */ async trackFlagView(payload: FlagViewBuilderArgs): Promise { - await this._analytics.trackFlagView(payload) + await this.sendInsightsEvent( + 'trackFlagView', + [payload], + this.eventBuilder.buildFlagView(payload), + ) } } diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index 650fa59f..eefa8ca6 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -8,7 +8,10 @@ import type { TrackBuilderArgs, ViewBuilderArgs } from './events' import type { QueueFlushFailureContext } from './lib/queue' import { batch, signalFns, signals } from './signals' import { PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, PREVIEW_PANEL_SIGNALS_SYMBOL } from './symbols' +import { mergeTagEntry } from './test/fixtures/mergeTagEntry' +import { personalizedEntry } from './test/fixtures/personalizedEntry' import { profile as profileFixture } from './test/fixtures/profile' +import { selectedPersonalizations as selectedPersonalizationsFixture } from './test/fixtures/selectedPersonalizations' const config: CoreStatefulConfig = { clientId: 'key_123', @@ -111,14 +114,12 @@ describe('CoreStateful blocked event handling', () => { expect(onEventBlocked).toHaveBeenCalledWith( expect.objectContaining({ reason: 'consent', - product: 'personalization', method: 'track', }), ) expect(blockedEvents.at(-1)).toEqual( expect.objectContaining({ reason: 'consent', - product: 'personalization', method: 'track', }), ) @@ -161,7 +162,6 @@ describe('CoreStateful blocked event handling', () => { expect(blockedEvents.at(-1)).toEqual( expect.objectContaining({ reason: 'consent', - product: 'personalization', method: 'track', }), ) @@ -169,7 +169,7 @@ describe('CoreStateful blocked event handling', () => { subscription.unsubscribe() }) - it('uses analytics.queuePolicy when provided', async () => { + it('uses shared queuePolicy.flush for insights retries when provided', async () => { rs.useFakeTimers() try { @@ -179,8 +179,8 @@ describe('CoreStateful blocked event handling', () => { consent: true, profile: profileFixture, }, - analytics: { - queuePolicy: { + queuePolicy: { + flush: { baseBackoffMs: 123, jitterRatio: 0, maxBackoffMs: 123, @@ -211,7 +211,7 @@ describe('CoreStateful blocked event handling', () => { } }) - it('uses personalization.queuePolicy when provided', async () => { + it('uses queuePolicy.offlineMaxEvents and onOfflineDrop for Experience buffering', async () => { rs.useFakeTimers() try { @@ -219,16 +219,14 @@ describe('CoreStateful blocked event handling', () => { const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() const core = createCoreStatefulHarness({ defaults: { consent: true }, - personalization: { - queuePolicy: { - maxEvents: 2, - onDrop, - flushPolicy: { - baseBackoffMs: 321, - jitterRatio: 0, - maxBackoffMs: 321, - onFlushFailure, - }, + queuePolicy: { + offlineMaxEvents: 2, + onOfflineDrop: onDrop, + flush: { + baseBackoffMs: 321, + jitterRatio: 0, + maxBackoffMs: 321, + onFlushFailure, }, }, }) @@ -279,42 +277,30 @@ describe('CoreStateful blocked event handling', () => { }).not.toThrow() }) - it('flushes analytics and personalization queues with force on destroy', () => { - const core = createCoreStateful() - const analytics: unknown = Reflect.get(core, '_analytics') - const personalization: unknown = Reflect.get(core, '_personalization') - - if ( - !( - typeof analytics === 'object' && - analytics !== null && - 'flush' in analytics && - typeof analytics.flush === 'function' - ) - ) { - throw new Error('CoreStateful analytics product is unavailable') - } - - if ( - !( - typeof personalization === 'object' && - personalization !== null && - 'flush' in personalization && - typeof personalization.flush === 'function' - ) - ) { - throw new Error('CoreStateful internal products are unavailable') - } + it('flushes insights and Experience queues with force on destroy', async () => { + const core = createCoreStatefulHarness({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + changes: [], + selectedPersonalizations: [], + profile: profileFixture, + }) - const analyticsFlushSpy = rs.spyOn(analytics, 'flush').mockResolvedValue(undefined) - const personalizationFlushSpy = rs.spyOn(personalization, 'flush').mockResolvedValue(undefined) + core.setOnlineState(false) + await core.trackClick({ componentId: 'hero-banner' }) + await core.track({ event: 'queued-experience-event' }) core.destroy() + await Promise.resolve() + await Promise.resolve() - expect(analyticsFlushSpy).toHaveBeenCalledTimes(1) - expect(analyticsFlushSpy).toHaveBeenCalledWith({ force: true }) - expect(personalizationFlushSpy).toHaveBeenCalledTimes(1) - expect(personalizationFlushSpy).toHaveBeenCalledWith({ force: true }) + expect(sendBatchEvents).toHaveBeenCalledTimes(1) + expect(upsertProfile).toHaveBeenCalledTimes(1) }) it('exposes online state through protected accessor pair', () => { @@ -368,6 +354,30 @@ describe('CoreStateful blocked event handling', () => { subscription.unsubscribe() }) + it('defaults personalizeEntry to the selectedPersonalizations signal', () => { + const core = createCoreStateful() + + signals.selectedPersonalizations.value = selectedPersonalizationsFixture + + const result = core.personalizeEntry(personalizedEntry) + + expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb') + expect(result.personalization).toEqual( + expect.objectContaining({ + experienceId: '2qVK4T5lnScbswoyBuGipd', + variantIndex: 1, + }), + ) + }) + + it('defaults getMergeTagValue to the profile signal', () => { + const core = createCoreStateful() + + signals.profile.value = profileFixture + + expect(core.getMergeTagValue(mergeTagEntry)).toBe('EU') + }) + it('auto-tracks getFlag retrievals in stateful environments', () => { const core = createCoreStateful({ defaults: { diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index c657b601..4294b8b9 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -1,50 +1,91 @@ import type { - InsightsEvent as AnalyticsEvent, ChangeArray, + ExperienceEvent as ExperienceEventPayload, + InsightsEvent as InsightsEventPayload, Json, - ExperienceEvent as PersonalizationEvent, + MergeTagEntry, + OptimizationData, + PartialProfile, Profile, SelectedPersonalizationArray, } from '@contentful/optimization-api-client/api-schemas' -import { logger } from '@contentful/optimization-api-client/logger' +import { createScopedLogger, logger } from '@contentful/optimization-api-client/logger' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import { isEqual } from 'es-toolkit/predicate' -import { AnalyticsStateful, type AnalyticsProductConfig, type AnalyticsStates } from './analytics' import type { BlockedEvent } from './BlockedEvent' -import type { ConsentController } from './Consent' -import CoreBase, { type CoreConfig } from './CoreBase' +import type { ConsentController, ConsentGuard } from './Consent' +import CoreBase, { type CoreConfig, type EventType } from './CoreBase' import type { FlagViewBuilderArgs } from './events' +import { toPositiveInt } from './lib/number' +import { type QueueFlushPolicy, resolveQueueFlushPolicy } from './lib/queue' import { acquireStatefulRuntimeSingleton, releaseStatefulRuntimeSingleton, } from './lib/singleton/StatefulRuntimeSingleton' -import { - PersonalizationStateful, - type PersonalizationProductConfig, - type PersonalizationStates, -} from './personalization' -import type { ProductConfig } from './ProductBase' +import { ExperienceQueue, type ExperienceQueueDropContext } from './queues/ExperienceQueue' +import { InsightsQueue } from './queues/InsightsQueue' +import type { ResolvedData } from './resolvers' import { batch, blockedEvent as blockedEventSignal, canPersonalize as canPersonalizeSignal, changes as changesSignal, consent as consentSignal, + effect, event as eventSignal, + type Observable, online as onlineSignal, previewPanelAttached as previewPanelAttachedSignal, previewPanelOpen as previewPanelOpenSignal, profile as profileSignal, selectedPersonalizations as selectedPersonalizationsSignal, signalFns, + type SignalFns, signals, + type Signals, toDistinctObservable, toObservable, - type Observable, - type SignalFns, - type Signals, } from './signals' import { PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, PREVIEW_PANEL_SIGNALS_SYMBOL } from './symbols' +const coreLogger = createScopedLogger('CoreStateful') + +const DEFAULT_ALLOWED_EVENT_TYPES: EventType[] = ['identify', 'page', 'screen'] +const OFFLINE_QUEUE_MAX_EVENTS = 100 +const CONSENT_EVENT_TYPE_MAP: Readonly>> = { + trackView: 'component', + trackFlagView: 'component', + trackClick: 'component_click', + trackHover: 'component_hover', +} +export type { ExperienceQueueDropContext } from './queues/ExperienceQueue' + +/** + * Unified queue policy for stateful Core. + * + * @public + */ +export interface QueuePolicy { + /** Shared retry/backoff/circuit policy for queued flushes. */ + flush?: QueueFlushPolicy + /** Maximum number of offline Experience events retained. */ + offlineMaxEvents?: number + /** Callback invoked when oldest offline Experience events are dropped. */ + onOfflineDrop?: (context: ExperienceQueueDropContext) => void +} + +interface ResolvedQueuePolicy { + flush: ReturnType + offlineMaxEvents: number + onOfflineDrop?: QueuePolicy['onOfflineDrop'] +} + +const resolveQueuePolicy = (policy: QueuePolicy | undefined): ResolvedQueuePolicy => ({ + flush: resolveQueueFlushPolicy(policy?.flush), + offlineMaxEvents: toPositiveInt(policy?.offlineMaxEvents, OFFLINE_QUEUE_MAX_EVENTS), + onOfflineDrop: policy?.onOfflineDrop, +}) + /** * Symbol-keyed signal bridge shared between core and first-party preview tooling. * @@ -61,10 +102,8 @@ export interface PreviewPanelSignalObject { * Combined observable state exposed by the stateful core. * * @public - * @see {@link AnalyticsStates} - * @see {@link PersonalizationStates} */ -export interface CoreStates extends AnalyticsStates, PersonalizationStates { +export interface CoreStates { /** Current consent value (if any). */ consent: Observable /** Whether the preview panel has been attached to the host runtime. */ @@ -73,19 +112,27 @@ export interface CoreStates extends AnalyticsStates, PersonalizationStates { previewPanelOpen: Observable /** Stream of the most recent blocked event payload. */ blockedEventStream: Observable - /** Stream of the most recent event emitted (analytics or personalization). */ - eventStream: Observable + /** Stream of the most recent event emitted. */ + eventStream: Observable + /** Key-scoped observable for a single Custom Flag value. */ + flag: (name: string) => Observable + /** Live view of the current profile. */ + profile: Observable + /** Live view of selected personalizations (variants). */ + selectedPersonalizations: Observable + /** Whether personalization data is currently available. */ + canPersonalize: Observable } /** - * Default values used to preconfigure the stateful core and products. + * Default values used to preconfigure the stateful core. * * @public */ export interface CoreConfigDefaults { /** Global consent default applied at construction time. */ consent?: boolean - /** Default active profile used for personalization and analytics. */ + /** Default active profile used for personalization and insights. */ profile?: Profile /** Initial diff of changes produced by the service. */ changes?: ChangeArray @@ -93,126 +140,50 @@ export interface CoreConfigDefaults { personalizations?: SelectedPersonalizationArray } -/** - * Stateful analytics configuration. - * - * @public - */ -export type CoreStatefulAnalyticsConfig = NonNullable & { - /** - * Queue policy for stateful analytics event buffering and flush retries. - * - * @see {@link AnalyticsProductConfig.queuePolicy} - */ - queuePolicy?: AnalyticsProductConfig['queuePolicy'] -} - -/** - * Stateful personalization configuration. - * - * @public - */ -export type CoreStatefulPersonalizationConfig = NonNullable & { - /** - * Queue policy for stateful personalization offline event buffering. - * - * @see {@link PersonalizationProductConfig.queuePolicy} - */ - queuePolicy?: PersonalizationProductConfig['queuePolicy'] -} - -const splitScopedQueuePolicy = < - TQueuePolicy, - TScopedConfig extends { - queuePolicy?: TQueuePolicy - }, ->( - config: TScopedConfig | undefined, -): { - apiConfig: Omit | undefined - queuePolicy: TQueuePolicy | undefined -} => { - if (config === undefined) { - return { - apiConfig: undefined, - queuePolicy: undefined, - } - } - - const { queuePolicy, ...apiConfig } = config - const resolvedApiConfig = Object.keys(apiConfig).length > 0 ? apiConfig : undefined - - return { - apiConfig: resolvedApiConfig, - queuePolicy, - } -} - /** * Configuration for {@link CoreStateful}. * * @public - * @see {@link CoreConfig} */ export interface CoreStatefulConfig extends CoreConfig { - /** - * Configuration for the analytics (Insights) API client plus stateful queue behavior. - */ - analytics?: CoreStatefulAnalyticsConfig - - /** - * Configuration for the personalization (Experience) API client plus stateful queue behavior. - */ - personalization?: CoreStatefulPersonalizationConfig - /** * Allow-listed event type strings permitted when consent is not set. - * - * @see {@link ProductConfig.allowedEventTypes} */ - allowedEventTypes?: ProductConfig['allowedEventTypes'] + allowedEventTypes?: EventType[] /** Optional set of default values applied on initialization. */ defaults?: CoreConfigDefaults /** Function used to obtain an anonymous user identifier. */ - getAnonymousId?: PersonalizationProductConfig['getAnonymousId'] + getAnonymousId?: () => string | undefined /** * Callback invoked whenever an event call is blocked by checks. */ - onEventBlocked?: ProductConfig['onEventBlocked'] + onEventBlocked?: (event: BlockedEvent) => void + + /** Unified queue policy for queued stateful work. */ + queuePolicy?: QueuePolicy } let statefulInstanceCounter = 0 /** - * Core runtime that constructs stateful product instances and exposes shared - * states, including consent, blocked events, and the event stream. + * Core runtime that owns stateful event delivery, consent, and shared signals. * - * @remarks - * Extends {@link CoreBase} with stateful capabilities, including - * consent management via {@link ConsentController}. - * @see {@link CoreBase} - * @see {@link ConsentController} * @public */ -class CoreStateful extends CoreBase implements ConsentController { +class CoreStateful extends CoreBase implements ConsentController, ConsentGuard { private readonly singletonOwner: string private destroyed = false private readonly flagObservables = new Map>() - - /** Stateful analytics product. */ - protected _analytics: AnalyticsStateful - /** Stateful personalization product. */ - protected _personalization: PersonalizationStateful + private readonly allowedEventTypes: EventType[] + private readonly experienceQueue: ExperienceQueue + private readonly insightsQueue: InsightsQueue + private readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] /** * Expose merged observable state for consumers. - * - * @remarks - * This object is stable for the lifetime of the instance so consumers can - * safely subscribe once without repeated resubscription churn. */ readonly states: CoreStates = { blockedEventStream: toObservable(blockedEventSignal), @@ -226,81 +197,50 @@ class CoreStateful extends CoreBase implements ConsentController { profile: toObservable(profileSignal), } - /** - * Create a stateful core with optional default consent and product defaults. - * - * @param config - Core and defaults configuration. - * @example - * ```ts - * const core = new CoreStateful({ - * clientId: 'app', - * environment: 'prod', - * defaults: { consent: true } - * }) - * core.consent(true) - * ``` - */ constructor(config: CoreStatefulConfig) { - const { apiConfig: analyticsApiConfig, queuePolicy: analyticsRuntimeQueuePolicy } = - splitScopedQueuePolicy( - config.analytics, - ) - const { apiConfig: personalizationApiConfig, queuePolicy: personalizationRuntimeQueuePolicy } = - splitScopedQueuePolicy< - PersonalizationProductConfig['queuePolicy'], - CoreStatefulPersonalizationConfig - >(config.personalization) - const baseConfig: CoreConfig = { - ...config, - analytics: analyticsApiConfig, - personalization: personalizationApiConfig, - } - - super(baseConfig) + super(config) this.singletonOwner = `CoreStateful#${++statefulInstanceCounter}` acquireStatefulRuntimeSingleton(this.singletonOwner) try { - const { allowedEventTypes, defaults, getAnonymousId, onEventBlocked } = config - - if (defaults?.consent !== undefined) { - const { consent: defaultConsent } = defaults - consentSignal.value = defaultConsent - } - - this._analytics = new AnalyticsStateful({ - api: this.api, - eventBuilder: this.eventBuilder, - config: { - allowedEventTypes, - queuePolicy: analyticsRuntimeQueuePolicy, - onEventBlocked, - defaults: { - consent: defaults?.consent, - profile: defaults?.profile, - }, - }, - interceptors: this.interceptors, + const { allowedEventTypes, defaults, getAnonymousId, onEventBlocked, queuePolicy } = config + const { + changes: defaultChanges, + consent: defaultConsent, + personalizations: defaultPersonalizations, + profile: defaultProfile, + } = defaults ?? {} + const resolvedQueuePolicy = resolveQueuePolicy(queuePolicy) + + this.allowedEventTypes = allowedEventTypes ?? DEFAULT_ALLOWED_EVENT_TYPES + this.onEventBlocked = onEventBlocked + this.insightsQueue = new InsightsQueue({ + eventInterceptors: this.interceptors.event, + flushPolicy: resolvedQueuePolicy.flush, + insightsApi: this.api.insights, + }) + this.experienceQueue = new ExperienceQueue({ + experienceApi: this.api.experience, + eventInterceptors: this.interceptors.event, + flushPolicy: resolvedQueuePolicy.flush, + getAnonymousId: getAnonymousId ?? (() => undefined), + offlineMaxEvents: resolvedQueuePolicy.offlineMaxEvents, + onOfflineDrop: resolvedQueuePolicy.onOfflineDrop, + stateInterceptors: this.interceptors.state, }) - this._personalization = new PersonalizationStateful({ - api: this.api, - eventBuilder: this.eventBuilder, - config: { - allowedEventTypes, - getAnonymousId, - queuePolicy: personalizationRuntimeQueuePolicy, - onEventBlocked, - defaults: { - consent: defaults?.consent, - changes: defaults?.changes, - profile: defaults?.profile, - selectedPersonalizations: defaults?.personalizations, - }, - }, - interceptors: this.interceptors, + if (defaultConsent !== undefined) consentSignal.value = defaultConsent + + batch(() => { + if (defaultChanges !== undefined) changesSignal.value = defaultChanges + if (defaultPersonalizations !== undefined) { + selectedPersonalizationsSignal.value = defaultPersonalizations + } + if (defaultProfile !== undefined) profileSignal.value = defaultProfile }) + + this.initializeEffects() } catch (error) { releaseStatefulRuntimeSingleton(this.singletonOwner) throw error @@ -318,6 +258,41 @@ class CoreStateful extends CoreBase implements ConsentController { return value } + override personalizeEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedPersonalizations?: SelectedPersonalizationArray, + ): ResolvedData + override personalizeEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedPersonalizations?: SelectedPersonalizationArray, + ): ResolvedData + override personalizeEntry< + S extends EntrySkeletonType, + M extends ChainModifiers, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedPersonalizations: + | SelectedPersonalizationArray + | undefined = selectedPersonalizationsSignal.value, + ): ResolvedData { + return super.personalizeEntry(entry, selectedPersonalizations) + } + + override getMergeTagValue( + embeddedEntryNodeTarget: MergeTagEntry, + profile: Profile | undefined = profileSignal.value, + ): string | undefined { + return super.getMergeTagValue(embeddedEntryNodeTarget, profile) + } + private buildFlagViewBuilderArgs( name: string, changes: ChangeArray | undefined = changesSignal.value, @@ -337,12 +312,7 @@ class CoreStateful extends CoreBase implements ConsentController { const trackFlagView = this.trackFlagView.bind(this) const buildFlagViewBuilderArgs = this.buildFlagViewBuilderArgs.bind(this) - const { _personalization } = this - - const valueSignal = signalFns.computed(() => - _personalization.getFlag(name, changesSignal.value), - ) - + const valueSignal = signalFns.computed(() => super.getFlag(name, changesSignal.value)) const distinctObservable = toDistinctObservable(valueSignal, isEqual) const trackedObservable: Observable = { @@ -383,38 +353,115 @@ class CoreStateful extends CoreBase implements ConsentController { return trackedObservable } - /** - * Release singleton ownership for stateful runtime usage. - * - * @remarks - * This method is idempotent and should be called when a stateful SDK instance - * is no longer needed (e.g. tests, hot reload, explicit teardown). - */ + hasConsent(name: string): boolean { + const { [name]: mappedEventType } = CONSENT_EVENT_TYPE_MAP + const isAllowed = + mappedEventType !== undefined + ? this.allowedEventTypes.includes(mappedEventType) + : this.allowedEventTypes.some((eventType) => eventType === name) + + return !!consentSignal.value || isAllowed + } + + onBlockedByConsent(name: string, args: readonly unknown[]): void { + coreLogger.warn( + `Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(args)}`, + ) + this.reportBlockedEvent('consent', name, args) + } + + private reportBlockedEvent( + reason: BlockedEvent['reason'], + method: string, + args: readonly unknown[], + ): void { + const event: BlockedEvent = { reason, method, args } + + try { + this.onEventBlocked?.(event) + } catch (error) { + coreLogger.warn(`onEventBlocked callback failed for method "${method}"`, error) + } + + blockedEventSignal.value = event + } + + protected override async sendExperienceEvent( + method: string, + args: readonly unknown[], + event: ExperienceEventPayload, + _profile?: PartialProfile, + ): Promise { + if (!this.hasConsent(method)) { + this.onBlockedByConsent(method, args) + return undefined + } + + return await this.experienceQueue.send(event) + } + + protected override async sendInsightsEvent( + method: string, + args: readonly unknown[], + event: InsightsEventPayload, + _profile?: PartialProfile, + ): Promise { + if (!this.hasConsent(method)) { + this.onBlockedByConsent(method, args) + return + } + + await this.insightsQueue.send(event) + } + + private initializeEffects(): void { + effect(() => { + coreLogger.debug( + `Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, + ) + }) + + effect(() => { + coreLogger.debug( + `Variants have been ${selectedPersonalizationsSignal.value?.length ? 'populated' : 'cleared'}`, + ) + }) + + effect(() => { + coreLogger.info( + `Core ${consentSignal.value ? 'will' : 'will not'} emit gated events due to consent (${consentSignal.value})`, + ) + }) + + effect(() => { + if (!onlineSignal.value) return + + this.insightsQueue.clearScheduledRetry() + this.experienceQueue.clearScheduledRetry() + void this.flushQueues({ force: true }) + }) + } + + private async flushQueues(options: { force?: boolean } = {}): Promise { + await this.insightsQueue.flush(options) + await this.experienceQueue.flush(options) + } + destroy(): void { if (this.destroyed) return this.destroyed = true - void this._analytics.flush({ force: true }).catch((error: unknown) => { - logger.warn('Failed to flush analytics queue during destroy()', String(error)) + void this.insightsQueue.flush({ force: true }).catch((error: unknown) => { + logger.warn('Failed to flush insights queue during destroy()', String(error)) }) - void this._personalization.flush({ force: true }).catch((error: unknown) => { - logger.warn('Failed to flush personalization queue during destroy()', String(error)) + void this.experienceQueue.flush({ force: true }).catch((error: unknown) => { + logger.warn('Failed to flush Experience queue during destroy()', String(error)) }) + this.insightsQueue.clearPeriodicFlushTimer() releaseStatefulRuntimeSingleton(this.singletonOwner) } - /** - * Reset internal state. Consent and preview panel state are intentionally preserved. - * - * @remarks - * Resetting personalization also resets analytics dependencies as a - * consequence of the current shared-state design. - * @example - * ```ts - * core.reset() - * ``` - */ reset(): void { batch(() => { blockedEventSignal.value = undefined @@ -425,75 +472,22 @@ class CoreStateful extends CoreBase implements ConsentController { }) } - /** - * Flush the queues for both the analytics and personalization products. - * @remarks - * The personalization queue is only populated if events have been triggered - * while a device is offline. - * @example - * ```ts - * await core.flush() - * ``` - */ async flush(): Promise { - await this._analytics.flush() - await this._personalization.flush() + await this.flushQueues() } - /** - * Update consent state - * - * @param accept - `true` if the user has granted consent; `false` otherwise. - * @example - * ```ts - * core.consent(true) - * ``` - */ consent(accept: boolean): void { consentSignal.value = accept } - /** - * Read current online state. - * - * @example - * ```ts - * if (this.online) { - * await this.flush() - * } - * ``` - */ protected get online(): boolean { return onlineSignal.value ?? false } - /** - * Update online state. - * - * @param isOnline - `true` if the runtime is online; `false` otherwise. - * @example - * ```ts - * this.online = navigator.onLine - * ``` - */ protected set online(isOnline: boolean) { onlineSignal.value = isOnline } - /** - * Register a preview panel compatible object to receive direct signal access. - * This enables the preview panel to modify SDK state for testing and simulation. - * - * @param previewPanel - An object implementing PreviewPanelSignalObject - * @remarks - * This method is intended for use by the Preview Panel component. - * Direct signal access allows immediate state updates without API calls. - * @example - * ```ts - * const previewBridge: PreviewPanelSignalObject = {} - * core.registerPreviewPanel(previewBridge) - * ``` - */ registerPreviewPanel(previewPanel: PreviewPanelSignalObject): void { Reflect.set(previewPanel, PREVIEW_PANEL_SIGNALS_SYMBOL, signals) Reflect.set(previewPanel, PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, signalFns) diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts new file mode 100644 index 00000000..822711c2 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -0,0 +1,100 @@ +import type { OptimizationData } from './api-schemas' +import CoreStateless from './CoreStateless' + +const EMPTY_OPTIMIZATION_DATA: OptimizationData = { + changes: [], + selectedPersonalizations: [], + profile: { + id: 'profile-id', + stableId: 'profile-id', + random: 1, + audiences: [], + traits: {}, + location: {}, + session: { + id: 'session-id', + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + }, +} + +describe('CoreStateless', () => { + it('strips beaconHandler from stateless api config', () => { + const beaconHandler = rs.fn(() => true) + const core: unknown = Reflect.construct(CoreStateless, [ + { + clientId: 'key_123', + environment: 'main', + api: { + beaconHandler, + insightsBaseUrl: 'https://ingest.example.test/', + }, + }, + ]) + + if (!(core instanceof CoreStateless)) { + throw new Error('Failed to construct CoreStateless') + } + + expect(Reflect.get(core.api.insights, 'beaconHandler')).toBeUndefined() + }) + + it('sends explicit profiles through Experience upserts', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + + await core.identify({ + userId: 'user-123', + profile: { id: 'profile-123' }, + }) + + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'profile-123', + events: [expect.objectContaining({ type: 'identify' })], + }), + ) + }) + + it('sends sticky component views through both Experience and Insights', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackView({ + componentId: 'hero-banner', + sticky: true, + viewId: 'hero-banner-view', + viewDurationMs: 1000, + profile: { id: 'profile-123' }, + }) + + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'profile-123', + events: [expect.objectContaining({ type: 'component' })], + }), + ) + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: { id: 'profile-123' }, + events: [expect.objectContaining({ type: 'component' })], + }, + ]) + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStateless.ts b/packages/universal/core-sdk/src/CoreStateless.ts index 031cdd98..12c53685 100644 --- a/packages/universal/core-sdk/src/CoreStateless.ts +++ b/packages/universal/core-sdk/src/CoreStateless.ts @@ -1,7 +1,14 @@ -import { AnalyticsStateless } from './analytics' -import CoreBase, { type CoreConfig } from './CoreBase' +import { + BatchInsightsEventArray, + ExperienceEvent as ExperienceEventSchema, + InsightsEvent as InsightsEventSchema, + parseWithFriendlyError, + type ExperienceEvent as ExperienceEventPayload, + type InsightsEvent as InsightsEventPayload, +} from '@contentful/optimization-api-client/api-schemas' +import type { OptimizationData, PartialProfile } from './api-schemas' +import CoreBase, { type CoreApiConfig, type CoreConfig } from './CoreBase' import type { EventBuilderConfig } from './events' -import { PersonalizationStateless } from './personalization' /** * Configuration for the Node-specific Optimization SDK. @@ -12,12 +19,12 @@ import { PersonalizationStateless } from './personalization' * of the event-builder configuration. SDKs commonly inject their own library * metadata or channel definitions. */ -export interface CoreStatelessConfig extends CoreConfig { +export interface CoreStatelessConfig extends Omit { /** - * Override configuration for the analytics (Insights) API client. Omits - * `beaconHandler`. + * Unified API configuration for stateless environments. Omits stateful-only + * delivery hooks such as `beaconHandler`. */ - analytics?: Omit + api?: Omit /** * Overrides for the event builder configuration. Omits methods that are only @@ -27,43 +34,52 @@ export interface CoreStatelessConfig extends CoreConfig { } /** - * Core runtime that constructs product instances for stateless environments. + * Core runtime for stateless environments. * * @public * @see {@link CoreBase} */ class CoreStateless extends CoreBase { - /** Stateless analytics product. */ - protected _analytics: AnalyticsStateless - /** Stateless personalization product. */ - protected _personalization: PersonalizationStateless - - /** - * Create a stateless core. Product instances share the same API client and - * event builder configured in {@link CoreBase}. - * - * @param config - Stateless Core configuration. - * @example - * ```ts - * const sdk = new CoreStateless({ clientId: 'app', environment: 'prod' }) - * core.trackFlagView({ componentId: 'hero' }) - * ``` - */ constructor(config: CoreStatelessConfig) { - super(config) - - this._analytics = new AnalyticsStateless({ - api: this.api, - eventBuilder: this.eventBuilder, - interceptors: this.interceptors, + super({ + ...config, + api: config.api ? { ...config.api, beaconHandler: undefined } : undefined, }) + } - this._personalization = new PersonalizationStateless({ - api: this.api, - eventBuilder: this.eventBuilder, - interceptors: this.interceptors, + protected override async sendExperienceEvent( + _method: string, + _args: readonly unknown[], + event: ExperienceEventPayload, + profile?: PartialProfile, + ): Promise { + const intercepted = await this.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) + + return await this.api.experience.upsertProfile({ + profileId: profile?.id, + events: [validEvent], }) } + + protected override async sendInsightsEvent( + _method: string, + _args: readonly unknown[], + event: InsightsEventPayload, + profile?: PartialProfile, + ): Promise { + const intercepted = await this.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) + + const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ + { + profile, + events: [validEvent], + }, + ]) + + await this.api.insights.sendBatchEvents(batchEvent) + } } export default CoreStateless diff --git a/packages/universal/core-sdk/src/ProductBase.ts b/packages/universal/core-sdk/src/ProductBase.ts deleted file mode 100644 index b6c969b1..00000000 --- a/packages/universal/core-sdk/src/ProductBase.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { ApiClient } from '@contentful/optimization-api-client' -import type { - InsightsEventType as AnalyticsEventType, - ExperienceEventType as PersonalizationEventType, -} from '@contentful/optimization-api-client/api-schemas' -import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { BlockedEvent, BlockedEventProduct, BlockedEventReason } from './BlockedEvent' -import type { LifecycleInterceptors } from './CoreBase' -import type { EventBuilder } from './events' -import { blockedEvent } from './signals' - -const logger = createScopedLogger('ProductBase') - -/** - * Union of all event {@link AnalyticsEventType | type keys} that this package may emit. - * - * @public - */ -export type EventType = AnalyticsEventType | PersonalizationEventType - -/** - * Default allow‑list of event types that can be emitted without explicit consent. - * - * @internal - * @privateRemarks These defaults are only applied when a consumer does not provide - * {@link ProductConfig.allowedEventTypes}. - */ -const defaultAllowedEvents: EventType[] = ['identify', 'page', 'screen'] - -/** - * Common configuration for all product implementations. - * - * @public - */ -export interface ProductConfig { - /** - * The set of event type strings that are allowed to be sent even if consent is - * not granted. - * - * @defaultValue `['identify', 'page', 'screen']` - * @remarks These types are compared against the `type` property of events. - */ - allowedEventTypes?: EventType[] - - /** - * Callback invoked whenever an event call is blocked by guards. - * - * @remarks - * This callback is best-effort. Any exception thrown by the callback is - * swallowed to keep event handling fault tolerant. - */ - onEventBlocked?: (event: BlockedEvent) => void -} - -/** - * Options for configuring the common functionality of {@link ProductBase} descendents. - * - * @public - */ -export interface ProductBaseOptions { - /** Optimization API client. */ - api: ApiClient - /** Event builder for constructing events. */ - eventBuilder: EventBuilder - /** Optional configuration for allow‑lists and guard callbacks. */ - config?: ProductConfig - /** Lifecycle container for event and state interceptors. */ - interceptors: LifecycleInterceptors -} - -/** - * Shared base for all product implementations. - * - * @internal - * @remarks - * This abstract class is not exported as part of the public API surface. - * Concrete implementations (e.g., analytics) should extend this class and - * expose their own public methods. - */ -abstract class ProductBase { - /** - * Allow‑list of event {@link AnalyticsEventType | type keys} permitted when consent is not present. - */ - protected readonly allowedEventTypes?: string[] - - /** Event builder used to construct strongly‑typed events. */ - protected readonly eventBuilder: EventBuilder - - /** Optimization API client used to send events to the Experience and Insights APIs. */ - protected readonly api: ApiClient - - /** Interceptors that can mutate/augment outgoing events or optimization state. */ - readonly interceptors: LifecycleInterceptors - - /** Optional callback invoked when an event call is blocked. */ - protected readonly onEventBlocked?: ProductConfig['onEventBlocked'] - - /** - * Creates a new product base instance. - * - * @param options - Options for configuring the functionality common among products. - */ - constructor(options: ProductBaseOptions) { - const { api, eventBuilder, config, interceptors } = options - this.allowedEventTypes = config?.allowedEventTypes ?? defaultAllowedEvents - this.api = api - this.eventBuilder = eventBuilder - this.interceptors = interceptors - this.onEventBlocked = config?.onEventBlocked - } - - /** - * Publish blocked event metadata to both callback and blocked event signal. - * - * @param reason - Reason the method call was blocked. - * @param product - Product that blocked the method call. - * @param method - Name of the blocked method. - * @param args - Original blocked call arguments. - */ - protected reportBlockedEvent( - reason: BlockedEventReason, - product: BlockedEventProduct, - method: string, - args: readonly unknown[], - ): void { - const event: BlockedEvent = { reason, product, method, args } - - try { - this.onEventBlocked?.(event) - } catch (error) { - logger.warn(`onEventBlocked callback failed for method "${method}"`, error) - } - - blockedEvent.value = event - } -} - -export default ProductBase diff --git a/packages/universal/core-sdk/src/analytics/AnalyticsBase.ts b/packages/universal/core-sdk/src/analytics/AnalyticsBase.ts deleted file mode 100644 index deeae406..00000000 --- a/packages/universal/core-sdk/src/analytics/AnalyticsBase.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - ClickBuilderArgs, - FlagViewBuilderArgs, - HoverBuilderArgs, - ViewBuilderArgs, -} from '../events' -import ProductBase from '../ProductBase' - -/** - * Base class for analytics implementations (internal). - * - * @internal - * @remarks - * Concrete analytics classes should implement the component/flag view tracking - * methods below. This base is not part of the public API. - */ -abstract class AnalyticsBase extends ProductBase { - /** - * Track a UI component view event. - * - * @param payload - Component view builder arguments. - * @returns A promise that resolves when processing is complete (or `void`). - */ - abstract trackView(payload: ViewBuilderArgs): Promise | void - - /** - * Track a UI component click event. - * - * @param payload - Component click builder arguments. - * @returns A promise that resolves when processing is complete (or `void`). - */ - abstract trackClick(payload: ClickBuilderArgs): Promise | void - - /** - * Track a UI component hover event. - * - * @param payload - Component hover builder arguments. - * @returns A promise that resolves when processing is complete (or `void`). - */ - abstract trackHover(payload: HoverBuilderArgs): Promise | void - - /** - * Track a flag (feature) view event. - * - * @param payload - Flag view builder arguments. - * @returns A promise that resolves when processing is complete (or `void`). - */ - abstract trackFlagView(payload: FlagViewBuilderArgs): Promise | void -} - -export default AnalyticsBase diff --git a/packages/universal/core-sdk/src/analytics/AnalyticsStateful.test.ts b/packages/universal/core-sdk/src/analytics/AnalyticsStateful.test.ts deleted file mode 100644 index e7868d78..00000000 --- a/packages/universal/core-sdk/src/analytics/AnalyticsStateful.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { ApiClient } from '@contentful/optimization-api-client' -import type { Profile } from '@contentful/optimization-api-client/api-schemas' -import type { LifecycleInterceptors } from '../CoreBase' -import { EventBuilder } from '../events' -import { InterceptorManager } from '../lib/interceptor' -import type { - QueueFlushFailureContext, - QueueFlushPolicy, - QueueFlushRecoveredContext, -} from '../lib/queue' -import { batch, consent, online, profile } from '../signals' -import AnalyticsStateful from './AnalyticsStateful' - -interface CreateAnalyticsOptions { - queuePolicy?: QueueFlushPolicy - sendBatchEvents?: ApiClient['insights']['sendBatchEvents'] -} - -const DEFAULT_PROFILE: Profile = { - id: 'profile-id', - stableId: 'profile-id', - random: 1, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'session-id', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'https://example.test/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, -} - -const createAnalytics = (options: CreateAnalyticsOptions = {}): AnalyticsStateful => { - const { queuePolicy } = options - const sendBatchEvents = - options.sendBatchEvents ?? - rs.fn().mockResolvedValue(true) - const api = new ApiClient({ - clientId: 'key_123', - environment: 'main', - }) - rs.spyOn(api.insights, 'sendBatchEvents').mockImplementation(sendBatchEvents) - - const builder = new EventBuilder({ - channel: 'web', - library: { - name: 'Optimization Core Tests', - version: '0.0.0', - }, - }) - - const interceptors: LifecycleInterceptors = { - event: new InterceptorManager(), - state: new InterceptorManager(), - } - - return new AnalyticsStateful({ - api, - eventBuilder: builder, - interceptors, - config: { - defaults: { - profile: DEFAULT_PROFILE, - }, - queuePolicy, - }, - }) -} - -const getQueuedEventCount = (analytics: AnalyticsStateful): number => { - const queue = Reflect.get(analytics, 'queue') - - if (!(queue instanceof Map)) { - return 0 - } - - let count = 0 - - queue.forEach((value: unknown) => { - if (Array.isArray(value)) { - count += value.length - return - } - - if (typeof value !== 'object' || value === null || !('events' in value)) { - return - } - - const events = Reflect.get(value, 'events') - - if (Array.isArray(events)) { - count += events.length - } - }) - - return count -} - -const getQueuedBatchCount = (analytics: AnalyticsStateful): number => { - const queue = Reflect.get(analytics, 'queue') - - return queue instanceof Map ? queue.size : 0 -} - -const createViewPayload = ( - componentId: string, -): { - componentId: string - viewId: string - viewDurationMs: number -} => ({ - componentId, - viewId: `${componentId}-view-id`, - viewDurationMs: 1000, -}) - -describe('AnalyticsStateful.flush policy', () => { - beforeEach(() => { - batch(() => { - consent.value = true - online.value = true - profile.value = undefined - }) - rs.useFakeTimers() - }) - - afterEach(() => { - rs.restoreAllMocks() - rs.useRealTimers() - }) - - it('queues events under one batch when profile object references change for the same profile ID', async () => { - const sendBatchEvents = rs - .fn() - .mockResolvedValue(true) - const analytics = createAnalytics({ sendBatchEvents }) - - await analytics.trackView(createViewPayload('hero-banner')) - - const sameProfileId: Profile = { - ...DEFAULT_PROFILE, - traits: { - plan: 'pro', - }, - } - - profile.value = sameProfileId - - await analytics.trackFlagView(createViewPayload('promo-flag')) - await analytics.trackClick({ componentId: 'hero-cta' }) - await analytics.trackHover({ - componentId: 'hero-hover', - hoverId: 'hero-hover-id', - hoverDurationMs: 500, - }) - - expect(getQueuedEventCount(analytics)).toBe(4) - expect(getQueuedBatchCount(analytics)).toBe(1) - - await analytics.flush() - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - - const [batches] = sendBatchEvents.mock.calls[0] ?? [] - - expect(batches).toHaveLength(1) - expect(batches?.[0]).toEqual( - expect.objectContaining({ - profile: sameProfileId, - }), - ) - expect(batches?.[0]?.events).toHaveLength(4) - expect(batches?.[0]?.events[0]).toEqual( - expect.objectContaining({ - componentId: 'hero-banner', - componentType: 'Entry', - }), - ) - expect(batches?.[0]?.events[1]).toEqual( - expect.objectContaining({ - componentId: 'promo-flag', - componentType: 'Variable', - }), - ) - expect(batches?.[0]?.events[2]).toEqual( - expect.objectContaining({ - type: 'component_click', - componentId: 'hero-cta', - componentType: 'Entry', - }), - ) - expect(batches?.[0]?.events[3]).toEqual( - expect.objectContaining({ - type: 'component_hover', - componentId: 'hero-hover', - hoverId: 'hero-hover-id', - hoverDurationMs: 500, - componentType: 'Entry', - }), - ) - - analytics.reset() - }) - - it('retries failed flushes with backoff and clears the queue after recovery', async () => { - const sendBatchEvents = rs - .fn() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - - const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() - const onFlushRecovered = rs.fn<(context: QueueFlushRecoveredContext) => void>() - - const analytics = createAnalytics({ - sendBatchEvents, - queuePolicy: { - baseBackoffMs: 20, - maxBackoffMs: 20, - jitterRatio: 0, - maxConsecutiveFailures: 5, - circuitOpenMs: 200, - onFlushFailure, - onFlushRecovered, - }, - }) - - await analytics.trackView(createViewPayload('hero-banner')) - await analytics.flush() - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - expect(onFlushFailure).toHaveBeenCalledTimes(1) - expect(onFlushFailure).toHaveBeenCalledWith( - expect.objectContaining({ - consecutiveFailures: 1, - queuedBatches: 1, - queuedEvents: 1, - retryDelayMs: 20, - }), - ) - expect(getQueuedEventCount(analytics)).toBe(1) - - await rs.advanceTimersByTimeAsync(20) - - expect(sendBatchEvents).toHaveBeenCalledTimes(2) - expect(onFlushFailure).toHaveBeenCalledTimes(2) - expect(getQueuedEventCount(analytics)).toBe(1) - - await rs.advanceTimersByTimeAsync(20) - - expect(sendBatchEvents).toHaveBeenCalledTimes(3) - expect(onFlushRecovered).toHaveBeenCalledTimes(1) - expect(onFlushRecovered).toHaveBeenCalledWith({ consecutiveFailures: 2 }) - expect(getQueuedEventCount(analytics)).toBe(0) - - analytics.reset() - }) - - it('opens a circuit window after repeated failures and retries after circuit cooldown', async () => { - const sendBatchEvents = rs - .fn() - .mockResolvedValue(false) - const onCircuitOpen = rs.fn<(context: QueueFlushFailureContext) => void>() - - const analytics = createAnalytics({ - sendBatchEvents, - queuePolicy: { - baseBackoffMs: 5, - maxBackoffMs: 5, - jitterRatio: 0, - maxConsecutiveFailures: 2, - circuitOpenMs: 50, - onCircuitOpen, - }, - }) - - await analytics.trackView(createViewPayload('hero-banner')) - await analytics.flush() - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - - await rs.advanceTimersByTimeAsync(5) - - expect(sendBatchEvents).toHaveBeenCalledTimes(2) - expect(onCircuitOpen).toHaveBeenCalledTimes(1) - expect(onCircuitOpen).toHaveBeenCalledWith( - expect.objectContaining({ - consecutiveFailures: 2, - queuedBatches: 1, - queuedEvents: 1, - retryDelayMs: 50, - }), - ) - expect(getQueuedEventCount(analytics)).toBe(1) - - await rs.advanceTimersByTimeAsync(49) - expect(sendBatchEvents).toHaveBeenCalledTimes(2) - - await rs.advanceTimersByTimeAsync(1) - expect(sendBatchEvents).toHaveBeenCalledTimes(3) - - analytics.reset() - }) - - it('supports force flushes that bypass an active backoff window', async () => { - const sendBatchEvents = rs - .fn() - .mockResolvedValue(false) - - const analytics = createAnalytics({ - sendBatchEvents, - queuePolicy: { - baseBackoffMs: 1_000, - maxBackoffMs: 1_000, - jitterRatio: 0, - maxConsecutiveFailures: 10, - circuitOpenMs: 1_000, - }, - }) - - await analytics.trackView(createViewPayload('hero-banner')) - await analytics.flush() - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - - await analytics.flush() - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - - await analytics.flush({ force: true }) - expect(sendBatchEvents).toHaveBeenCalledTimes(2) - - analytics.reset() - }) - - it('treats thrown send errors as flush failures and retries', async () => { - const sendBatchEvents = rs - .fn() - .mockRejectedValueOnce(new Error('network-down')) - .mockResolvedValueOnce(true) - - const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() - const onFlushRecovered = rs.fn<(context: QueueFlushRecoveredContext) => void>() - - const analytics = createAnalytics({ - sendBatchEvents, - queuePolicy: { - baseBackoffMs: 15, - maxBackoffMs: 15, - jitterRatio: 0, - maxConsecutiveFailures: 5, - circuitOpenMs: 200, - onFlushFailure, - onFlushRecovered, - }, - }) - - await analytics.trackView(createViewPayload('hero-banner')) - await analytics.flush() - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - expect(onFlushFailure).toHaveBeenCalledTimes(1) - expect(getQueuedEventCount(analytics)).toBe(1) - - await rs.advanceTimersByTimeAsync(15) - - expect(sendBatchEvents).toHaveBeenCalledTimes(2) - expect(onFlushRecovered).toHaveBeenCalledTimes(1) - expect(onFlushRecovered).toHaveBeenCalledWith({ consecutiveFailures: 1 }) - expect(getQueuedEventCount(analytics)).toBe(0) - - analytics.reset() - }) - - it('periodically flushes queued events based on queuePolicy.flushIntervalMs', async () => { - const sendBatchEvents = rs - .fn() - .mockResolvedValue(true) - const analytics = createAnalytics({ - sendBatchEvents, - queuePolicy: { - flushIntervalMs: 30, - }, - }) - - await analytics.trackView(createViewPayload('hero-banner')) - - expect(sendBatchEvents).not.toHaveBeenCalled() - - await rs.advanceTimersByTimeAsync(30) - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - expect(getQueuedEventCount(analytics)).toBe(0) - - await rs.advanceTimersByTimeAsync(90) - - expect(sendBatchEvents).toHaveBeenCalledTimes(1) - - analytics.reset() - }) -}) diff --git a/packages/universal/core-sdk/src/analytics/AnalyticsStateful.ts b/packages/universal/core-sdk/src/analytics/AnalyticsStateful.ts deleted file mode 100644 index b0928350..00000000 --- a/packages/universal/core-sdk/src/analytics/AnalyticsStateful.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { - InsightsEvent as AnalyticsEvent, - parseWithFriendlyError, - type BatchInsightsEventArray, - type InsightsEventArray, - type ExperienceEvent as PersonalizationEvent, - type Profile, -} from '@contentful/optimization-api-client/api-schemas' -import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { BlockedEvent } from '../BlockedEvent' -import type { ConsentGuard } from '../Consent' -import type { - ClickBuilderArgs, - FlagViewBuilderArgs, - HoverBuilderArgs, - ViewBuilderArgs, -} from '../events' -import { guardedBy } from '../lib/decorators' -import { QueueFlushRuntime, resolveQueueFlushPolicy, type QueueFlushPolicy } from '../lib/queue' -import type { ProductBaseOptions, ProductConfig } from '../ProductBase' -import { - batch, - blockedEvent as blockedEventSignal, - consent, - effect, - event as eventSignal, - online as onlineSignal, - profile as profileSignal, - toObservable, - type Observable, -} from '../signals' -import AnalyticsBase from './AnalyticsBase' - -const logger = createScopedLogger('Analytics') - -/** - * Default analytics state values applied at construction time. - * - * @public - */ -export interface AnalyticsProductConfigDefaults { - /** Whether analytics collection is allowed by default. */ - consent?: boolean - /** Default profile to associate with events. */ - profile?: Profile -} - -/** - * Configuration for the stateful analytics implementation. - * - * @public - */ -export interface AnalyticsProductConfig extends ProductConfig { - /** - * Default signal values applied on initialization. - */ - defaults?: AnalyticsProductConfigDefaults - - /** - * Policy that controls stateful queue flush retries, backoff, and circuit - * behavior after repeated failures. - */ - queuePolicy?: QueueFlushPolicy -} - -/** - * Observables exposed by the stateful analytics product. - * - * @public - */ -export interface AnalyticsStates { - /** Observable stream of the latest blocked event payload (or `undefined`). */ - blockedEventStream: Observable - /** Observable stream of the latest {@link AnalyticsEvent} or {@link PersonalizationEvent} (or `undefined`). */ - eventStream: Observable - /** Observable stream of the active {@link Profile} (or `undefined`). */ - profile: Observable -} - -/** - * Options for configuring {@link AnalyticsStateful} functionality. - * - * @public - * @see {@link ProductBaseOptions} - */ -export type AnalyticsStatefulOptions = ProductBaseOptions & { - /** Configuration specific to the Analytics product */ - config?: AnalyticsProductConfig -} - -/** - * Maximum number of queued events before an automatic flush is triggered. - * - * @internal - */ -const MAX_QUEUED_EVENTS = 25 -const ANALYTICS_METHOD_EVENT_TYPE_MAP: Readonly> = { - trackView: 'component', - trackFlagView: 'component', - trackClick: 'component_click', - trackHover: 'component_hover', -} - -interface QueuedProfileEvents { - profile: Profile - events: InsightsEventArray -} - -/** - * Analytics implementation that maintains local state (consent, profile) and - * queues events until flushed or the queue reaches a maximum size. - * - * @remarks - * Repeated flush failures are managed by the configured {@link QueueFlushPolicy} - * using bounded backoff and a temporary circuit-open window. - * - * @public - */ -class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { - /** In-memory queue keyed by stable profile identifier. */ - private readonly queue = new Map() - /** Shared queue flush retry runtime state machine. */ - private readonly flushRuntime: QueueFlushRuntime - /** Periodic queue flush interval in milliseconds. */ - private readonly flushIntervalMs: number - /** Timer handle used for periodic flush attempts while the queue is non-empty. */ - private periodicFlushTimer: ReturnType | undefined - - /** Exposed observable state references. */ - readonly states: AnalyticsStates = { - blockedEventStream: toObservable(blockedEventSignal), - eventStream: toObservable(eventSignal), - profile: toObservable(profileSignal), - } - - /** - * Create a new stateful analytics instance. - * - * @param options - Options to configure the analytics product for stateful environments. - * @example - * ```ts - * const analytics = new AnalyticsStateful({ api, builder, config: { defaults: { consent: true } }, interceptors }) - * ``` - */ - constructor(options: AnalyticsStatefulOptions) { - const { api, eventBuilder, config, interceptors } = options - - super({ api, eventBuilder, config, interceptors }) - - this.applyDefaults(config?.defaults) - const resolvedQueuePolicy = resolveQueueFlushPolicy(config?.queuePolicy) - const { flushIntervalMs } = resolvedQueuePolicy - this.flushIntervalMs = flushIntervalMs - - this.flushRuntime = new QueueFlushRuntime({ - policy: resolvedQueuePolicy, - onRetry: () => { - void this.flush() - }, - onCallbackError: (callbackName, error) => { - logger.warn(`Analytics flush policy callback "${callbackName}" failed`, error) - }, - }) - this.initializeEffects() - } - - /** - * Reset analytics‑related signals and the last emitted event. - * - * @example - * ```ts - * analytics.reset() - * ``` - */ - reset(): void { - this.flushRuntime.reset() - this.clearPeriodicFlushTimer() - - batch(() => { - blockedEventSignal.value = undefined - eventSignal.value = undefined - profileSignal.value = undefined - }) - } - - /** - * Determine whether the named operation is permitted based on consent and - * allowed event type configuration. - * - * @param name - The method name; component view/flag methods are normalized - * to `'component'`, component click methods are normalized to `'component_click'`, - * and component hover methods are normalized to `'component_hover'` for - * allow‑list checks. - * @returns `true` if the operation is permitted; otherwise `false`. - * @example - * ```ts - * if (analytics.hasConsent('track')) { ... } - * ``` - */ - hasConsent(name: string): boolean { - const mappedEventType = ANALYTICS_METHOD_EVENT_TYPE_MAP[name] ?? name - - return !!consent.value || (this.allowedEventTypes ?? []).includes(mappedEventType) - } - - /** - * Hook invoked when an operation is blocked due to missing consent. - * - * @param name - The blocked operation name. - * @param payload - The original arguments supplied to the operation. - * @example - * ```ts - * analytics.onBlockedByConsent('track', [payload]) - * ``` - */ - onBlockedByConsent(name: string, payload: readonly unknown[]): void { - logger.warn( - `Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(payload)}`, - ) - - this.reportBlockedEvent('consent', 'analytics', name, payload) - } - - /** - * Queue a component view event for the active profile. - * - * @param payload - Component view builder arguments. - * @returns A promise that resolves when the event has been queued. - * @example - * ```ts - * await analytics.trackView({ componentId: 'hero-banner' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackView(payload: ViewBuilderArgs): Promise { - logger.info(`Processing "component view" event for ${payload.componentId}`) - - await this.enqueueEvent(this.eventBuilder.buildView(payload)) - } - - /** - * Queue a component click event for the active profile. - * - * @param payload - Component click builder arguments. - * @returns A promise that resolves when the event has been queued. - * @example - * ```ts - * await analytics.trackClick({ componentId: 'hero-banner' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackClick(payload: ClickBuilderArgs): Promise { - logger.info(`Processing "component click" event for ${payload.componentId}`) - - await this.enqueueEvent(this.eventBuilder.buildClick(payload)) - } - - /** - * Queue a component hover event for the active profile. - * - * @param payload - Component hover builder arguments. - * @returns A promise that resolves when the event has been queued. - * @example - * ```ts - * await analytics.trackHover({ componentId: 'hero-banner' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackHover(payload: HoverBuilderArgs): Promise { - logger.info(`Processing "component hover" event for ${payload.componentId}`) - - await this.enqueueEvent(this.eventBuilder.buildHover(payload)) - } - - /** - * Queue a flag view event for the active profile. - * - * @param payload - Flag view builder arguments. - * @returns A promise that resolves when the event has been queued. - * @example - * ```ts - * await analytics.trackFlagView({ componentId: 'feature-flag-123' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackFlagView(payload: FlagViewBuilderArgs): Promise { - logger.debug(`Processing "flag view" event for ${payload.componentId}`) - - await this.enqueueEvent(this.eventBuilder.buildFlagView(payload)) - } - - /** - * Intercept, validate, and place an event into the profile‑scoped queue; then - * trigger a size‑based flush if necessary. - * - * @param event - The event to enqueue. - */ - private async enqueueEvent(event: AnalyticsEvent): Promise { - const { value: profile } = profileSignal - - if (!profile) { - logger.warn('Attempting to emit an event without an Optimization profile') - - return - } - - const intercepted = await this.interceptors.event.run(event) - - const validEvent = parseWithFriendlyError(AnalyticsEvent, intercepted) - - logger.debug(`Queueing ${validEvent.type} event for profile ${profile.id}`, validEvent) - - const { id: profileId } = profile - const queuedProfileEvents = this.queue.get(profileId) - - eventSignal.value = validEvent - - if (queuedProfileEvents) { - // Keep the latest profile snapshot for this ID while appending events. - queuedProfileEvents.profile = profile - queuedProfileEvents.events.push(validEvent) - } else { - this.queue.set(profileId, { profile, events: [validEvent] }) - } - - this.ensurePeriodicFlushTimer() - await this.flushMaxEvents() - this.reconcilePeriodicFlushTimer() - } - - /** - * Flush the queue automatically when the total number of queued events - * reaches {@link MAX_QUEUED_EVENTS}. - */ - private async flushMaxEvents(): Promise { - if (this.getQueuedEventCount() >= MAX_QUEUED_EVENTS) await this.flush() - } - - /** - * Send all queued events grouped by profile and clear the queue. - * - * @param options - Optional flush controls. - * @param options.force - When `true`, bypass offline/backoff/circuit gates and attempt immediately. - * @remarks Only under rare circumstances should there be more than one - * profile in a stateful application. - * @example - * ```ts - * await analytics.flush() - * ``` - */ - async flush(options: { force?: boolean } = {}): Promise { - const { force = false } = options - - if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return - - logger.debug('Flushing event queue') - - const batches = this.createBatches() - - if (!batches.length) { - this.flushRuntime.clearScheduledRetry() - this.reconcilePeriodicFlushTimer() - return - } - - this.flushRuntime.markFlushStarted() - - try { - const sendSuccess = await this.trySendBatches(batches) - - if (sendSuccess) { - this.queue.clear() - this.flushRuntime.handleFlushSuccess() - } else { - this.flushRuntime.handleFlushFailure({ - queuedBatches: batches.length, - queuedEvents: this.getQueuedEventCount(), - }) - } - } finally { - this.flushRuntime.markFlushFinished() - this.reconcilePeriodicFlushTimer() - } - } - - /** - * Apply default stateful analytics values when provided. - * - * @param defaults - Optional defaults for analytics state. - */ - private applyDefaults(defaults: AnalyticsProductConfigDefaults | undefined): void { - if (defaults?.profile === undefined) return - - const { profile: defaultProfile } = defaults - profileSignal.value = defaultProfile - } - - /** - * Initialize reactive effects for consent/profile logging and online flushes. - */ - private initializeEffects(): void { - effect(() => { - const id = profileSignal.value?.id - - logger.info( - `Analytics ${consent.value ? 'will' : 'will not'} be collected due to consent (${consent.value})`, - ) - - logger.debug(`Profile ${id && `with ID ${id}`} has been ${id ? 'set' : 'cleared'}`) - }) - - effect(() => { - if (!onlineSignal.value) return - - this.flushRuntime.clearScheduledRetry() - void this.flush({ force: true }) - }) - } - - /** - * Build batch payloads grouped by profile from the in-memory queue. - * - * @returns Grouped batch payloads. - */ - private createBatches(): BatchInsightsEventArray { - const batches: BatchInsightsEventArray = [] - - this.queue.forEach(({ profile, events }) => { - batches.push({ profile, events }) - }) - - return batches - } - - /** - * Attempt to send queued batches to the Insights API. - * - * @param batches - Batches to send. - * @returns `true` when send succeeds; otherwise `false`. - */ - private async trySendBatches(batches: BatchInsightsEventArray): Promise { - try { - return await this.api.insights.sendBatchEvents(batches) - } catch (error) { - logger.warn('Analytics queue flush request threw an error', error) - return false - } - } - - /** - * Compute the total number of queued events across all profiles. - * - * @returns Total queued event count. - */ - private getQueuedEventCount(): number { - let queuedCount = 0 - - this.queue.forEach(({ events }) => { - queuedCount += events.length - }) - - return queuedCount - } - - /** - * Start periodic flush attempts when the queue contains events. - */ - private ensurePeriodicFlushTimer(): void { - if (this.periodicFlushTimer !== undefined) return - if (this.getQueuedEventCount() === 0) return - - this.periodicFlushTimer = setInterval(() => { - void this.flush() - }, this.flushIntervalMs) - } - - /** - * Clear periodic queue flush timer when no longer needed. - */ - private clearPeriodicFlushTimer(): void { - if (this.periodicFlushTimer === undefined) return - - clearInterval(this.periodicFlushTimer) - this.periodicFlushTimer = undefined - } - - /** - * Keep periodic flush scheduling in sync with queue occupancy. - */ - private reconcilePeriodicFlushTimer(): void { - if (this.getQueuedEventCount() > 0) { - this.ensurePeriodicFlushTimer() - return - } - - this.clearPeriodicFlushTimer() - } -} - -export default AnalyticsStateful diff --git a/packages/universal/core-sdk/src/analytics/AnalyticsStateless.ts b/packages/universal/core-sdk/src/analytics/AnalyticsStateless.ts deleted file mode 100644 index 4f9f77fa..00000000 --- a/packages/universal/core-sdk/src/analytics/AnalyticsStateless.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - InsightsEvent as AnalyticsEvent, - BatchInsightsEventArray, - parseWithFriendlyError, - type PartialProfile, -} from '@contentful/optimization-api-client/api-schemas' -import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { - ClickBuilderArgs, - FlagViewBuilderArgs, - HoverBuilderArgs, - ViewBuilderArgs, -} from '../events' -import AnalyticsBase from './AnalyticsBase' - -const logger = createScopedLogger('Analytics') - -/** - * Arguments for tracking a component/flag view in stateless mode. - * - * @public - * @remarks - * The `profile` is optional; when omitted, the APIs may infer identity via - * other means. - */ -export type TrackViewArgs = ViewBuilderArgs & { profile?: PartialProfile } -export type TrackFlagViewArgs = FlagViewBuilderArgs & { profile?: PartialProfile } -export type TrackClickArgs = ClickBuilderArgs & { profile?: PartialProfile } -export type TrackHoverArgs = HoverBuilderArgs & { profile?: PartialProfile } - -/** - * Stateless analytics implementation that sends each event immediately in a - * single‑event batch. - * - * @public - */ -class AnalyticsStateless extends AnalyticsBase { - /** - * Build, intercept, validate, and send a component view event. - * - * @param args - {@link TrackViewArgs} used to build the event. Includes an - * optional partial profile. - * @returns A promise that resolves once the batch has been sent. - * @example - * ```ts - * await analytics.trackView({ componentId: 'hero-banner', profile: { id: 'user-1' } }) - * ``` - */ - async trackView(args: TrackViewArgs): Promise { - logger.info('Processing "component view" event') - - const { profile, ...builderArgs } = args - - const event = this.eventBuilder.buildView(builderArgs) - - await this.sendBatchEvent(event, profile) - } - - /** - * Build, intercept, validate, and send a component click event. - * - * @param args - {@link TrackClickArgs} used to build the event. Includes an - * optional partial profile. - * @returns A promise that resolves once the batch has been sent. - * @example - * ```ts - * await analytics.trackClick({ componentId: 'hero-banner', profile: { id: 'user-1' } }) - * ``` - */ - async trackClick(args: TrackClickArgs): Promise { - logger.info('Processing "component click" event') - - const { profile, ...builderArgs } = args - - const event = this.eventBuilder.buildClick(builderArgs) - - await this.sendBatchEvent(event, profile) - } - - /** - * Build, intercept, validate, and send a component hover event. - * - * @param args - {@link TrackHoverArgs} used to build the event. Includes an - * optional partial profile. - * @returns A promise that resolves once the batch has been sent. - * @example - * ```ts - * await analytics.trackHover({ componentId: 'hero-banner', profile: { id: 'user-1' } }) - * ``` - */ - async trackHover(args: TrackHoverArgs): Promise { - logger.info('Processing "component hover" event') - - const { profile, ...builderArgs } = args - - const event = this.eventBuilder.buildHover(builderArgs) - - await this.sendBatchEvent(event, profile) - } - - /** - * Build, intercept, validate, and send a flag view event. - * - * @param args - {@link TrackViewArgs} used to build the event. Includes an - * optional partial profile. - * @returns A promise that resolves once the batch has been sent. - * @example - * ```ts - * await analytics.trackFlagView({ componentId: 'feature-flag-123' }) - * ``` - */ - async trackFlagView(args: TrackFlagViewArgs): Promise { - logger.debug('Processing "flag view" event') - - const { profile, ...builderArgs } = args - - const event = this.eventBuilder.buildFlagView(builderArgs) - - await this.sendBatchEvent(event, profile) - } - - /** - * Send a single {@link AnalyticsEvent} wrapped in a one‑item batch. - * - * @param event - The event to send. - * @param profile - Optional partial profile to attach to the batch. - * @returns A promise that resolves when the API call completes. - * @internal - */ - private async sendBatchEvent(event: AnalyticsEvent, profile?: PartialProfile): Promise { - const intercepted = await this.interceptors.event.run(event) - - const parsed = parseWithFriendlyError(AnalyticsEvent, intercepted) - - const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ - { - profile, - events: [parsed], - }, - ]) - - await this.api.insights.sendBatchEvents(batchEvent) - } -} - -export default AnalyticsStateless diff --git a/packages/universal/core-sdk/src/analytics/index.ts b/packages/universal/core-sdk/src/analytics/index.ts deleted file mode 100644 index 14478e21..00000000 --- a/packages/universal/core-sdk/src/analytics/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './AnalyticsStateful' -export { default as AnalyticsStateful } from './AnalyticsStateful' - -export * from './AnalyticsStateless' -export { default as AnalyticsStateless } from './AnalyticsStateless' diff --git a/packages/universal/core-sdk/src/events/EventBuilder.ts b/packages/universal/core-sdk/src/events/EventBuilder.ts index e064aa7a..66fe82e2 100644 --- a/packages/universal/core-sdk/src/events/EventBuilder.ts +++ b/packages/universal/core-sdk/src/events/EventBuilder.ts @@ -272,7 +272,7 @@ export const DEFAULT_PAGE_PROPERTIES = { } /** - * Helper class for building analytics and personalization events. + * Helper class for building optimization events. * * @remarks * This class coordinates configuration and argument validation to produce diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index c7a4ea66..2defd84d 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -14,7 +14,6 @@ export { type Signals, } from './signals' -export * from './analytics' export type * from './BlockedEvent' export * from './constants' export * from './CoreBase' @@ -22,7 +21,7 @@ export * from './CoreStateful' export * from './CoreStateless' export * from './lib/decorators' export * from './lib/interceptor' -export * from './personalization' +export * from './resolvers' export * from './symbols' export { default as CoreStateful } from './CoreStateful' diff --git a/packages/universal/core-sdk/src/personalization/PersonalizationBase.ts b/packages/universal/core-sdk/src/personalization/PersonalizationBase.ts deleted file mode 100644 index b9ce50f5..00000000 --- a/packages/universal/core-sdk/src/personalization/PersonalizationBase.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { - ChangeArray, - Json, - MergeTagEntry, - OptimizationData, - Profile, - SelectedPersonalizationArray, -} from '@contentful/optimization-api-client/api-schemas' -import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' -import type { - IdentifyBuilderArgs, - PageViewBuilderArgs, - ScreenViewBuilderArgs, - TrackBuilderArgs, - ViewBuilderArgs, -} from '../events' -import ProductBase from '../ProductBase' -import { - FlagsResolver, - MergeTagValueResolver, - PersonalizedEntryResolver, - type ResolvedData, -} from './resolvers' - -/** - * These methods assist in resolving values via Resolvers - * - * @internal - * @privateRemarks - * This interface exists to document that the included methods should not be - * considered static. - */ -export interface ResolverMethods { - /** - * Get the specified Custom Flag's value from the supplied changes. - * @param name - The name or key of the Custom Flag. - * @param changes - Optional changes array. - * @returns The current value of the Custom Flag if found. - * @remarks - * The changes array can be sourced from the data returned when emitting any - * personalization event. - * */ - getFlag: (name: string, changes?: ChangeArray) => Json - - /** - * Resolve a Contentful entry to a personalized variant using the current - * or provided selected personalizations. - * - * @typeParam S - Entry skeleton type. - * @typeParam M - Chain modifiers. - * @typeParam L - Locale code. - * @param entry - The entry to personalize. - * @param personalizations - Optional selections. - * @returns The resolved entry data. - * @remarks - * Selected personalizations can be sourced from the data returned when emitting any - * personalization event. - */ - personalizeEntry: (< - S extends EntrySkeletonType = EntrySkeletonType, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - personalizations?: SelectedPersonalizationArray, - ) => ResolvedData) & - (< - S extends EntrySkeletonType, - M extends ChainModifiers = ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - personalizations?: SelectedPersonalizationArray, - ) => ResolvedData) - - /** - * Resolve a merge tag to a value based on the current (or provided) profile. - * - * @param embeddedEntryNodeTarget - The merge‑tag entry node to resolve. - * @param profile - Optional profile. - * @returns The resolved value (type depends on the tag). - * @remarks - * Merge tags are references to profile data that can be substituted into content. The - * profile can be sourced from the data returned when emitting any personalization event. - */ - getMergeTagValue: ( - embeddedEntryNodeTarget: MergeTagEntry, - profile?: Profile, - ) => string | undefined -} - -/** - * Internal base for personalization products. - * - * @internal - * @remarks - * Concrete implementations should extend this class to expose public methods for - * identify, page, and track events. This base wires in shared singleton - * resolvers used to fetch/resolve personalized data. - */ -abstract class PersonalizationBase extends ProductBase implements ResolverMethods { - /** - * Static {@link FlagsResolver | resolver} for evaluating personalized - * custom flags. - */ - readonly flagsResolver = FlagsResolver - - /** - * Static {@link MergeTagValueResolver | resolver} that returns values - * sourced from a user profile based on a Contentful Merge Tag entry. - */ - readonly mergeTagValueResolver = MergeTagValueResolver - - /** - * Static {@link PersonalizedEntryResolver | resolver } for personalized - * Contentful entries (e.g., entry variants targeted to a profile audience). - * - * @remarks - * Used by higher-level personalization flows to materialize entry content - * prior to event emission. - */ - readonly personalizedEntryResolver = PersonalizedEntryResolver - - /** - * Get the specified Custom Flag's value from the supplied changes. - * @param name - The name/key of the Custom Flag. - * @param changes - Optional changes array. - * @returns The current value of the Custom Flag if found. - * @remarks - * The changes array can be sourced from the data returned when emitting any - * personalization event. - * */ - getFlag(name: string, changes?: ChangeArray): Json { - return this.flagsResolver.resolve(changes)[name] - } - - /** - * Resolve a Contentful entry to a personalized variant using the current - * or provided selected personalizations. - * - * @typeParam S - Entry skeleton type. - * @typeParam M - Chain modifiers. - * @typeParam L - Locale code. - * @param entry - The entry to personalize. - * @param selectedPersonalizations - Optional selected personalizations. - * @returns The resolved entry data. - * @remarks - * Selected personalizations can be sourced from the data returned when emitting any - * personalization event. - */ - personalizeEntry< - S extends EntrySkeletonType = EntrySkeletonType, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations?: SelectedPersonalizationArray, - ): ResolvedData - personalizeEntry< - S extends EntrySkeletonType, - M extends ChainModifiers = ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations?: SelectedPersonalizationArray, - ): ResolvedData - personalizeEntry< - S extends EntrySkeletonType, - M extends ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations?: SelectedPersonalizationArray, - ): ResolvedData { - return PersonalizedEntryResolver.resolve(entry, selectedPersonalizations) - } - - /** - * Resolve a merge tag to a value based on the current (or provided) profile. - * - * @param embeddedEntryNodeTarget - The merge tag entry node to resolve. - * @param profile - Optional profile. - * @returns The resolved value (type depends on the tag). - * @remarks - * Merge tags are references to profile data that can be substituted into content. The - * profile can be sourced from the data returned when emitting any personalization event. - */ - getMergeTagValue(embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile): string | undefined { - return MergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) - } - - /** - * Identify the current profile/visitor to associate traits with a profile. - * - * @param payload - Identify builder payload. - * @returns The resulting {@link OptimizationData} for the identified user if the device is online. - */ - abstract identify(payload: IdentifyBuilderArgs): Promise - - /** - * Record a page view. - * - * @param payload - Page view builder payload. - * @returns The evaluated {@link OptimizationData} for this page view if the device is online. - */ - abstract page(payload: PageViewBuilderArgs): Promise - - /** - * Record a screen view. - * - * @param payload - Screen view builder payload. - * @returns The evaluated {@link OptimizationData} for this screen view if the device is online. - */ - abstract screen(payload: ScreenViewBuilderArgs): Promise - - /** - * Record a custom track event. - * - * @param payload - Track builder payload. - * @returns The evaluated {@link OptimizationData} for this event if the device is online. - */ - abstract track(payload: TrackBuilderArgs): Promise - - /** - * Record a "sticky" component view. - * - * @param payload - "Sticky" component view builder payload. - * @returns The evaluated {@link OptimizationData} for this component view if the device is online. - * @remarks - * This method is intended to be called only when a component is considered - * "sticky". - */ - abstract trackView(payload: ViewBuilderArgs): Promise -} - -export default PersonalizationBase diff --git a/packages/universal/core-sdk/src/personalization/PersonalizationStateful.test.ts b/packages/universal/core-sdk/src/personalization/PersonalizationStateful.test.ts deleted file mode 100644 index 1d348b0a..00000000 --- a/packages/universal/core-sdk/src/personalization/PersonalizationStateful.test.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { ApiClient } from '@contentful/optimization-api-client' -import type { - ChangeArray, - ExperienceEventArray, - OptimizationData, - Profile, -} from '@contentful/optimization-api-client/api-schemas' -import type { LifecycleInterceptors } from '../CoreBase' -import { EventBuilder } from '../events' -import { InterceptorManager } from '../lib/interceptor' -import type { QueueFlushFailureContext, QueueFlushRecoveredContext } from '../lib/queue' -import { batch, changes as changesSignal, consent, online, profile } from '../signals' -import PersonalizationStateful, { - type PersonalizationOfflineQueueDropContext, - type PersonalizationQueuePolicy, -} from './PersonalizationStateful' - -interface CreatePersonalizationOptions { - queuePolicy?: PersonalizationQueuePolicy - upsertProfile?: ApiClient['experience']['upsertProfile'] -} - -const DEFAULT_PROFILE: Profile = { - id: 'profile-id', - stableId: 'profile-id', - random: 1, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'session-id', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'https://example.test/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, -} - -const EMPTY_OPTIMIZATION_DATA: OptimizationData = { - changes: [], - selectedPersonalizations: [], - profile: DEFAULT_PROFILE, -} - -const createPersonalization = ( - options: CreatePersonalizationOptions = {}, -): PersonalizationStateful => { - const { queuePolicy } = options - const upsertProfile = - options.upsertProfile ?? - rs.fn().mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - const api = new ApiClient({ - clientId: 'key_123', - environment: 'main', - }) - rs.spyOn(api.experience, 'upsertProfile').mockImplementation(upsertProfile) - - const builder = new EventBuilder({ - channel: 'web', - library: { - name: 'Optimization Core Tests', - version: '0.0.0', - }, - }) - - const interceptors: LifecycleInterceptors = { - event: new InterceptorManager(), - state: new InterceptorManager(), - } - - return new PersonalizationStateful({ - api, - eventBuilder: builder, - interceptors, - config: { - defaults: { - profile: DEFAULT_PROFILE, - }, - queuePolicy, - }, - }) -} - -const getOfflineQueue = (personalization: PersonalizationStateful): Set => { - const queue = Reflect.get(personalization, 'offlineQueue') - - if (!(queue instanceof Set)) { - throw new TypeError('Expected PersonalizationStateful.offlineQueue to be a Set') - } - - return queue -} - -const getTrackNamesFromQueue = (queue: Set): string[] => { - const names: string[] = [] - - queue.forEach((event) => { - if (typeof event !== 'object' || event === null) return - - const type = Reflect.get(event, 'type') - const name = Reflect.get(event, 'event') - - if (type === 'track' && typeof name === 'string') names.push(name) - }) - - return names -} - -const getTrackNamesFromEvents = (events: ExperienceEventArray): string[] => - events.flatMap((event) => (event.type === 'track' ? [event.event] : [])) - -const enqueueOfflineTrackEvent = ( - personalization: PersonalizationStateful, - event: string, -): void => { - const enqueueOfflineEventValue: unknown = Reflect.get(personalization, 'enqueueOfflineEvent') - const builderValue: unknown = Reflect.get(personalization, 'eventBuilder') - - if (typeof enqueueOfflineEventValue !== 'function') { - throw new TypeError( - 'Expected PersonalizationStateful.enqueueOfflineEvent to be a callable function', - ) - } - - if (!(builderValue instanceof EventBuilder)) { - throw new TypeError( - 'Expected PersonalizationStateful.eventBuilder to be an EventBuilder instance', - ) - } - - const trackEvent = builderValue.buildTrack({ event }) - - Reflect.apply(enqueueOfflineEventValue, personalization, [trackEvent]) -} - -describe('PersonalizationStateful offline queue policy', () => { - beforeEach(() => { - batch(() => { - consent.value = true - online.value = false - profile.value = undefined - }) - - rs.useFakeTimers() - }) - - afterEach(() => { - rs.clearAllTimers() - rs.restoreAllMocks() - rs.useRealTimers() - }) - - it('drops oldest events when queue exceeds maxEvents', async () => { - const personalization = createPersonalization({ - queuePolicy: { - maxEvents: 3, - }, - }) - - await personalization.track({ event: 'e1' }) - await personalization.track({ event: 'e2' }) - await personalization.track({ event: 'e3' }) - await personalization.track({ event: 'e4' }) - await personalization.track({ event: 'e5' }) - - const queue = getOfflineQueue(personalization) - - expect(queue.size).toBe(3) - expect(getTrackNamesFromQueue(queue)).toEqual(['e3', 'e4', 'e5']) - }) - - it('emits queue drop telemetry callback when events are dropped', async () => { - const onDrop = rs.fn<(context: PersonalizationOfflineQueueDropContext) => void>() - const personalization = createPersonalization({ - queuePolicy: { - maxEvents: 2, - onDrop, - }, - }) - - await personalization.track({ event: 'e1' }) - await personalization.track({ event: 'e2' }) - await personalization.track({ event: 'e3' }) - - expect(onDrop).toHaveBeenCalledTimes(1) - expect(onDrop).toHaveBeenCalledWith( - expect.objectContaining({ - droppedCount: 1, - maxEvents: 2, - queuedEvents: 2, - }), - ) - - const droppedEvents = onDrop.mock.calls[0]?.[0].droppedEvents - - if (!droppedEvents) throw new TypeError('Expected onDrop callback to receive dropped events') - - expect(getTrackNamesFromEvents(droppedEvents)).toEqual(['e1']) - }) - - it('swallows queue drop callback failures', async () => { - const onDrop = rs - .fn<(context: PersonalizationOfflineQueueDropContext) => void>() - .mockImplementation(() => { - throw new Error('telemetry-down') - }) - const personalization = createPersonalization({ - queuePolicy: { - maxEvents: 2, - onDrop, - }, - }) - - await personalization.track({ event: 'e1' }) - await personalization.track({ event: 'e2' }) - - await expect(personalization.track({ event: 'e3' })).resolves.toBeUndefined() - expect(onDrop).toHaveBeenCalledTimes(1) - }) - - it('flushes retained events after oldest events are dropped', async () => { - const sentEvents: ExperienceEventArray[] = [] - const upsertProfile = rs - .fn() - .mockImplementation(async ({ events }) => { - sentEvents.push(events) - await Promise.resolve() - return EMPTY_OPTIMIZATION_DATA - }) - const personalization = createPersonalization({ - upsertProfile, - queuePolicy: { - maxEvents: 2, - }, - }) - - await personalization.track({ event: 'e1' }) - await personalization.track({ event: 'e2' }) - await personalization.track({ event: 'e3' }) - await personalization.track({ event: 'e4' }) - - await personalization.flush({ force: true }) - - expect(sentEvents).toHaveLength(1) - expect(getTrackNamesFromEvents(sentEvents[0] ?? [])).toEqual(['e3', 'e4']) - expect(getOfflineQueue(personalization).size).toBe(0) - }) - - it('retries failed offline queue flushes with backoff and clears the queue after recovery', async () => { - const upsertProfile = rs - .fn() - .mockRejectedValueOnce(new Error('network-down')) - .mockRejectedValueOnce(new Error('still-down')) - .mockResolvedValueOnce(EMPTY_OPTIMIZATION_DATA) - const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() - const onFlushRecovered = rs.fn<(context: QueueFlushRecoveredContext) => void>() - batch(() => { - online.value = true - }) - - const personalization = createPersonalization({ - upsertProfile, - queuePolicy: { - flushPolicy: { - baseBackoffMs: 20, - maxBackoffMs: 20, - jitterRatio: 0, - maxConsecutiveFailures: 5, - circuitOpenMs: 200, - onFlushFailure, - onFlushRecovered, - }, - }, - }) - - enqueueOfflineTrackEvent(personalization, 'e1') - - await personalization.flush() - - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(onFlushFailure).toHaveBeenCalledTimes(1) - expect(onFlushFailure).toHaveBeenCalledWith( - expect.objectContaining({ - consecutiveFailures: 1, - queuedBatches: 1, - queuedEvents: 1, - retryDelayMs: 20, - }), - ) - expect(getOfflineQueue(personalization).size).toBe(1) - - await rs.advanceTimersByTimeAsync(20) - - expect(upsertProfile).toHaveBeenCalledTimes(2) - expect(onFlushFailure).toHaveBeenCalledTimes(2) - expect(getOfflineQueue(personalization).size).toBe(1) - - await rs.advanceTimersByTimeAsync(20) - - expect(upsertProfile).toHaveBeenCalledTimes(3) - expect(onFlushRecovered).toHaveBeenCalledTimes(1) - expect(onFlushRecovered).toHaveBeenCalledWith({ consecutiveFailures: 2 }) - expect(getOfflineQueue(personalization).size).toBe(0) - }) - - it('opens a circuit window after repeated offline flush failures', async () => { - const upsertProfile = rs - .fn() - .mockRejectedValue(new Error('network-down')) - const onCircuitOpen = rs.fn<(context: QueueFlushFailureContext) => void>() - batch(() => { - online.value = true - }) - - const personalization = createPersonalization({ - upsertProfile, - queuePolicy: { - flushPolicy: { - baseBackoffMs: 5, - maxBackoffMs: 5, - jitterRatio: 0, - maxConsecutiveFailures: 2, - circuitOpenMs: 50, - onCircuitOpen, - }, - }, - }) - - enqueueOfflineTrackEvent(personalization, 'e1') - - await personalization.flush() - - expect(upsertProfile).toHaveBeenCalledTimes(1) - - await rs.advanceTimersByTimeAsync(5) - - expect(upsertProfile).toHaveBeenCalledTimes(2) - expect(onCircuitOpen).toHaveBeenCalledTimes(1) - expect(onCircuitOpen).toHaveBeenCalledWith( - expect.objectContaining({ - consecutiveFailures: 2, - queuedBatches: 1, - queuedEvents: 1, - retryDelayMs: 50, - }), - ) - expect(getOfflineQueue(personalization).size).toBe(1) - - await rs.advanceTimersByTimeAsync(49) - expect(upsertProfile).toHaveBeenCalledTimes(2) - - await rs.advanceTimersByTimeAsync(1) - expect(upsertProfile).toHaveBeenCalledTimes(3) - }) - - it('does not queue online personalization events when immediate delivery fails', async () => { - const upsertProfile = rs - .fn() - .mockRejectedValue(new Error('network-down')) - const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() - const personalization = createPersonalization({ - upsertProfile, - queuePolicy: { - flushPolicy: { - onFlushFailure, - }, - }, - }) - - batch(() => { - online.value = true - }) - await rs.advanceTimersByTimeAsync(0) - - await expect(personalization.track({ event: 'e1' })).rejects.toThrow('network-down') - - expect(getOfflineQueue(personalization).size).toBe(0) - expect(onFlushFailure).not.toHaveBeenCalled() - expect(upsertProfile).toHaveBeenCalledTimes(1) - }) -}) - -const DARK_MODE_CHANGE: ChangeArray[number] = { - key: 'dark-mode', - type: 'Variable', - value: true, - meta: { - experienceId: 'experience-id', - variantIndex: 0, - }, -} - -const CONFIG_CHANGE: ChangeArray[number] = { - key: 'config', - type: 'Variable', - value: { - value: { - amount: 10, - currency: 'USD', - }, - }, - meta: { - experienceId: 'experience-id', - variantIndex: 1, - }, -} - -const CHANGES: ChangeArray = [DARK_MODE_CHANGE, CONFIG_CHANGE] - -describe('PersonalizationStateful custom flags', () => { - beforeEach(() => { - batch(() => { - changesSignal.value = undefined - }) - }) - - afterEach(() => { - rs.restoreAllMocks() - }) - - it('resolves custom flag values from provided changes', () => { - const personalization = createPersonalization() - - expect(personalization.getFlag('dark-mode', CHANGES)).toBe(true) - expect(personalization.getFlag('config', CHANGES)).toEqual({ - amount: 10, - currency: 'USD', - }) - }) - - it('uses signal changes by default when resolving custom flag values', () => { - const personalization = createPersonalization() - - batch(() => { - changesSignal.value = CHANGES - }) - - expect(personalization.getFlag('dark-mode')).toBe(true) - expect(personalization.getFlag('config')).toEqual({ - amount: 10, - currency: 'USD', - }) - }) - - it('exposes flag observables scoped to a key', () => { - const personalization = createPersonalization() - const values: Array = [] - const subscription = personalization.states.flag('dark-mode').subscribe((value) => { - values.push(value === undefined ? undefined : Boolean(value)) - }) - - batch(() => { - changesSignal.value = CHANGES - }) - - batch(() => { - changesSignal.value = [ - ...CHANGES, - { - key: 'other-flag', - type: 'Variable', - value: 'unchanged', - meta: { - experienceId: 'another-experience', - variantIndex: 0, - }, - }, - ] - }) - - batch(() => { - changesSignal.value = [ - { - ...DARK_MODE_CHANGE, - value: false, - }, - CONFIG_CHANGE, - ] - }) - - expect(values).toEqual([undefined, true, false]) - - subscription.unsubscribe() - }) -}) diff --git a/packages/universal/core-sdk/src/personalization/PersonalizationStateful.ts b/packages/universal/core-sdk/src/personalization/PersonalizationStateful.ts deleted file mode 100644 index 4f87c45f..00000000 --- a/packages/universal/core-sdk/src/personalization/PersonalizationStateful.ts +++ /dev/null @@ -1,744 +0,0 @@ -import { - type InsightsEvent as AnalyticsEvent, - type ChangeArray, - type Json, - type MergeTagEntry, - type OptimizationData, - parseWithFriendlyError, - ExperienceEvent as PersonalizationEvent, - type ExperienceEventArray as PersonalizationEventArray, - type Profile, - type SelectedPersonalizationArray, -} from '@contentful/optimization-api-client/api-schemas' -import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' -import { isEqual } from 'es-toolkit/predicate' -import type { BlockedEvent } from '../BlockedEvent' -import type { ConsentGuard } from '../Consent' -import type { - IdentifyBuilderArgs, - PageViewBuilderArgs, - ScreenViewBuilderArgs, - TrackBuilderArgs, - ViewBuilderArgs, -} from '../events' -import { guardedBy } from '../lib/decorators' -import { toPositiveInt } from '../lib/number' -import { - type QueueFlushPolicy, - QueueFlushRuntime, - type ResolvedQueueFlushPolicy, - resolveQueueFlushPolicy, -} from '../lib/queue' -import type { ProductBaseOptions, ProductConfig } from '../ProductBase' -import { - batch, - blockedEvent as blockedEventSignal, - canPersonalize as canPersonalizeSignal, - changes as changesSignal, - consent as consentSignal, - effect, - event as eventSignal, - type Observable, - online as onlineSignal, - profile as profileSignal, - selectedPersonalizations as selectedPersonalizationsSignal, - signalFns, - toDistinctObservable, - toObservable, -} from '../signals' -import PersonalizationBase from './PersonalizationBase' -import type { ResolvedData } from './resolvers' - -const logger = createScopedLogger('Personalization') - -/** - * Default state values for {@link PersonalizationStateful} applied at construction time. - * - * @public - */ -export interface PersonalizationProductConfigDefaults { - /** Whether personalization is allowed by default. */ - consent?: boolean - /** Initial diff of changes produced by the service. */ - changes?: ChangeArray - /** Default active profile used for personalization. */ - profile?: Profile - /** Preselected personalization variants (e.g., winning treatments). */ - selectedPersonalizations?: SelectedPersonalizationArray -} - -/** - * Configuration for {@link PersonalizationStateful}. - * - * @public - */ -export interface PersonalizationProductConfig extends ProductConfig { - /** Default signal values applied during initialization. */ - defaults?: PersonalizationProductConfigDefaults - - /** - * Policy that controls the offline personalization queue size and drop telemetry. - */ - queuePolicy?: PersonalizationQueuePolicy - - /** - * Function used to obtain an anonymous user identifier. - * - * @remarks - * If a `getAnonymousId` function has been provided, the returned value will - * take precedence over the `id` property of the current {@link Profile} - * signal value - * - * @returns A string identifier, or `undefined` if no anonymous ID is available. - */ - getAnonymousId?: () => string | undefined -} - -/** - * Context payload emitted when offline personalization events are dropped due to queue bounds. - * - * @public - */ -export interface PersonalizationOfflineQueueDropContext { - /** Number of dropped events. */ - droppedCount: number - /** Dropped events in oldest-first order. */ - droppedEvents: PersonalizationEventArray - /** Configured queue max size. */ - maxEvents: number - /** Queue size after enqueueing the current event. */ - queuedEvents: number -} - -/** - * Policy options for the stateful personalization offline queue. - * - * @public - */ -export interface PersonalizationQueuePolicy { - /** - * Maximum number of personalization events retained while offline. - */ - maxEvents?: number - - /** - * Callback invoked whenever oldest events are dropped due to queue bounds. - */ - onDrop?: (context: PersonalizationOfflineQueueDropContext) => void - - /** - * Policy that controls offline queue flush retries, backoff, and circuit - * behavior after repeated failures. - */ - flushPolicy?: QueueFlushPolicy -} - -/** - * Observables exposed by {@link PersonalizationStateful} that mirror internal signals. - * - * @public - */ -export interface PersonalizationStates { - /** Observable stream of the latest blocked event payload (or `undefined`). */ - blockedEventStream: Observable - /** Observable stream of the latest {@link AnalyticsEvent} or {@link PersonalizationEvent} (or `undefined`). */ - eventStream: Observable - /** Key-scoped observable for a single Custom Flag value. */ - flag: (name: string) => Observable - /** Live view of the current profile. */ - profile: Observable - /** Live view of selected personalizations (variants). */ - selectedPersonalizations: Observable - /** Whether personalization data is currently available for entry resolution. */ - canPersonalize: Observable -} - -/** - * Options for configuring {@link PersonalizationStateful} functionality. - * - * @public - * @see {@link ProductBaseOptions} - */ -export type PersonalizationStatefulOptions = ProductBaseOptions & { - /** Configuration specific to the Personalization product */ - config?: PersonalizationProductConfig -} - -const OFFLINE_QUEUE_MAX_EVENTS = 100 - -interface ResolvedQueuePolicy { - maxEvents: number - onDrop?: PersonalizationQueuePolicy['onDrop'] - flushPolicy: ResolvedQueueFlushPolicy -} - -const resolvePersonalizationQueuePolicy = ( - policy: PersonalizationQueuePolicy | undefined, -): ResolvedQueuePolicy => ({ - maxEvents: toPositiveInt(policy?.maxEvents, OFFLINE_QUEUE_MAX_EVENTS), - onDrop: policy?.onDrop, - flushPolicy: resolveQueueFlushPolicy(policy?.flushPolicy), -}) - -/** - * Stateful personalization product that manages consent, profile, flags, and - * selected variants while emitting Experience events and updating state. - * - * @public - * @remarks - * The class maintains reactive signals and exposes read‑only observables via - * {@link PersonalizationStateful.states}. Events are validated via schema parsers and - * run through interceptors before being submitted. Resulting state is merged - * back into signals. - */ -class PersonalizationStateful extends PersonalizationBase implements ConsentGuard { - /** In-memory ordered queue for offline personalization events. */ - private readonly offlineQueue = new Set() - /** Cached key-scoped flag observables. */ - private readonly flagObservables = new Map>() - /** Resolved offline queue policy values. */ - private readonly queuePolicy: ResolvedQueuePolicy - /** Shared queue flush retry runtime state machine. */ - private readonly flushRuntime: QueueFlushRuntime - - /** Exposed observable state references. */ - readonly states: PersonalizationStates = { - blockedEventStream: toObservable(blockedEventSignal), - flag: (name: string): Observable => this.getFlagObservable(name), - eventStream: toObservable(eventSignal), - profile: toObservable(profileSignal), - selectedPersonalizations: toObservable(selectedPersonalizationsSignal), - canPersonalize: toObservable(canPersonalizeSignal), - } - - /** - * Function that provides an anonymous ID when available. - * - * @internal - */ - getAnonymousId: () => string | undefined - - /** - * Create a new stateful personalization instance. - * - * @param options - Options to configure the personalization product for stateful environments. - * @example - * ```ts - * const personalization = new PersonalizationStateful({ api, builder, config: { getAnonymousId }, interceptors }) - * ``` - */ - constructor(options: PersonalizationStatefulOptions) { - const { api, eventBuilder, config, interceptors } = options - - super({ api, eventBuilder, config, interceptors }) - - const { defaults, getAnonymousId, queuePolicy } = config ?? {} - - this.queuePolicy = resolvePersonalizationQueuePolicy(queuePolicy) - this.flushRuntime = new QueueFlushRuntime({ - policy: this.queuePolicy.flushPolicy, - onRetry: () => { - void this.flush() - }, - onCallbackError: (callbackName, error) => { - logger.warn(`Personalization flush policy callback "${callbackName}" failed`, error) - }, - }) - - if (defaults) { - const { - changes: defaultChanges, - selectedPersonalizations: defaultPersonalizations, - profile: defaultProfile, - } = defaults - - batch(() => { - changesSignal.value = defaultChanges - selectedPersonalizationsSignal.value = defaultPersonalizations - profileSignal.value = defaultProfile - }) - } - - if (defaults?.consent !== undefined) { - const { consent: defaultConsent } = defaults - consentSignal.value = defaultConsent - } - - this.getAnonymousId = getAnonymousId ?? (() => undefined) - - // Log signal changes for observability - effect(() => { - logger.debug( - `Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, - ) - }) - - effect(() => { - logger.debug( - `Variants have been ${selectedPersonalizationsSignal.value?.length ? 'populated' : 'cleared'}`, - ) - }) - - effect(() => { - logger.info( - `Personalization ${consentSignal.value ? 'will' : 'will not'} take effect due to consent (${consentSignal.value})`, - ) - }) - - effect(() => { - if (!onlineSignal.value) return - - this.flushRuntime.clearScheduledRetry() - void this.flush({ force: true }) - }) - } - - private getFlagObservable(name: string): Observable { - const existingObservable = this.flagObservables.get(name) - if (existingObservable) return existingObservable - - const valueSignal = signalFns.computed(() => super.getFlag(name, changesSignal.value)) - const observable = toDistinctObservable(valueSignal, isEqual) - this.flagObservables.set(name, observable) - - return observable - } - - /** - * Reset stateful signals managed by this product. - * - * @remarks - * Clears `changes`, `profile`, and selected `personalizations`. - * @example - * ```ts - * personalization.reset() - * ``` - */ - reset(): void { - this.flushRuntime.reset() - - batch(() => { - changesSignal.value = undefined - blockedEventSignal.value = undefined - eventSignal.value = undefined - profileSignal.value = undefined - selectedPersonalizationsSignal.value = undefined - }) - } - - /** - * Get the specified Custom Flag's value (derived from the signal). - * @param name - The name or key of the Custom Flag. - * @returns The current value of the Custom Flag if found. - * @example - * ```ts - * const flagValue = personalization.getFlag('dark-mode') - * ``` - * */ - getFlag(name: string, changes: ChangeArray | undefined = changesSignal.value): Json { - return super.getFlag(name, changes) - } - - /** - * Resolve a Contentful entry to a personalized variant using the current - * or provided selections. - * - * @typeParam S - Entry skeleton type. - * @typeParam M - Chain modifiers. - * @typeParam L - Locale code. - * @param entry - The entry to personalize. - * @param selectedPersonalizations - Optional selections; defaults to the current signal value. - * @returns The resolved entry data. - * @example - * ```ts - * const { entry } = personalization.personalizeEntry(baselineEntry) - * ``` - */ - override personalizeEntry< - S extends EntrySkeletonType = EntrySkeletonType, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations?: SelectedPersonalizationArray, - ): ResolvedData - override personalizeEntry< - S extends EntrySkeletonType, - M extends ChainModifiers = ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations?: SelectedPersonalizationArray, - ): ResolvedData - override personalizeEntry< - S extends EntrySkeletonType, - M extends ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedPersonalizations: - | SelectedPersonalizationArray - | undefined = selectedPersonalizationsSignal.value, - ): ResolvedData { - return super.personalizeEntry(entry, selectedPersonalizations) - } - - /** - * Resolve a merge tag to a value based on the current (or provided) profile. - * - * @param embeddedEntryNodeTarget - The merge‑tag entry node to resolve. - * @param profile - Optional profile; defaults to the current signal value. - * @returns The resolved value (type depends on the tag). - * @remarks - * Merge tags are references to profile data that can be substituted into content. - * @example - * ```ts - * const name = personalization.getMergeTagValue(mergeTagNode) - * ``` - */ - getMergeTagValue( - embeddedEntryNodeTarget: MergeTagEntry, - profile: Profile | undefined = profileSignal.value, - ): string | undefined { - return super.getMergeTagValue(embeddedEntryNodeTarget, profile) - } - - /** - * Determine whether the named operation is permitted based on consent and - * allowed event type configuration. - * - * @param name - Method name; `trackView` is normalized to - * `'component'` for allow‑list checks. - * @returns `true` if the operation is permitted; otherwise `false`. - * @example - * ```ts - * if (personalization.hasConsent('track')) { ... } - * ``` - */ - hasConsent(name: string): boolean { - return ( - !!consentSignal.value || - (this.allowedEventTypes ?? []).includes( - name === 'trackView' || name === 'trackFlagView' ? 'component' : name, - ) - ) - } - - /** - * Hook invoked when an operation is blocked due to missing consent. - * - * @param name - The blocked operation name. - * @param payload - The original arguments supplied to the operation. - * @example - * ```ts - * personalization.onBlockedByConsent('track', [payload]) - * ``` - */ - onBlockedByConsent(name: string, payload: readonly unknown[]): void { - logger.warn( - `Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(payload)}`, - ) - this.reportBlockedEvent('consent', 'personalization', name, payload) - } - - /** - * Identify the current profile/visitor to associate traits with a profile - * and update optimization state. - * - * @param payload - Identify builder payload. - * @returns The resulting {@link OptimizationData} for the identified user. - * @example - * ```ts - * const data = await personalization.identify({ userId: 'user-123' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async identify(payload: IdentifyBuilderArgs): Promise { - logger.info('Sending "identify" event') - - const event = this.eventBuilder.buildIdentify(payload) - - return await this.sendOrEnqueueEvent(event) - } - - /** - * Record a page view and update optimization state. - * - * @param payload - Page view builder payload. - * @returns The evaluated {@link OptimizationData} for this page view. - * @example - * ```ts - * const data = await personalization.page({ properties: { title: 'Home' } }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async page(payload: PageViewBuilderArgs): Promise { - logger.info('Sending "page" event') - - const event = this.eventBuilder.buildPageView(payload) - - return await this.sendOrEnqueueEvent(event) - } - - /** - * Record a screen view and update optimization state. - * - * @param payload - Screen view builder payload. - * @returns The evaluated {@link OptimizationData} for this screen view. - * @example - * ```ts - * const data = await personalization.screen({ name: 'HomeScreen' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async screen(payload: ScreenViewBuilderArgs): Promise { - logger.info(`Sending "screen" event for "${payload.name}"`) - - const event = this.eventBuilder.buildScreenView(payload) - - return await this.sendOrEnqueueEvent(event) - } - - /** - * Record a custom track event and update optimization state. - * - * @param payload - Track builder payload. - * @returns The evaluated {@link OptimizationData} for this event. - * @example - * ```ts - * const data = await personalization.track({ event: 'button_click' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async track(payload: TrackBuilderArgs): Promise { - logger.info(`Sending "track" event "${payload.event}"`) - - const event = this.eventBuilder.buildTrack(payload) - - return await this.sendOrEnqueueEvent(event) - } - - /** - * Record a "sticky" component view and update optimization state. - * - * @param payload - Component view builder payload. - * @returns The evaluated {@link OptimizationData} for this component view. - * @example - * ```ts - * const data = await personalization.trackView({ componentId: 'hero-banner' }) - * ``` - */ - @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackView(payload: ViewBuilderArgs): Promise { - logger.info(`Sending "track personalization" event for ${payload.componentId}`) - - const event = this.eventBuilder.buildView(payload) - - return await this.sendOrEnqueueEvent(event) - } - - /** - * Intercept, validate, and place an event into the offline event queue; then - * trigger a size‑based flush if necessary. - * - * @param event - The event to enqueue. - */ - private async sendOrEnqueueEvent( - event: PersonalizationEvent, - ): Promise { - const intercepted = await this.interceptors.event.run(event) - - const validEvent = parseWithFriendlyError(PersonalizationEvent, intercepted) - - eventSignal.value = validEvent - - if (onlineSignal.value) return await this.upsertProfile([validEvent]) - - logger.debug(`Queueing ${validEvent.type} event`, validEvent) - - this.enqueueOfflineEvent(validEvent) - - return undefined - } - - /** - * Enqueue an offline event, dropping oldest events first when queue bounds are reached. - * - * @param event - Event to enqueue. - */ - private enqueueOfflineEvent(event: PersonalizationEvent): void { - let droppedEvents: PersonalizationEventArray = [] - - if (this.offlineQueue.size >= this.queuePolicy.maxEvents) { - const dropCount = this.offlineQueue.size - this.queuePolicy.maxEvents + 1 - droppedEvents = this.dropOldestOfflineEvents(dropCount) - - if (droppedEvents.length > 0) { - logger.warn( - `Dropped ${droppedEvents.length} oldest personalization offline event(s) due to queue limit (${this.queuePolicy.maxEvents})`, - ) - } - } - - this.offlineQueue.add(event) - - if (droppedEvents.length > 0) { - this.invokeQueueDropCallback({ - droppedCount: droppedEvents.length, - droppedEvents, - maxEvents: this.queuePolicy.maxEvents, - queuedEvents: this.offlineQueue.size, - }) - } - } - - /** - * Drop oldest offline events from the queue. - * - * @param count - Number of oldest events to drop. - * @returns Dropped events in oldest-first order. - */ - private dropOldestOfflineEvents(count: number): PersonalizationEventArray { - const droppedEvents: PersonalizationEventArray = [] - - for (let index = 0; index < count; index += 1) { - const oldestEvent = this.offlineQueue.values().next() - if (oldestEvent.done) break - - this.offlineQueue.delete(oldestEvent.value) - droppedEvents.push(oldestEvent.value) - } - - return droppedEvents - } - - /** - * Invoke offline queue drop callback in a fault-tolerant manner. - * - * @param context - Drop callback payload. - */ - private invokeQueueDropCallback(context: PersonalizationOfflineQueueDropContext): void { - try { - this.queuePolicy.onDrop?.(context) - } catch (error) { - logger.warn('Personalization offline queue drop callback failed', error) - } - } - - /** - * Flush the offline queue - * - * @param options - Optional flush controls. - * @param options.force - When `true`, bypass offline/backoff/circuit gates and attempt immediately. - * - * @example - * ```ts - * await personalization.flush() - */ - async flush(options: { force?: boolean } = {}): Promise { - await this.flushOfflineQueue(options) - } - - /** - * Flush queued offline events using retry/circuit guards. - * - * @param options - Flush controls. - * @param options.force - When true, bypass online/backoff/circuit gates. - */ - private async flushOfflineQueue(options: { force?: boolean } = {}): Promise { - const { force = false } = options - - if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return - - if (this.offlineQueue.size === 0) { - this.flushRuntime.clearScheduledRetry() - return - } - - logger.debug('Flushing offline event queue') - - const queuedEvents = Array.from(this.offlineQueue) - this.flushRuntime.markFlushStarted() - - try { - const sendSuccess = await this.tryUpsertQueuedEvents(queuedEvents) - - if (sendSuccess) { - queuedEvents.forEach((event) => { - this.offlineQueue.delete(event) - }) - this.flushRuntime.handleFlushSuccess() - } else { - this.flushRuntime.handleFlushFailure({ - queuedBatches: this.offlineQueue.size > 0 ? 1 : 0, - queuedEvents: this.offlineQueue.size, - }) - } - } finally { - this.flushRuntime.markFlushFinished() - } - } - - /** - * Attempt to send queued events to the Experience API. - * - * @param events - Snapshot of queued events to send. - * @returns `true` when send succeeds; otherwise `false`. - */ - private async tryUpsertQueuedEvents(events: PersonalizationEventArray): Promise { - try { - await this.upsertProfile(events) - return true - } catch (error) { - logger.warn('Personalization offline queue flush request threw an error', error) - return false - } - } - - /** - * Submit events to the Experience API, updating output signals with the - * returned state. - * - * @param events - The events to submit. - * @returns The {@link OptimizationData} returned by the service. - * @internal - * @privateRemarks - * If a `getAnonymousId` function has been provided, the returned value will - * take precedence over the `id` property of the current {@link Profile} - * signal value - */ - private async upsertProfile(events: PersonalizationEventArray): Promise { - const anonymousId = this.getAnonymousId() - if (anonymousId) logger.debug(`Anonymous ID found: ${anonymousId}`) - - const data = await this.api.experience.upsertProfile({ - profileId: anonymousId ?? profileSignal.value?.id, - events, - }) - - await this.updateOutputSignals(data) - - return data - } - - /** - * Apply returned optimization state to local signals after interceptor processing. - * - * @param data - Optimization state returned by the service. - * @internal - */ - private async updateOutputSignals(data: OptimizationData): Promise { - const intercepted = await this.interceptors.state.run(data) - - const { changes, selectedPersonalizations, profile } = intercepted - - batch(() => { - if (!isEqual(changesSignal.value, changes)) changesSignal.value = changes - if (!isEqual(profileSignal.value, profile)) profileSignal.value = profile - if (!isEqual(selectedPersonalizationsSignal.value, selectedPersonalizations)) - selectedPersonalizationsSignal.value = selectedPersonalizations - }) - } -} - -export default PersonalizationStateful diff --git a/packages/universal/core-sdk/src/personalization/PersonalizationStateless.ts b/packages/universal/core-sdk/src/personalization/PersonalizationStateless.ts deleted file mode 100644 index f797f630..00000000 --- a/packages/universal/core-sdk/src/personalization/PersonalizationStateless.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { - IdentifyEvent, - type OptimizationData, - PageViewEvent, - parseWithFriendlyError, - type PartialProfile, - ExperienceEvent as PersonalizationEvent, - ScreenViewEvent, - TrackEvent, - ViewEvent, -} from '@contentful/optimization-api-client/api-schemas' -import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { - IdentifyBuilderArgs, - PageViewBuilderArgs, - ScreenViewBuilderArgs, - TrackBuilderArgs, - ViewBuilderArgs, -} from '../events' -import PersonalizationBase from './PersonalizationBase' - -const logger = createScopedLogger('Personalization') - -/** - * Stateless personalization implementation that immediately validates and sends - * a single event to the Experience API, upserting the profile as needed. - * - * @public - * @remarks - * Each public method constructs a strongly-typed event via the shared builder, - * runs it through event interceptors, and performs a profile upsert using the - * Experience API. If an anonymous ID is available from the builder, it will be - * preferred as the `profileId` unless an explicit profile is provided. - */ -class PersonalizationStateless extends PersonalizationBase { - /** - * Identify the current profile/visitor to associate traits with a profile. - * - * @param payload - Identify builder arguments with an optional partial - * profile to attach to the upsert request. - * @returns The resulting {@link OptimizationData} for the identified user. - * @example - * ```ts - * const data = await personalization.identify({ userId: 'user-123', profile: { id: 'anon-1' } }) - * ``` - */ - async identify( - payload: IdentifyBuilderArgs & { profile?: PartialProfile }, - ): Promise { - logger.info('Sending "identify" event') - - const { profile, ...builderArgs } = payload - - const event = parseWithFriendlyError( - IdentifyEvent, - this.eventBuilder.buildIdentify(builderArgs), - ) - - return await this.upsertProfile(event, profile) - } - - /** - * Record a page view. - * - * @param payload - Page view builder arguments with an optional partial profile. - * @returns The evaluated {@link OptimizationData} for this page view. - * @example - * ```ts - * const data = await personalization.page({ properties: { title: 'Home' }, profile: { id: 'anon-1' } }) - * ``` - */ - async page( - payload: PageViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { - logger.info('Sending "page" event') - - const { profile, ...builderArgs } = payload - - const event = parseWithFriendlyError( - PageViewEvent, - this.eventBuilder.buildPageView(builderArgs), - ) - - return await this.upsertProfile(event, profile) - } - - /** - * Record a screen view. - * - * @param payload - Screen view builder arguments with an optional partial profile. - * @returns The evaluated {@link OptimizationData} for this screen view. - * @example - * ```ts - * const data = await personalization.screen({ name: 'HomeScreen', profile: { id: 'anon-1' } }) - * ``` - */ - async screen( - payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { - logger.info(`Sending "screen" event for "${payload.name}"`) - - const { profile, ...builderArgs } = payload - - const event = parseWithFriendlyError( - ScreenViewEvent, - this.eventBuilder.buildScreenView(builderArgs), - ) - - return await this.upsertProfile(event, profile) - } - - /** - * Record a custom track event. - * - * @param payload - Track builder arguments with an optional partial profile. - * @returns The evaluated {@link OptimizationData} for this event. - * @example - * ```ts - * const data = await personalization.track({ event: 'purchase', profile: { id: 'anon-1' } }) - * ``` - */ - async track(payload: TrackBuilderArgs & { profile?: PartialProfile }): Promise { - logger.info(`Sending "track" event "${payload.event}"`) - - const { profile, ...builderArgs } = payload - - const event = parseWithFriendlyError(TrackEvent, this.eventBuilder.buildTrack(builderArgs)) - - return await this.upsertProfile(event, profile) - } - - /** - * Record a "sticky" component view. - * - * @param payload - Component view builder arguments with an optional partial profile. - * @returns The evaluated {@link OptimizationData} for this component view. - * @example - * ```ts - * const data = await personalization.trackView({ componentId: 'hero', profile: { id: 'anon-1' } }) - * ``` - */ - async trackView( - payload: ViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { - logger.info('Sending "track personalization" event') - - const { profile, ...builderArgs } = payload - - const event = parseWithFriendlyError(ViewEvent, this.eventBuilder.buildView(builderArgs)) - - return await this.upsertProfile(event, profile) - } - - /** - * Intercept, validate, and upsert the profile with a single personalization - * event. - * - * @param event - The {@link PersonalizationEvent} to submit. - * @param profile - Optional partial profile. If omitted, the anonymous ID from - * the builder (when present) is used as the `profileId`. - * @returns The {@link OptimizationData} returned by the Experience API. - * @internal - */ - private async upsertProfile( - event: PersonalizationEvent, - profile?: PartialProfile, - ): Promise { - const intercepted = await this.interceptors.event.run(event) - const validEvent = parseWithFriendlyError(PersonalizationEvent, intercepted) - - const data = await this.api.experience.upsertProfile({ - profileId: profile?.id, - events: [validEvent], - }) - - return data - } -} - -export default PersonalizationStateless diff --git a/packages/universal/core-sdk/src/personalization/index.ts b/packages/universal/core-sdk/src/personalization/index.ts deleted file mode 100644 index cba974da..00000000 --- a/packages/universal/core-sdk/src/personalization/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { - FlagsResolver, - MergeTagValueResolver, - PersonalizedEntryResolver, - type ResolvedData, -} from './resolvers' - -export * from './PersonalizationBase' -export { default as PersonalizationBase } from './PersonalizationBase' - -export * from './PersonalizationStateful' -export { default as PersonalizationStateful } from './PersonalizationStateful' - -export * from './PersonalizationStateless' -export { default as PersonalizationStateless } from './PersonalizationStateless' diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts new file mode 100644 index 00000000..dead1348 --- /dev/null +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -0,0 +1,233 @@ +import { + ExperienceEvent as ExperienceEventSchema, + parseWithFriendlyError, + type ExperienceEventArray, + type ExperienceEvent as ExperienceEventPayload, + type OptimizationData, +} from '@contentful/optimization-api-client/api-schemas' +import { createScopedLogger } from '@contentful/optimization-api-client/logger' +import { isEqual } from 'es-toolkit/predicate' +import type { LifecycleInterceptors } from '../CoreBase' +import { QueueFlushRuntime, type ResolvedQueueFlushPolicy } from '../lib/queue' +import { + batch, + changes as changesSignal, + event as eventSignal, + online as onlineSignal, + profile as profileSignal, + selectedPersonalizations as selectedPersonalizationsSignal, +} from '../signals' + +const coreLogger = createScopedLogger('CoreStateful') + +/** + * Context payload emitted when offline Experience events are dropped. + * + * @public + */ +export interface ExperienceQueueDropContext { + /** Number of dropped events. */ + droppedCount: number + /** Dropped events in oldest-first order. */ + droppedEvents: ExperienceEventArray + /** Configured queue max size. */ + maxEvents: number + /** Queue size after enqueueing the current event. */ + queuedEvents: number +} + +interface ExperienceQueueOptions { + experienceApi: { + upsertProfile: (payload: { + profileId?: string + events: ExperienceEventArray + }) => Promise + } + eventInterceptors: LifecycleInterceptors['event'] + flushPolicy: ResolvedQueueFlushPolicy + getAnonymousId: () => string | undefined + offlineMaxEvents: number + onOfflineDrop?: (context: ExperienceQueueDropContext) => void + stateInterceptors: LifecycleInterceptors['state'] +} + +/** + * Internal Experience send/offline runtime used by {@link CoreStateful}. + * + * @internal + */ +export class ExperienceQueue { + private readonly experienceApi: ExperienceQueueOptions['experienceApi'] + private readonly eventInterceptors: ExperienceQueueOptions['eventInterceptors'] + private readonly flushRuntime: QueueFlushRuntime + private readonly getAnonymousId: ExperienceQueueOptions['getAnonymousId'] + private readonly offlineMaxEvents: number + private readonly onOfflineDrop?: ExperienceQueueOptions['onOfflineDrop'] + private readonly queuedExperienceEvents = new Set() + private readonly stateInterceptors: ExperienceQueueOptions['stateInterceptors'] + + constructor(options: ExperienceQueueOptions) { + const { + experienceApi, + eventInterceptors, + flushPolicy, + getAnonymousId, + offlineMaxEvents, + onOfflineDrop, + stateInterceptors, + } = options + + this.experienceApi = experienceApi + this.eventInterceptors = eventInterceptors + this.getAnonymousId = getAnonymousId + this.offlineMaxEvents = offlineMaxEvents + this.onOfflineDrop = onOfflineDrop + this.stateInterceptors = stateInterceptors + this.flushRuntime = new QueueFlushRuntime({ + policy: flushPolicy, + onRetry: () => { + void this.flush() + }, + onCallbackError: (callbackName, error) => { + coreLogger.warn(`Experience flush policy callback "${callbackName}" failed`, error) + }, + }) + } + + clearScheduledRetry(): void { + this.flushRuntime.clearScheduledRetry() + } + + async send(event: ExperienceEventPayload): Promise { + const intercepted = await this.eventInterceptors.run(event) + const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) + + eventSignal.value = validEvent + + if (onlineSignal.value) return await this.upsertProfile([validEvent]) + + coreLogger.debug(`Queueing ${validEvent.type} event`, validEvent) + this.enqueueEvent(validEvent) + + return undefined + } + + async flush(options: { force?: boolean } = {}): Promise { + const { force = false } = options + + if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return + + if (this.queuedExperienceEvents.size === 0) { + this.flushRuntime.clearScheduledRetry() + return + } + + coreLogger.debug('Flushing offline Experience event queue') + + const queuedEvents = Array.from(this.queuedExperienceEvents) + this.flushRuntime.markFlushStarted() + + try { + const sendSuccess = await this.tryUpsertQueuedEvents(queuedEvents) + + if (sendSuccess) { + queuedEvents.forEach((queuedEvent) => { + this.queuedExperienceEvents.delete(queuedEvent) + }) + this.flushRuntime.handleFlushSuccess() + } else { + this.flushRuntime.handleFlushFailure({ + queuedBatches: this.queuedExperienceEvents.size > 0 ? 1 : 0, + queuedEvents: this.queuedExperienceEvents.size, + }) + } + } finally { + this.flushRuntime.markFlushFinished() + } + } + + private enqueueEvent(event: ExperienceEventPayload): void { + let droppedEvents: ExperienceEventArray = [] + + if (this.queuedExperienceEvents.size >= this.offlineMaxEvents) { + const dropCount = this.queuedExperienceEvents.size - this.offlineMaxEvents + 1 + droppedEvents = this.dropOldestEvents(dropCount) + + if (droppedEvents.length > 0) { + coreLogger.warn( + `Dropped ${droppedEvents.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`, + ) + } + } + + this.queuedExperienceEvents.add(event) + + if (droppedEvents.length > 0) { + this.invokeOfflineDropCallback({ + droppedCount: droppedEvents.length, + droppedEvents, + maxEvents: this.offlineMaxEvents, + queuedEvents: this.queuedExperienceEvents.size, + }) + } + } + + private dropOldestEvents(count: number): ExperienceEventArray { + const droppedEvents: ExperienceEventArray = [] + + for (let index = 0; index < count; index += 1) { + const oldestEvent = this.queuedExperienceEvents.values().next() + if (oldestEvent.done) break + + this.queuedExperienceEvents.delete(oldestEvent.value) + droppedEvents.push(oldestEvent.value) + } + + return droppedEvents + } + + private invokeOfflineDropCallback(context: ExperienceQueueDropContext): void { + try { + this.onOfflineDrop?.(context) + } catch (error) { + coreLogger.warn('Offline queue drop callback failed', error) + } + } + + private async tryUpsertQueuedEvents(events: ExperienceEventArray): Promise { + try { + await this.upsertProfile(events) + return true + } catch (error) { + coreLogger.warn('Experience queue flush request threw an error', error) + return false + } + } + + private async upsertProfile(events: ExperienceEventArray): Promise { + const anonymousId = this.getAnonymousId() + if (anonymousId) coreLogger.debug(`Anonymous ID found: ${anonymousId}`) + + const data = await this.experienceApi.upsertProfile({ + profileId: anonymousId ?? profileSignal.value?.id, + events, + }) + + await this.updateOutputSignals(data) + + return data + } + + private async updateOutputSignals(data: OptimizationData): Promise { + const intercepted = await this.stateInterceptors.run(data) + const { changes, profile, selectedPersonalizations } = intercepted + + batch(() => { + if (!isEqual(changesSignal.value, changes)) changesSignal.value = changes + if (!isEqual(profileSignal.value, profile)) profileSignal.value = profile + if (!isEqual(selectedPersonalizationsSignal.value, selectedPersonalizations)) { + selectedPersonalizationsSignal.value = selectedPersonalizations + } + }) + } +} diff --git a/packages/universal/core-sdk/src/queues/InsightsQueue.ts b/packages/universal/core-sdk/src/queues/InsightsQueue.ts new file mode 100644 index 00000000..5f94d998 --- /dev/null +++ b/packages/universal/core-sdk/src/queues/InsightsQueue.ts @@ -0,0 +1,185 @@ +import { + InsightsEvent as InsightsEventSchema, + parseWithFriendlyError, + type BatchInsightsEventArray, + type InsightsEventArray, + type InsightsEvent as InsightsEventPayload, + type Profile, +} from '@contentful/optimization-api-client/api-schemas' +import { createScopedLogger } from '@contentful/optimization-api-client/logger' +import type { LifecycleInterceptors } from '../CoreBase' +import { QueueFlushRuntime, type ResolvedQueueFlushPolicy } from '../lib/queue' +import { event as eventSignal, online as onlineSignal, profile as profileSignal } from '../signals' + +const coreLogger = createScopedLogger('CoreStateful') + +const MAX_QUEUED_INSIGHTS_EVENTS = 25 + +interface QueuedProfileEvents { + profile: Profile + events: InsightsEventArray +} + +interface InsightsQueueOptions { + eventInterceptors: LifecycleInterceptors['event'] + flushPolicy: ResolvedQueueFlushPolicy + insightsApi: { + sendBatchEvents: (batches: BatchInsightsEventArray) => Promise + } +} + +/** + * Internal Insights queueing and flush runtime used by {@link CoreStateful}. + * + * @internal + */ +export class InsightsQueue { + private readonly eventInterceptors: InsightsQueueOptions['eventInterceptors'] + private readonly flushIntervalMs: number + private readonly flushRuntime: QueueFlushRuntime + private readonly insightsApi: InsightsQueueOptions['insightsApi'] + private readonly queuedInsightsByProfile = new Map() + private insightsPeriodicFlushTimer: ReturnType | undefined + + constructor(options: InsightsQueueOptions) { + const { eventInterceptors, flushPolicy, insightsApi } = options + const { flushIntervalMs } = flushPolicy + + this.eventInterceptors = eventInterceptors + this.flushIntervalMs = flushIntervalMs + this.insightsApi = insightsApi + this.flushRuntime = new QueueFlushRuntime({ + policy: flushPolicy, + onRetry: () => { + void this.flush() + }, + onCallbackError: (callbackName, error) => { + coreLogger.warn(`Insights flush policy callback "${callbackName}" failed`, error) + }, + }) + } + + clearScheduledRetry(): void { + this.flushRuntime.clearScheduledRetry() + } + + clearPeriodicFlushTimer(): void { + if (this.insightsPeriodicFlushTimer === undefined) return + + clearInterval(this.insightsPeriodicFlushTimer) + this.insightsPeriodicFlushTimer = undefined + } + + async send(event: InsightsEventPayload): Promise { + const { value: profile } = profileSignal + + if (!profile) { + coreLogger.warn('Attempting to emit an event without an Optimization profile') + return + } + + const intercepted = await this.eventInterceptors.run(event) + const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) + + coreLogger.debug(`Queueing ${validEvent.type} event for profile ${profile.id}`, validEvent) + + const queuedProfileEvents = this.queuedInsightsByProfile.get(profile.id) + + eventSignal.value = validEvent + + if (queuedProfileEvents) { + queuedProfileEvents.profile = profile + queuedProfileEvents.events.push(validEvent) + } else { + this.queuedInsightsByProfile.set(profile.id, { profile, events: [validEvent] }) + } + + this.ensurePeriodicFlushTimer() + if (this.getQueuedEventCount() >= MAX_QUEUED_INSIGHTS_EVENTS) { + await this.flush() + } + this.reconcilePeriodicFlushTimer() + } + + async flush(options: { force?: boolean } = {}): Promise { + const { force = false } = options + + if (this.flushRuntime.shouldSkip({ force, isOnline: !!onlineSignal.value })) return + + coreLogger.debug('Flushing insights event queue') + + const batches = this.createBatches() + + if (!batches.length) { + this.flushRuntime.clearScheduledRetry() + this.reconcilePeriodicFlushTimer() + return + } + + this.flushRuntime.markFlushStarted() + + try { + const sendSuccess = await this.trySendBatches(batches) + + if (sendSuccess) { + this.queuedInsightsByProfile.clear() + this.flushRuntime.handleFlushSuccess() + } else { + this.flushRuntime.handleFlushFailure({ + queuedBatches: batches.length, + queuedEvents: this.getQueuedEventCount(), + }) + } + } finally { + this.flushRuntime.markFlushFinished() + this.reconcilePeriodicFlushTimer() + } + } + + private createBatches(): BatchInsightsEventArray { + const batches: BatchInsightsEventArray = [] + + this.queuedInsightsByProfile.forEach(({ profile, events }) => { + batches.push({ profile, events }) + }) + + return batches + } + + private async trySendBatches(batches: BatchInsightsEventArray): Promise { + try { + return await this.insightsApi.sendBatchEvents(batches) + } catch (error) { + coreLogger.warn('Insights queue flush request threw an error', error) + return false + } + } + + private getQueuedEventCount(): number { + let queuedCount = 0 + + this.queuedInsightsByProfile.forEach(({ events }) => { + queuedCount += events.length + }) + + return queuedCount + } + + private ensurePeriodicFlushTimer(): void { + if (this.insightsPeriodicFlushTimer !== undefined) return + if (this.getQueuedEventCount() === 0) return + + this.insightsPeriodicFlushTimer = setInterval(() => { + void this.flush() + }, this.flushIntervalMs) + } + + private reconcilePeriodicFlushTimer(): void { + if (this.getQueuedEventCount() > 0) { + this.ensurePeriodicFlushTimer() + return + } + + this.clearPeriodicFlushTimer() + } +} diff --git a/packages/universal/core-sdk/src/personalization/resolvers/FlagsResolver.ts b/packages/universal/core-sdk/src/resolvers/FlagsResolver.ts similarity index 100% rename from packages/universal/core-sdk/src/personalization/resolvers/FlagsResolver.ts rename to packages/universal/core-sdk/src/resolvers/FlagsResolver.ts diff --git a/packages/universal/core-sdk/src/personalization/resolvers/MergeTagValueResolver.test.ts b/packages/universal/core-sdk/src/resolvers/MergeTagValueResolver.test.ts similarity index 96% rename from packages/universal/core-sdk/src/personalization/resolvers/MergeTagValueResolver.test.ts rename to packages/universal/core-sdk/src/resolvers/MergeTagValueResolver.test.ts index 2a636a55..f91087a5 100644 --- a/packages/universal/core-sdk/src/personalization/resolvers/MergeTagValueResolver.test.ts +++ b/packages/universal/core-sdk/src/resolvers/MergeTagValueResolver.test.ts @@ -1,7 +1,7 @@ import { isMergeTagEntry } from '@contentful/optimization-api-client/api-schemas' import { cloneDeep } from 'es-toolkit' -import { mergeTagEntry } from '../../test/fixtures/mergeTagEntry' -import { profile } from '../../test/fixtures/profile' +import { mergeTagEntry } from '../test/fixtures/mergeTagEntry' +import { profile } from '../test/fixtures/profile' import MergeTagValueResolver from './MergeTagValueResolver' describe('MergeTagValueResolver', () => { diff --git a/packages/universal/core-sdk/src/personalization/resolvers/MergeTagValueResolver.ts b/packages/universal/core-sdk/src/resolvers/MergeTagValueResolver.ts similarity index 100% rename from packages/universal/core-sdk/src/personalization/resolvers/MergeTagValueResolver.ts rename to packages/universal/core-sdk/src/resolvers/MergeTagValueResolver.ts diff --git a/packages/universal/core-sdk/src/personalization/resolvers/PersonalizedEntryResolver.test.ts b/packages/universal/core-sdk/src/resolvers/PersonalizedEntryResolver.test.ts similarity index 99% rename from packages/universal/core-sdk/src/personalization/resolvers/PersonalizedEntryResolver.test.ts rename to packages/universal/core-sdk/src/resolvers/PersonalizedEntryResolver.test.ts index 8ed85f01..084fa4bb 100644 --- a/packages/universal/core-sdk/src/personalization/resolvers/PersonalizedEntryResolver.test.ts +++ b/packages/universal/core-sdk/src/resolvers/PersonalizedEntryResolver.test.ts @@ -14,8 +14,8 @@ import { describe, expect, it, rs } from '@rstest/core' import type { Entry } from 'contentful' import { mockLogger } from 'mocks' -import { personalizedEntry as personalizedEntryFixture } from '../../test/fixtures/personalizedEntry' -import { selectedPersonalizations as selectedPersonalizationsFixture } from '../../test/fixtures/selectedPersonalizations' +import { personalizedEntry as personalizedEntryFixture } from '../test/fixtures/personalizedEntry' +import { selectedPersonalizations as selectedPersonalizationsFixture } from '../test/fixtures/selectedPersonalizations' import PersonalizedEntryResolver from './PersonalizedEntryResolver' const mockedLogger = rs.mocked(mockLogger) diff --git a/packages/universal/core-sdk/src/personalization/resolvers/PersonalizedEntryResolver.ts b/packages/universal/core-sdk/src/resolvers/PersonalizedEntryResolver.ts similarity index 100% rename from packages/universal/core-sdk/src/personalization/resolvers/PersonalizedEntryResolver.ts rename to packages/universal/core-sdk/src/resolvers/PersonalizedEntryResolver.ts diff --git a/packages/universal/core-sdk/src/personalization/resolvers/index.ts b/packages/universal/core-sdk/src/resolvers/index.ts similarity index 100% rename from packages/universal/core-sdk/src/personalization/resolvers/index.ts rename to packages/universal/core-sdk/src/resolvers/index.ts diff --git a/packages/universal/core-sdk/src/signals/signals.ts b/packages/universal/core-sdk/src/signals/signals.ts index 21470ad8..b0651602 100644 --- a/packages/universal/core-sdk/src/signals/signals.ts +++ b/packages/universal/core-sdk/src/signals/signals.ts @@ -30,7 +30,7 @@ export const blockedEvent: Signal = signal() /** - * Most recent emitted analytics or personalization event. + * Most recent emitted optimization event. * * @public */ diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index b81e4a40..e2d1d283 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -63,8 +63,10 @@ function App() { @@ -75,16 +77,15 @@ function App() { Available config props: -| Prop | Type | Required | Description | -| --------------------------- | ----------------------------------- | -------- | ------------------------------------------------ | -| `clientId` | `string` | Yes | Your Contentful Optimization client identifier | -| `environment` | `string` | No | Contentful environment (defaults to `'main'`) | -| `analytics` | `CoreStatefulAnalyticsConfig` | No | Analytics/Insights API configuration | -| `personalization` | `CoreStatefulPersonalizationConfig` | No | Personalization/Experience API configuration | -| `app` | `App` | No | Application metadata for events | -| `autoTrackEntryInteraction` | `AutoTrackEntryInteractionOptions` | No | Automatic entry interaction tracking options | -| `logLevel` | `LogLevels` | No | Minimum log level for console output | -| `liveUpdates` | `boolean` | No | Enable global live updates (defaults to `false`) | +| Prop | Type | Required | Description | +| --------------------------- | ---------------------------------- | -------- | ------------------------------------------------- | +| `clientId` | `string` | Yes | Your Contentful Optimization client identifier | +| `environment` | `string` | No | Contentful environment (defaults to `'main'`) | +| `api` | `CoreApiConfig` | No | Unified Insights and Experience API configuration | +| `app` | `App` | No | Application metadata for events | +| `autoTrackEntryInteraction` | `AutoTrackEntryInteractionOptions` | No | Automatic entry interaction tracking options | +| `logLevel` | `LogLevels` | No | Minimum log level for console output | +| `liveUpdates` | `boolean` | No | Enable global live updates (defaults to `false`) | ### Provider Composition @@ -121,8 +122,10 @@ export default function App({ Component, pageProps }: AppProps) { @@ -182,8 +185,10 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} @@ -229,8 +234,10 @@ export function AppLayout() { @@ -277,8 +284,10 @@ export function RootLayout() { @@ -449,8 +458,10 @@ render( , diff --git a/packages/web/frameworks/react-web-sdk/dev/main.tsx b/packages/web/frameworks/react-web-sdk/dev/main.tsx index eee8fbab..7832a362 100644 --- a/packages/web/frameworks/react-web-sdk/dev/main.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/main.tsx @@ -41,8 +41,10 @@ createRoot(rootElement).render( { @@ -59,8 +61,7 @@ describe('@contentful/optimization-react-web core providers', () => { , @@ -88,8 +89,7 @@ describe('@contentful/optimization-react-web core providers', () => { @@ -279,8 +279,7 @@ describe('@contentful/optimization-react-web core providers', () => { , @@ -322,8 +321,7 @@ describe('@contentful/optimization-react-web core providers', () => { @@ -337,8 +335,7 @@ describe('@contentful/optimization-react-web core providers', () => { @@ -362,8 +359,7 @@ describe('@contentful/optimization-react-web core providers', () => {
, @@ -385,8 +381,7 @@ describe('@contentful/optimization-react-web core providers', () => {
, diff --git a/packages/web/preview-panel/index.html b/packages/web/preview-panel/index.html index 7edeecbb..cc70ba4a 100644 --- a/packages/web/preview-panel/index.html +++ b/packages/web/preview-panel/index.html @@ -256,8 +256,10 @@

Entries

name: document.title, version: '0.0.0', }, - analytics: { baseUrl: '<%= PUBLIC_INSIGHTS_API_BASE_URL %>' }, - personalization: { baseUrl: '<%= PUBLIC_EXPERIENCE_API_BASE_URL %>' }, + api: { + insightsBaseUrl: '<%= PUBLIC_INSIGHTS_API_BASE_URL %>', + experienceBaseUrl: '<%= PUBLIC_EXPERIENCE_API_BASE_URL %>', + }, }) await attachOptimizationPreviewPanel({ diff --git a/packages/web/web-sdk/index.html b/packages/web/web-sdk/index.html index e4fdfba1..515349ac 100644 --- a/packages/web/web-sdk/index.html +++ b/packages/web/web-sdk/index.html @@ -256,8 +256,10 @@

Entries

name: document.title, version: '0.0.0', }, - analytics: { baseUrl: '<%= PUBLIC_INSIGHTS_API_BASE_URL %>' }, - personalization: { baseUrl: '<%= PUBLIC_EXPERIENCE_API_BASE_URL %>' }, + api: { + insightsBaseUrl: '<%= PUBLIC_INSIGHTS_API_BASE_URL %>', + experienceBaseUrl: '<%= PUBLIC_EXPERIENCE_API_BASE_URL %>', + }, }) // Emit page event diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 8c2d910b..59f2b3f8 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -207,7 +207,6 @@ describe('ContentfulOptimization', () => { expect(onEventBlocked).toHaveBeenCalledWith( expect.objectContaining({ reason: 'consent', - product: 'personalization', method: 'track', }), ) @@ -230,6 +229,16 @@ describe('ContentfulOptimization', () => { expect(onEventBlocked).not.toHaveBeenCalled() }) + it('supports page() without an explicit payload', async () => { + const web = new ContentfulOptimization(config) + const upsertProfile = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + + await expect(web.page()).resolves.toEqual(EMPTY_OPTIMIZATION_DATA) + expect(upsertProfile).toHaveBeenCalledTimes(1) + }) + it('forwards onEventBlocked callback to core stateful guards', async () => { const onEventBlocked = rs.fn() const web = new ContentfulOptimization({ ...config, onEventBlocked }) @@ -241,7 +250,6 @@ describe('ContentfulOptimization', () => { expect(onEventBlocked).toHaveBeenCalledWith( expect.objectContaining({ reason: 'consent', - product: 'personalization', method: 'track', }), ) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index d8188e14..1b2ee607 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -136,7 +136,7 @@ function resolveDefaultState( * This helper wires together: * - consent/profile/personalizations from LocalStore, * - Web-specific eventBuilder functions (locale, page, user agent), - * - beacon-based analytics flushing, + * - beacon-based Insights delivery, * - and anonymous ID retrieval. * * @internal @@ -152,9 +152,9 @@ function mergeConfig({ const { eventBuilder: configuredEventBuilder } = config const mergedConfig: CoreStatefulConfig = { ...config, - analytics: { + api: { beaconHandler, - ...config.analytics, + ...config.api, }, defaults: { ...baseDefaults, diff --git a/packages/web/web-sdk/src/handlers/createOnlineChangeListener.ts b/packages/web/web-sdk/src/handlers/createOnlineChangeListener.ts index a7d5e19d..e08f2dbc 100644 --- a/packages/web/web-sdk/src/handlers/createOnlineChangeListener.ts +++ b/packages/web/web-sdk/src/handlers/createOnlineChangeListener.ts @@ -28,7 +28,7 @@ type Callback = (isOnline: boolean) => Promise | void * @example * ```ts * const cleanup = createOnlineChangeListener(async (isOnline) => { - * if (isOnline) await sdk.analytics.flush() + * if (isOnline) await sdk.flush() * }) * * // Later: diff --git a/specs/009-core-foundational-and-shared/spec.md b/specs/009-core-foundational-and-shared/spec.md index b944daa3..7fbd9a1e 100644 --- a/specs/009-core-foundational-and-shared/spec.md +++ b/specs/009-core-foundational-and-shared/spec.md @@ -4,15 +4,15 @@ **Created**: 2026-02-26 **Status**: Current (Pre-release) **Input**: Repository behavior review for the current pre-release implementation (validated -2026-03-12). +2026-03-24). ## User Scenarios & Testing _(mandatory)_ ### User Story 1 - Bootstrap Shared Core Infrastructure (Priority: P1) As an SDK integrator, I need one shared core wiring layer that composes API access, event -construction, lifecycle interceptors, and logger sink setup so both stateless and stateful products -start from the same runtime contract. +construction, lifecycle interceptors, and logger sink setup so both stateless and stateful Core +implementations start from the same runtime contract. **Why this priority**: This composition boundary underpins all higher-level SDK behavior. @@ -21,10 +21,10 @@ EventBuilder defaults, and interceptor availability. **Acceptance Scenarios**: -1. **Given** `CoreConfig` with `clientId` and optional global/scoped API options, **When** a core +1. **Given** `CoreConfig` with `clientId` and optional global/unified API options, **When** a core runtime is created, **Then** one `ApiClient` is constructed with forwarded global - (`clientId`/`environment`/`fetchOptions`) and isolated scoped (`analytics`/`personalization`) - config. + (`clientId`/`environment`/`fetchOptions`) config plus derived Insights and Experience endpoint + config from `api`. 2. **Given** omitted `eventBuilder` config, **When** core initializes, **Then** `EventBuilder` defaults to `channel: 'server'` and library metadata from `OPTIMIZATION_CORE_SDK_NAME`/`OPTIMIZATION_CORE_SDK_VERSION`. @@ -38,26 +38,27 @@ EventBuilder defaults, and interceptor availability. ### User Story 2 - Use the Unified Core Facade (Priority: P1) As a product SDK developer, I need one facade with resolver helpers and event methods so callers can -use personalization and analytics behavior without reaching into product internals. +use optimization behavior without reaching into internal delivery paths. **Why this priority**: The core facade is the main integration surface used by downstream SDKs. -**Independent Test**: Invoke resolver and event methods on a core instance and validate delegation -and return behavior. +**Independent Test**: Invoke resolver and event methods on a core instance and validate routing and +return behavior. **Acceptance Scenarios**: 1. **Given** resolver input data, **When** `getFlag`, `personalizeEntry`, and `getMergeTagValue` are - called, **Then** each method delegates to personalization resolvers without changing result + called, **Then** each method uses the shared Core resolver utilities without changing result shape. 2. **Given** identify/page/screen/track payloads, **When** `identify`, `page`, `screen`, and `track` - are called, **Then** each delegates to personalization and returns its async result. + are called, **Then** each routes through the Experience delivery path and returns its async + result. 3. **Given** `trackView` payload with `sticky: true`, **When** the method is called, **Then** it - delegates to personalization and analytics, and returns the personalization result. + routes through both Experience and Insights, and returns the Experience result. 4. **Given** `trackView` payload with `sticky` omitted or `false`, **When** the method is called, - **Then** it delegates to analytics and resolves with `undefined`. -5. **Given** analytics interaction payloads, **When** `trackClick`, `trackHover`, or `trackFlagView` - is called, **Then** each method delegates to analytics. + **Then** it routes through Insights only and resolves with `undefined`. +5. **Given** component/flag interaction payloads, **When** `trackClick`, `trackHover`, or + `trackFlagView` is called, **Then** each method routes through Insights. --- @@ -68,33 +69,29 @@ can extend behavior safely and expose the correct import surfaces. **Why this priority**: Extensibility and package-surface correctness drive downstream SDK stability. -**Independent Test**: Register interceptors and guard hooks, emit blocked events, and verify root -and subpath exports. +**Independent Test**: Register interceptors and guard hooks and verify root and subpath exports. **Acceptance Scenarios**: 1. **Given** registered interceptors, **When** `InterceptorManager.run` executes, **Then** it uses invocation-time interceptor snapshots, executes in insertion order, and deep-clones accumulator input before each interceptor call. -2. **Given** blocked event reporting with a throwing `onEventBlocked` callback, **When** - `reportBlockedEvent` runs, **Then** callback errors are swallowed and the shared `blockedEvent` - signal is still updated. -3. **Given** a method wrapped with `guardedBy`, **When** predicate blocks execution, **Then** +2. **Given** a method wrapped with `guardedBy`, **When** predicate blocks execution, **Then** optional `onBlocked` runs synchronously and blocked calls return `undefined` for sync methods or `Promise` for async methods. -4. **Given** package consumption, **When** importing from `@contentful/optimization-core`, **Then** - root exports include core runtime modules, analytics/personalization modules, decorator and - interceptor utilities, signals helpers, constants, and symbols; logger/API client/API schema - surfaces are consumed through dedicated subpaths. +3. **Given** package consumption, **When** importing from `@contentful/optimization-core`, **Then** + root exports include core runtime modules, resolver utilities, decorator and interceptor + utilities, signals helpers, constants, and symbols; logger/API client/API schema surfaces are + consumed through dedicated subpaths. --- ### Edge Cases -- Analytics and personalization API base URLs remain isolated when only one scoped override is set. +- Insights and Experience API base URLs remain isolated when only one unified `api` override is set. - Top-level `fetchOptions` are forwarded to shared API client config. -- `CoreBase.trackView` always sends analytics view events; when `sticky` is truthy, it also sends - personalization view events. +- `CoreBase.trackView` always sends Insights view events; when `sticky` is truthy, it also sends + Experience view events. - `InterceptorManager.run` returns the original input reference when no interceptors are registered. - `guardedBy` throws `TypeError` at call time when the configured predicate key is not callable. - Core root export surface has no package default export and does not expose logger/API client/API @@ -105,57 +102,52 @@ and subpath exports. ### Functional Requirements - **FR-001**: `CoreConfig` MUST support global API properties (`clientId`, `environment`, - `fetchOptions`), optional scoped `analytics` and `personalization` API config, optional - `eventBuilder`, and optional `logLevel`. -- **FR-002**: `CoreBase` MUST create one shared `ApiClient` with global API properties and scoped - analytics/personalization config objects. + `fetchOptions`), an optional unified `api` config object, optional `eventBuilder`, and optional + `logLevel`. +- **FR-002**: `CoreBase` MUST create one shared `ApiClient` with global API properties and derived + Insights/Experience config objects built from `api`. - **FR-003**: `CoreBase` MUST register a `ConsoleLogSink` using the configured `logLevel`. - **FR-004**: `CoreBase` MUST initialize `EventBuilder` from provided config or default to `channel: 'server'` and core package library metadata constants. - **FR-005**: `CoreBase` MUST expose lifecycle interceptors with separate managers for `event` and `state`. - **FR-006**: `CoreBase` MUST expose resolver accessors (`flagsResolver`, `mergeTagValueResolver`, - `personalizedEntryResolver`) delegated from personalization. + `personalizedEntryResolver`) from shared Core resolver utilities. - **FR-007**: `CoreBase` resolver helpers (`getFlag`, `personalizeEntry`, `getMergeTagValue`) MUST - delegate to personalization methods without reshaping outputs. -- **FR-008**: `CoreBase.identify`, `page`, `screen`, and `track` MUST delegate to personalization - and return delegated async results. -- **FR-009**: `CoreBase.trackView` MUST delegate to analytics for all payloads and MUST additionally - delegate to personalization when `payload.sticky` is truthy; it MUST return personalization data - for sticky payloads and `undefined` otherwise. -- **FR-010**: `CoreBase.trackClick`, `trackHover`, and `trackFlagView` MUST delegate to analytics. -- **FR-011**: `ProductBase` MUST default `allowedEventTypes` to `['identify', 'page', 'screen']` - when unspecified. -- **FR-012**: `ProductBase.reportBlockedEvent` MUST invoke optional `onEventBlocked`, swallow - callback failures, and publish the blocked payload to the shared blocked-event signal. -- **FR-013**: `InterceptorManager` MUST support add/remove/clear/count and sequential sync+async + use resolver utilities without reshaping outputs. +- **FR-008**: `CoreBase.identify`, `page`, `screen`, and `track` MUST route through the Experience + delivery path and return delegated async results. +- **FR-009**: `CoreBase.trackView` MUST route through Insights for all payloads and MUST + additionally route through Experience when `payload.sticky` is truthy; it MUST return + `OptimizationData` for sticky payloads and `undefined` otherwise. +- **FR-010**: `CoreBase.trackClick`, `trackHover`, and `trackFlagView` MUST route through Insights. +- **FR-011**: `BlockedEvent` MUST contain `reason`, `method`, and `args`. +- **FR-012**: `InterceptorManager` MUST support add/remove/clear/count and sequential sync+async execution through `run`. -- **FR-014**: `InterceptorManager.run` MUST use an invocation-time snapshot of registered +- **FR-013**: `InterceptorManager.run` MUST use an invocation-time snapshot of registered interceptors and deep-clone the current accumulator before each interceptor invocation. -- **FR-015**: `guardedBy` MUST use a synchronous predicate, support optional synchronous `onBlocked` +- **FR-014**: `guardedBy` MUST use a synchronous predicate, support optional synchronous `onBlocked` callback or instance-method key hooks, and preserve sync/async return shape when blocked. -- **FR-016**: Root exports from `@contentful/optimization-core` MUST include signals utilities, - analytics/personalization modules, decorator/interceptor utilities, constants, symbols, - `CoreStateful`, and `CoreStateless`. -- **FR-017**: Logger/API client/API schema contracts MUST be consumed via dedicated subpath +- **FR-015**: Root exports from `@contentful/optimization-core` MUST include signals utilities, + resolver utilities, decorator/interceptor utilities, constants, symbols, `CoreStateful`, and + `CoreStateless`. +- **FR-016**: Logger/API client/API schema contracts MUST be consumed via dedicated subpath entrypoints (`@contentful/optimization-core/logger`, `/api-client`, `/api-schemas`) rather than root exports. -- **FR-018**: `OPTIMIZATION_CORE_SDK_NAME` and `OPTIMIZATION_CORE_SDK_VERSION` MUST use build-time +- **FR-017**: `OPTIMIZATION_CORE_SDK_NAME` and `OPTIMIZATION_CORE_SDK_VERSION` MUST use build-time define values when they are strings, otherwise fallback to `'@contentful/optimization-core'` and `'0.0.0'` respectively. -- **FR-019**: Package build output MUST include ESM/CJS runtime artifacts plus dual declaration +- **FR-018**: Package build output MUST include ESM/CJS runtime artifacts plus dual declaration artifacts for configured entrypoints. ### Key Entities _(include if feature involves data)_ - **CoreBase**: Internal shared runtime composition layer for API, builder, logging, interceptors, - and facade delegation. -- **ProductBase**: Shared product primitive for allowed-event configuration and blocked-event - reporting. + and facade routing. - **Lifecycle Interceptors**: `event` and `state` interceptor managers used to transform outgoing events and optimization state. -- **BlockedEvent**: Diagnostics payload (`reason`, `product`, `method`, `args`) emitted when guarded - calls are blocked. +- **BlockedEvent**: Diagnostics payload (`reason`, `method`, `args`) emitted when guarded calls are + blocked. - **Core Package Surfaces**: Root module plus dedicated logger/API client/API schema subpath entrypoints. diff --git a/specs/010-core-stateless-environment-support/spec.md b/specs/010-core-stateless-environment-support/spec.md index 3e7d3b5e..5ddb5ebd 100644 --- a/specs/010-core-stateless-environment-support/spec.md +++ b/specs/010-core-stateless-environment-support/spec.md @@ -4,29 +4,29 @@ **Created**: 2026-02-26 **Status**: Current (Pre-release) **Input**: Repository behavior review for the current pre-release implementation (validated -2026-03-12). +2026-03-24). ## User Scenarios & Testing _(mandatory)_ ### User Story 1 - Initialize Stateless Core Runtime (Priority: P1) -As a server-side SDK author, I need a stateless core that composes stateless analytics and -personalization products with shared API/event-builder/interceptor dependencies so I can execute in -Node/SSR without stateful runtime ownership concerns. +As a server-side SDK author, I need a stateless core that owns direct Insights and Experience +delivery with shared API/event-builder/interceptor dependencies so I can execute in Node/SSR without +stateful runtime ownership concerns. **Why this priority**: Stateless composition is the baseline requirement for server and function environments. -**Independent Test**: Construct `CoreStateless` and verify product wiring, config type constraints, -and absence of stateful-only APIs. +**Independent Test**: Construct `CoreStateless` and verify direct runtime wiring, config type +constraints, and absence of stateful-only APIs. **Acceptance Scenarios**: 1. **Given** valid `CoreStatelessConfig`, **When** `CoreStateless` is created, **Then** it - constructs `AnalyticsStateless` and `PersonalizationStateless` using shared `api`, - `eventBuilder`, and `interceptors`. -2. **Given** stateless analytics config typing, **When** callers provide `analytics`, **Then** the - type surface excludes `beaconHandler`. + initializes direct stateless Insights and Experience delivery using shared `api`, `eventBuilder`, + and `interceptors`. +2. **Given** stateless API config typing, **When** callers provide `api`, **Then** the type surface + excludes `beaconHandler`. 3. **Given** stateless event builder typing, **When** callers provide `eventBuilder`, **Then** the type surface excludes `getLocale`, `getPageProperties`, and `getUserAgent`. 4. **Given** a `CoreStateless` instance, **When** stateless APIs are inspected, **Then** stateful @@ -37,11 +37,11 @@ and absence of stateful-only APIs. ### User Story 2 - Send Personalization Events Immediately (Priority: P1) -As an integrator in a stateless host, I need personalization methods to build, validate, intercept, +As an integrator in a stateless host, I need Experience-bound methods to build, validate, intercept, and send events immediately through Experience API upsert so no local queue/state coordination is required. -**Why this priority**: Stateless personalization depends on direct request-response delivery. +**Why this priority**: Stateless Experience delivery depends on direct request-response behavior. **Independent Test**: Call `identify/page/screen/track/trackView` with and without `profile.id` and verify one upsert per call with schema-validated event payload. @@ -49,21 +49,21 @@ verify one upsert per call with schema-validated event payload. **Acceptance Scenarios**: 1. **Given** valid identify/page/screen/track/sticky-view payloads, **When** corresponding - `PersonalizationStateless` methods run, **Then** each method builds the correct event type, - validates it, and performs one `upsertProfile` call. -2. **Given** personalization event interceptors, **When** stateless personalization sends events, - **Then** interceptor output is validated as `PersonalizationEvent` before submission. + `CoreStateless` methods run through the Experience path, **Then** each method builds the correct + event type, validates it, and performs one `upsertProfile` call. +2. **Given** event interceptors, **When** stateless Experience delivery sends events, **Then** + interceptor output is validated as `ExperienceEvent` before submission. 3. **Given** optional partial profile input, **When** upsert payload is built, **Then** `profileId` uses `profile?.id` when present and is omitted when absent. --- -### User Story 3 - Send Stateless Analytics as Single Batches (Priority: P2) +### User Story 3 - Send Stateless Insights as Single Batches (Priority: P2) -As an analytics integrator in stateless environments, I need component and flag interactions sent as +As an integrator in stateless environments, I need component and flag interactions sent as single-item Insights batches so delivery remains deterministic and easy to reason about. -**Why this priority**: Stateless analytics correctness relies on strict one-call mapping. +**Why this priority**: Stateless Insights correctness relies on strict one-call mapping. **Independent Test**: Call `trackView`, `trackClick`, `trackHover`, and `trackFlagView`; verify builder output, interceptor application, schema parsing, and one-batch send payload shape. @@ -75,19 +75,19 @@ builder output, interceptor application, schema parsing, and one-batch send payl `BatchInsightsEventArray` item. 2. **Given** flag-view payload, **When** `trackFlagView` runs, **Then** event type remains `'component'` and `componentType` is `'Variable'` before sending. -3. **Given** optional partial profile payload, **When** analytics batch payload is built, **Then** +3. **Given** optional partial profile payload, **When** an Insights batch payload is built, **Then** outgoing shape is `[{ profile?: PartialProfile, events: [event] }]`. 4. **Given** core facade usage, **When** `CoreBase.trackView` is called, **Then** sticky payloads - route to both personalization and analytics while non-sticky payloads route to analytics only. + route to both Experience and Insights while non-sticky payloads route to Insights only. --- ### Edge Cases -- Stateless products do not use consent guards, singleton locking, or queue retry/backoff runtime. -- Stateless personalization does not use anonymous-ID fallback; outgoing `profileId` is derived only - from `profile?.id`. -- `CoreBase.trackFlagView`, `trackClick`, and `trackHover` always dispatch through analytics. +- Stateless runtimes do not use consent guards, singleton locking, or queue retry/backoff runtime. +- Stateless Experience upserts do not use anonymous-ID fallback; outgoing `profileId` is derived + only from `profile?.id`. +- `CoreBase.trackFlagView`, `trackClick`, and `trackHover` always dispatch through Insights. - Stateless resolver helpers (`getFlag`, `personalizeEntry`, `getMergeTagValue`) require explicit input values; no stateful signal defaults are applied. - Stateless `getFlag` does not auto-emit `trackFlagView`; flag-view emission is explicit in @@ -97,38 +97,38 @@ builder output, interceptor application, schema parsing, and one-batch send payl ### Functional Requirements -- **FR-001**: `CoreStateless` MUST extend `CoreBase` and instantiate `AnalyticsStateless` and - `PersonalizationStateless` with shared `api`, `eventBuilder`, and `interceptors`. -- **FR-002**: `CoreStatelessConfig.analytics` MUST omit `beaconHandler` from its type surface. +- **FR-001**: `CoreStateless` MUST extend `CoreBase` and own direct stateless Insights and + Experience delivery with shared `api`, `eventBuilder`, and `interceptors`. +- **FR-002**: `CoreStatelessConfig.api` MUST omit `beaconHandler` from its type surface. - **FR-003**: `CoreStatelessConfig.eventBuilder` MUST omit `getLocale`, `getPageProperties`, and `getUserAgent` from its type surface. -- **FR-004**: `PersonalizationStateless.identify` MUST build identify events, validate with - `IdentifyEvent`, and send through one `upsertProfile` call. -- **FR-005**: `PersonalizationStateless.page` MUST build page events, validate with `PageViewEvent`, - and send through one `upsertProfile` call. -- **FR-006**: `PersonalizationStateless.screen` MUST build screen events, validate with - `ScreenViewEvent`, and send through one `upsertProfile` call. -- **FR-007**: `PersonalizationStateless.track` MUST build track events, validate with `TrackEvent`, - and send through one `upsertProfile` call. -- **FR-008**: `PersonalizationStateless.trackView` MUST build view events, validate with - `ViewEvent`, and send through one `upsertProfile` call. -- **FR-009**: `PersonalizationStateless` MUST run event interceptors before validating as - `PersonalizationEvent` and sending. -- **FR-010**: Stateless personalization upsert payload MUST use +- **FR-004**: `CoreStateless.identify` MUST build identify events, validate with `IdentifyEvent`, + and send through one Experience `upsertProfile` call. +- **FR-005**: `CoreStateless.page` MUST build page events, validate with `PageViewEvent`, and send + through one Experience `upsertProfile` call. +- **FR-006**: `CoreStateless.screen` MUST build screen events, validate with `ScreenViewEvent`, and + send through one Experience `upsertProfile` call. +- **FR-007**: `CoreStateless.track` MUST build track events, validate with `TrackEvent`, and send + through one Experience `upsertProfile` call. +- **FR-008**: `CoreStateless.trackView` MUST build sticky view events for the Experience path, + validate with `ViewEvent`, and send through one Experience `upsertProfile` call. +- **FR-009**: `CoreStateless` MUST run event interceptors before validating as `ExperienceEvent` and + sending on the Experience path. +- **FR-010**: Stateless Experience upsert payload MUST use `{ profileId: profile?.id, events: [validEvent] }`. -- **FR-011**: `AnalyticsStateless.trackView` MUST build view events, run event interceptors, +- **FR-011**: `CoreStateless.trackView` MUST build Insights view events, run event interceptors, validate as `InsightsEvent`, and send one-item batches. -- **FR-012**: `AnalyticsStateless.trackClick` MUST build click events, run event interceptors, - validate as `InsightsEvent`, and send one-item batches. -- **FR-013**: `AnalyticsStateless.trackHover` MUST build hover events, run event interceptors, - validate as `InsightsEvent`, and send one-item batches. -- **FR-014**: `AnalyticsStateless.trackFlagView` MUST build flag-view events via `buildFlagView`, - run event interceptors, validate as `InsightsEvent`, and send one-item batches. -- **FR-015**: `AnalyticsStateless` outgoing batch payload MUST validate against +- **FR-012**: `CoreStateless.trackClick` MUST build click events, run event interceptors, validate + as `InsightsEvent`, and send one-item batches. +- **FR-013**: `CoreStateless.trackHover` MUST build hover events, run event interceptors, validate + as `InsightsEvent`, and send one-item batches. +- **FR-014**: `CoreStateless.trackFlagView` MUST build flag-view events via `buildFlagView`, run + event interceptors, validate as `InsightsEvent`, and send one-item batches. +- **FR-015**: Stateless Insights outgoing batch payload MUST validate against `BatchInsightsEventArray` and use shape `[{ profile?: PartialProfile, events: [event] }]`. - **FR-016**: Core stateless facade methods MUST route as follows: `identify/page/screen/track` to - personalization; `trackView` to analytics for all payloads and additionally to personalization - when `sticky` is truthy; `trackClick/trackHover/trackFlagView` to analytics. + Experience; `trackView` to Insights for all payloads and additionally to Experience when `sticky` + is truthy; `trackClick/trackHover/trackFlagView` to Insights. - **FR-017**: `CoreStateless` MUST expose resolver helpers (`getFlag`, `personalizeEntry`, `getMergeTagValue`) without requiring mutable runtime state. - **FR-018**: `CoreStateless` MUST remain stateless-only and MUST NOT introduce stateful singleton, @@ -136,25 +136,24 @@ builder output, interceptor application, schema parsing, and one-batch send payl ### Key Entities _(include if feature involves data)_ -- **CoreStateless**: Stateless runtime composed from shared core infrastructure. -- **PersonalizationStateless**: Immediate Experience API upsert sender for identify/page/screen/ - track/sticky-view events. -- **AnalyticsStateless**: Immediate Insights batch sender for component view/click/hover/flag-view - events. -- **TrackView/TrackClick/TrackHover Args**: Analytics payload shapes with optional partial profile - data in stateless product APIs. -- **Stateless Upsert Payload**: `{ profileId?: string, events: ExperienceEventArray }` sent per - personalization call. +- **CoreStateless**: Stateless runtime composed from shared core infrastructure and direct delivery + helpers. +- **TrackView/TrackClick/TrackHover Args**: Component interaction payload shapes with optional + partial profile data in stateless APIs. +- **Stateless Experience Upsert Payload**: `{ profileId?: string, events: ExperienceEventArray }` + sent per Experience-bound call. +- **Stateless Insights Batch Payload**: `[{ profile?: PartialProfile, events: [InsightsEvent] }]` + sent per Insights-bound call. ## Success Criteria _(mandatory)_ ### Measurable Outcomes -- **SC-001**: Stateless core initialization yields both stateless products wired to shared API, - builder, and interceptor instances. -- **SC-002**: Each stateless personalization method produces exactly one Experience API upsert call +- **SC-001**: Stateless core initialization yields direct Insights and Experience delivery wired to + shared API, builder, and interceptor instances. +- **SC-002**: Each stateless Experience-bound method produces exactly one Experience API upsert call with one validated event. -- **SC-003**: Stateless analytics methods (`trackView`, `trackClick`, `trackHover`, `trackFlagView`) +- **SC-003**: Stateless Insights methods (`trackView`, `trackClick`, `trackHover`, `trackFlagView`) each produce one Insights send call with one validated batch item. - **SC-004**: Stateless usage does not require consent signals, queue policies, singleton lock management, or preview-panel bridge registration. diff --git a/specs/011-core-stateful-environment-support/spec.md b/specs/011-core-stateful-environment-support/spec.md index dc49bc6b..9d2e50a8 100644 --- a/specs/011-core-stateful-environment-support/spec.md +++ b/specs/011-core-stateful-environment-support/spec.md @@ -4,7 +4,7 @@ **Created**: 2026-02-26 **Status**: Current (Pre-release) **Input**: Repository behavior review for the current pre-release implementation (validated -2026-03-12). +2026-03-24). ## User Scenarios & Testing _(mandatory)_ @@ -41,8 +41,8 @@ default-value application, reset behavior, and observable contracts. ### User Story 2 - Enforce Consent Gating with Blocked Event Telemetry (Priority: P1) -As a privacy-focused integrator, I need stateful analytics and personalization methods to be guarded -by consent and to emit blocked diagnostics so blocked behavior is auditable. +As a privacy-focused integrator, I need stateful Core event methods to be guarded by consent and to +emit blocked diagnostics so blocked behavior is auditable. **Why this priority**: Consent gating is required for compliant event behavior. @@ -56,7 +56,7 @@ callback and blocked-event stream behavior. 2. **Given** default allowed event types (`identify`, `page`, `screen`), **When** consent is missing, **Then** those event types are still allowed. 3. **Given** consent mapping for component methods, **When** `trackView`/`trackFlagView` are gated, - **Then** allow-list checks use `'component'`; analytics also maps `trackClick` to + **Then** allow-list checks use `'component'`; the shared mapping also maps `trackClick` to `'component_click'` and `trackHover` to `'component_hover'`. 4. **Given** throwing `onEventBlocked` callbacks, **When** blocked-event reporting runs, **Then** callback failures are swallowed and blocked-event signal publication continues. @@ -65,7 +65,7 @@ callback and blocked-event stream behavior. ### User Story 3 - Queue and Flush Reliably with Retry/Backoff/Circuit Controls (Priority: P2) -As a runtime maintainer, I need stateful analytics and personalization queues with retry/backoff and +As a runtime maintainer, I need stateful Insights and Experience queues with retry/backoff and offline handling so temporary failures do not immediately lose events. **Why this priority**: Stateful reliability depends on bounded retry and deterministic queue @@ -76,19 +76,20 @@ retry scheduling, circuit windows, and recovery callbacks. **Acceptance Scenarios**: -1. **Given** analytics events and a current profile, **When** events are enqueued, **Then** queue +1. **Given** Insights events and a current profile, **When** events are enqueued, **Then** queue storage is grouped by `profile.id` and the latest profile snapshot is retained per key. -2. **Given** analytics queue growth to threshold, **When** queued event count reaches `25`, **Then** +2. **Given** Insights queue growth to threshold, **When** queued event count reaches `25`, **Then** flush is automatically triggered. -3. **Given** personalization events while offline, **When** queue exceeds `maxEvents` (default - `100`), **Then** oldest events are dropped first and optional `onDrop` receives drop context. +3. **Given** Experience events while offline, **When** queue exceeds `offlineMaxEvents` (default + `100`), **Then** oldest events are dropped first and optional `onOfflineDrop` receives drop + context. 4. **Given** flush failures (false response or thrown error), **When** retries run, **Then** backoff/circuit policy and callbacks (`onFlushFailure`, `onCircuitOpen`, `onFlushRecovered`) execute via `QueueFlushRuntime`. 5. **Given** online status changes to true, **When** reactive online effects run, **Then** pending - retries are cleared and force-flush is attempted at product level. -6. **Given** immediate online personalization sends fail, **When** the send throws, **Then** the - error propagates and the event is not backfilled into the offline queue. + retries are cleared and force-flush is attempted for both delivery paths. +6. **Given** immediate online Experience sends fail, **When** the send throws, **Then** the error + propagates and the event is not backfilled into the offline queue. --- @@ -99,14 +100,13 @@ retry scheduling, circuit windows, and recovery callbacks. toggle `previewPanelAttached`/`previewPanelOpen`. - `canPersonalize` is derived from `selectedPersonalizations !== undefined`; an empty array still yields `true`. -- `CoreStateful.reset()` clears selected signal values only; it does not directly clear analytics - queue maps or personalization offline queue sets. -- `CoreStateful.flush()` has no force option and always awaits analytics flush before - personalization flush. -- Product-level `flush({ force: true })` bypasses offline/backoff/circuit gates, but not an already - in-flight flush. +- `CoreStateful.reset()` clears selected signal values only; it does not directly clear internal + Insights queue maps or Experience offline queue sets. +- `CoreStateful.flush()` has no force option and always awaits Insights flush before Experience + flush. +- Internal force flushes bypass offline/backoff/circuit gates, but not an already in-flight flush. - Queue policy callbacks are best-effort; callback exceptions are swallowed and reported through - product logging/runtime callback handlers. + runtime callback handlers. ## Requirements _(mandatory)_ @@ -118,10 +118,10 @@ retry scheduling, circuit windows, and recovery callbacks. release the singleton lock before rethrowing. - **FR-003**: `CoreStateful.destroy()` MUST be idempotent and MUST release singleton ownership for its owner token. -- **FR-004**: `CoreStateful` MUST split scoped `queuePolicy` fields from analytics/personalization - API config before constructing `CoreBase`. -- **FR-005**: `CoreStateful` MUST construct `AnalyticsStateful` and `PersonalizationStateful` with - shared `api`, `eventBuilder`, `interceptors`, and forwarded stateful product config +- **FR-004**: `CoreStateful` MUST accept a shared `queuePolicy` separate from unified `api` + configuration and apply it to the appropriate internal delivery runtimes. +- **FR-005**: `CoreStateful` MUST initialize Core-owned stateful Insights and Experience delivery + using shared `api`, `eventBuilder`, `interceptors`, and forwarded stateful config (`allowedEventTypes`, `onEventBlocked`, defaults, queue policy, and optional `getAnonymousId`). - **FR-006**: `CoreStateful.states` MUST expose observables for `consent`, `blockedEventStream`, `eventStream`, `flags`, `canPersonalize`, `profile`, `selectedPersonalizations`, @@ -133,44 +133,43 @@ retry scheduling, circuit windows, and recovery callbacks. `selectedPersonalizations` signals. - **FR-010**: `CoreStateful.reset()` MUST NOT clear `consent`, `previewPanelAttached`, or `previewPanelOpen`. -- **FR-011**: `CoreStateful.flush()` MUST sequentially await analytics flush then personalization - flush. +- **FR-011**: `CoreStateful.flush()` MUST sequentially await Insights flush then Experience flush. - **FR-012**: `CoreStateful.registerPreviewPanel()` MUST set `PREVIEW_PANEL_SIGNALS_SYMBOL -> signals` and `PREVIEW_PANEL_SIGNAL_FNS_SYMBOL -> signalFns` on the provided object. - **FR-013**: `CoreStateful.registerPreviewPanel()` MUST NOT set or infer preview attached/open boolean signal state. -- **FR-014**: Stateful analytics and personalization methods MUST be guarded through `guardedBy` - using `hasConsent` and `onBlockedByConsent`. +- **FR-014**: Stateful Core event methods MUST be guarded through `guardedBy` using `hasConsent` and + `onBlockedByConsent`. - **FR-015**: Consent checks MUST allow events when consent is true or mapped event type is in `allowedEventTypes`; default allowed types MUST remain `['identify', 'page', 'screen']` when unspecified. -- **FR-016**: Analytics consent mapping MUST normalize `trackView` and `trackFlagView` to - `'component'`, `trackClick` to `'component_click'`, and `trackHover` to `'component_hover'`. -- **FR-017**: Personalization consent mapping MUST normalize `trackView` and `trackFlagView` to - `'component'`. -- **FR-018**: Analytics stateful queue MUST be keyed by `profile.id`, preserve latest profile +- **FR-016**: Consent mapping MUST normalize `trackView` and `trackFlagView` to `'component'`, + `trackClick` to `'component_click'`, and `trackHover` to `'component_hover'`. +- **FR-017**: Blocked event diagnostics MUST emit `reason`, `method`, and `args` and MUST NOT + include a product taxonomy field. +- **FR-018**: Insights stateful queue MUST be keyed by `profile.id`, preserve latest profile snapshot per key, and skip enqueue when no current profile is available. -- **FR-019**: Analytics stateful queue MUST auto-flush when total queued events reaches `25`. -- **FR-020**: Analytics flush MUST treat both `false` API responses and thrown send errors as flush +- **FR-019**: Insights stateful queue MUST auto-flush when total queued events reaches `25`. +- **FR-020**: Insights flush MUST treat both `false` API responses and thrown send errors as flush failures for retry runtime handling. -- **FR-021**: Personalization stateful offline queue MUST default to `maxEvents: 100`, drop oldest - events first when bounds are exceeded, and provide drop context to optional `onDrop`. -- **FR-022**: Personalization offline `onDrop` callback failures MUST be swallowed. -- **FR-023**: Personalization stateful online path MUST send events immediately and return +- **FR-021**: Shared `queuePolicy.flush` defaults MUST normalize to `baseBackoffMs=500`, + `maxBackoffMs=30000`, `jitterRatio=0.2`, `maxConsecutiveFailures=8`, and `circuitOpenMs=120000`. +- **FR-022**: Experience stateful offline queue MUST default to `offlineMaxEvents: 100`, drop oldest + events first when bounds are exceeded, and provide drop context to optional `onOfflineDrop`. +- **FR-023**: Experience offline `onOfflineDrop` callback failures MUST be swallowed. +- **FR-024**: Experience stateful online path MUST send events immediately and return `OptimizationData`; offline path MUST queue events and return `undefined`. -- **FR-024**: Personalization online send failures MUST propagate and MUST NOT automatically enqueue +- **FR-025**: Experience online send failures MUST propagate and MUST NOT automatically enqueue failed online events in the offline queue. -- **FR-025**: Personalization upsert profile resolution MUST prefer `getAnonymousId()` when it - returns a truthy value, otherwise fallback to `profile.id` from shared signal state. -- **FR-026**: Personalization state updates from Experience responses MUST run through - `interceptors.state` before updating signals. -- **FR-027**: Signal updates for `changes`, `profile`, and `selectedPersonalizations` MUST be +- **FR-026**: Experience upsert profile resolution MUST prefer `getAnonymousId()` when it returns a + truthy value, otherwise fallback to `profile.id` from shared signal state. +- **FR-027**: Experience state updates from responses MUST run through `interceptors.state` before + updating signals. +- **FR-028**: Signal updates for `changes`, `profile`, and `selectedPersonalizations` MUST be deep-equality aware and skip redundant assignments. -- **FR-028**: `QueueFlushRuntime.shouldSkip()` MUST always block in-flight flushes and, unless +- **FR-029**: `QueueFlushRuntime.shouldSkip()` MUST always block in-flight flushes and, unless forced, MUST gate flushes for offline state, active backoff windows, and open circuits. -- **FR-029**: Queue flush runtime defaults MUST normalize to `baseBackoffMs=500`, - `maxBackoffMs=30000`, `jitterRatio=0.2`, `maxConsecutiveFailures=8`, and `circuitOpenMs=120000`. - **FR-030**: Queue flush runtime MUST invoke failure/circuit/recovered callbacks with queue and retry context payloads, schedule retries, and fault-tolerantly report callback exceptions through `onCallbackError` when configured. @@ -179,12 +178,11 @@ retry scheduling, circuit windows, and recovery callbacks. ### Key Entities _(include if feature involves data)_ -- **CoreStateful**: Runtime singleton coordinating stateful analytics and personalization products. +- **CoreStateful**: Runtime singleton coordinating stateful Insights and Experience delivery. - **CoreStates**: Observable contract for consent, blocked/event streams, flags, profile, selected-personalization state, derived `canPersonalize`, and preview panel signals. -- **AnalyticsStateful Queue**: Profile-grouped in-memory queue with auto-flush and retry/circuit - controls. -- **Personalization Offline Queue**: Ordered set of Experience events retained while offline. +- **Insights Queue**: Profile-grouped in-memory queue with auto-flush and retry/circuit controls. +- **Experience Offline Queue**: Ordered set of Experience events retained while offline. - **QueueFlushRuntime**: Shared retry/backoff/circuit state machine used by stateful products. - **Preview Panel Bridge**: Symbol-keyed attachment contract for sharing `signals` and `signalFns` with preview tooling. @@ -197,9 +195,9 @@ retry scheduling, circuit windows, and recovery callbacks. `destroy()` is called. - **SC-002**: Consent-blocked calls are emitted through both `onEventBlocked` callback and `blockedEventStream` observable. -- **SC-003**: Analytics and personalization queues demonstrate configured retry/backoff/circuit - behavior and recover by clearing queued events after successful flush. -- **SC-004**: Offline personalization queue enforces max-size drop policy with accurate drop-context +- **SC-003**: Insights and Experience queues demonstrate configured retry/backoff/circuit behavior + and recover by clearing queued events after successful flush. +- **SC-004**: Offline Experience queue enforces max-size drop policy with accurate drop-context callback payloads. - **SC-005**: Core state observable tests confirm full state coverage, reset preservation semantics, and deep-cloned `current`/`subscribe`/`subscribeOnce` behavior.