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.