diff --git a/documentation/README.md b/documentation/README.md index 48f4945d..ff7fc4de 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -2,6 +2,7 @@ title: Guides children: - ./choosing-the-right-sdk.md + - ./integrating-the-node-sdk-in-a-node-app.md --- # Guides @@ -13,3 +14,6 @@ are intended to be used. - [Choosing the Right SDK](./choosing-the-right-sdk.md): pick the narrowest published package layer for a browser, React, Node, or React Native application +- [Integrating the Optimization Node SDK in a Node App](./integrating-the-node-sdk-in-a-node-app.md): + step-by-step server-side integration guidance using Express-style examples and the Node reference + implementations diff --git a/documentation/integrating-the-node-sdk-in-a-node-app.md b/documentation/integrating-the-node-sdk-in-a-node-app.md new file mode 100644 index 00000000..ddca43b7 --- /dev/null +++ b/documentation/integrating-the-node-sdk-in-a-node-app.md @@ -0,0 +1,535 @@ +# Integrating the Optimization Node SDK in a Node App + +Use this guide when you want to implement server-side personalization in a Node runtime such as +Express, a custom SSR server, or a server-side function. + +The examples below use Express, but the same flow applies to any Node request handler. + +## Scope and Capabilities + +The Node SDK is the server-side package for Node-based applications in the Optimization SDK Suite. +It lets consumers: + +- evaluate a request and receive profile data, selected optimizations, and Custom Flag changes +- stitch anonymous and known identities together when a user becomes known +- render optimized Contentful entries before HTML leaves the server +- render merge tags against the current profile data +- emit server-side optimization and analytics events +- share an anonymous profile identifier with the Web SDK when the app has both SSR and browser code + +The Node SDK is intentionally stateless. It does not manage consent, cookies, sessions, or +long-lived profile state for you. Your application decides how profile IDs are persisted and when +events should be sent. + +It also does not replace your Contentful delivery client. Your app still fetches entries from +Contentful. The Node SDK helps you choose the right variant for the current profile after that +content has been fetched. + +## The Integration Flow + +In practice, most Node integrations follow this high-level sequence: + +1. Create one SDK instance for the Node process. +2. Read the request-scoped inputs your app owns: consent state, existing `profile.id`, known user + identity, and page context. +3. Call the SDK to evaluate the request and, when appropriate, associate a known user with the + current profile. +4. Use the returned profile data, selected optimizations, and flag changes to render the response. +5. Persist the returned `profile.id` and emit follow-up events only when your consent policy allows + it. + +The two Node reference implementations in this repository show that pattern in working applications: + +- [Node SSR Only](../implementations/node-sdk/README.md) +- [Node SSR + Web SDK Vanilla](../implementations/node-sdk+web-sdk/README.md) + +## 1. Install And Initialize The SDK + +Install the package in your Node app: + +```sh +pnpm add @contentful/optimization-node +``` + +Create the SDK once and reuse it across requests: + +```ts +import ContentfulOptimization from '@contentful/optimization-node' + +function required(name: string): string { + const value = process.env[name] + + if (!value) { + throw new Error(`Missing environment variable: ${name}`) + } + + return value +} + +export const optimization = new ContentfulOptimization({ + clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'), + environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', + app: { + name: 'my-express-app', + version: '1.0.0', + }, + api: { + experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL, + insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL, + }, + logLevel: 'error', +}) +``` + +Treat that SDK as a module-level singleton for the current Node process. Do not create a new +`ContentfulOptimization` instance per incoming request. Create a fresh +`optimization.forRequest(...)` scope for each request instead. + +Notes: + +- The reference implementations in this repo use `PUBLIC_...` environment variable names because + they double as local dev harnesses. A consumer app can use any environment variable names that fit + its deployment setup. +- On modern Node runtimes, the built-in `fetch` implementation is usually enough. If your runtime + does not expose a standard Fetch API, provide `fetchOptions.fetchMethod`. + +## 2. Turn The Express Request Into SDK Event Context + +The SDK can accept request-scoped event context such as locale, user agent, and page information. +That context should be built fresh for every incoming request. + +The reference implementations do this by translating the Express request into +`UniversalEventBuilderArgs`: + +```ts +import type { Request } from 'express' +import type { + CoreStatelessRequestOptions, + UniversalEventBuilderArgs, +} from '@contentful/optimization-node/core-sdk' + +function toQueryValue(value: unknown): string | null { + if (value === undefined || value === null) return null + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(String).join(',') + + return JSON.stringify(value) +} + +function getRequestContext(req: Request): UniversalEventBuilderArgs { + const url = new URL(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`) + + const query = Object.keys(req.query).reduce>((acc, key) => { + const stringValue = toQueryValue(req.query[key]) + + if (stringValue !== null) { + acc[key] = stringValue + } + + return acc + }, {}) + + return { + locale: req.acceptsLanguages()[0] ?? 'en-US', + userAgent: req.get('user-agent') ?? 'node-server', + page: { + path: req.path, + query, + referrer: req.get('referer') ?? '', + search: url.search, + url: url.toString(), + }, + } +} + +function getExperienceRequestOptions(req: Request): CoreStatelessRequestOptions { + return { + locale: req.acceptsLanguages()[0] ?? 'en-US', + } +} +``` + +The exact page fields do not need to come from Express. The important part is that the app passes a +stable, request-specific description of the current page or route. + +`getRequestContext(req).locale` affects the event payload context. +`getExperienceRequestOptions(req).locale` affects the Experience API request itself. Those two +locale values are intentionally separate, even if your app derives them from the same request +header. + +## 3. Handle Consent In Your Application Layer + +The Node SDK does not expose a server-side `consent()` state the way stateful SDKs do. In a Node +app, consent belongs in your application layer. + +That usually means your app should: + +- store the user's consent decision in its own cookie, session, or user-preference store +- decide which high-level SDK methods are allowed before consent, after consent, and after consent + revocation + +One common conservative pattern is: + +- when consent is unknown or denied, do not persist `profile.id` and do not emit follow-up tracking + events +- in many applications, that also means rendering baseline content until consent exists +- when consent is granted, switch back to normal requests and persist the returned `profile.id` +- when consent is revoked, clear the stored anonymous ID and stop sending further optimization + traffic until consent is granted again + +```ts +import type { Request, Response } from 'express' +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' + +const OPTIMIZATION_CONSENT_COOKIE = 'ctfl-opt-consent' + +function hasOptimizationConsent(req: Request): boolean { + return req.cookies[OPTIMIZATION_CONSENT_COOKIE] === 'true' +} + +function clearOptimizationIdentity(res: Response): void { + res.clearCookie(ANONYMOUS_ID_COOKIE, { path: '/' }) +} +``` + +The exact consent policy belongs to the application, not the SDK. The important part is that the +server makes that decision before it persists identifiers or emits events on the user's behalf. + +## 4. Decide How You Will Persist The Profile ID + +Because the Node SDK is stateless, it will not remember a visitor between requests on its own. Your +app needs to persist the returned `profile.id` somewhere and pass it back into later SDK calls when +your consent policy allows it. + +There are two common approaches: + +- server-only app: keep the ID in a session or first-party cookie +- hybrid SSR + browser app: store the ID in the shared `ANONYMOUS_ID_COOKIE` so the Node SDK and Web + SDK can continue the same anonymous journey + +With Express and cookies, the shared-cookie approach looks like this: + +```ts +import cookieParser from 'cookie-parser' +import type { Request, Response } from 'express' +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' + +app.use(cookieParser()) + +function getProfileFromRequest(req: Request): { id: string } | undefined { + const id = req.cookies[ANONYMOUS_ID_COOKIE] + + return typeof id === 'string' && id.length > 0 ? { id } : undefined +} + +function persistProfile(res: Response, profileId?: string): void { + if (!profileId) return + + res.cookie(ANONYMOUS_ID_COOKIE, profileId, { + path: '/', + sameSite: 'lax', + }) +} +``` + +This is the same cookie name used in the hybrid reference implementation. + +If your app will also run `@contentful/optimization-web` in the browser, avoid marking that cookie +as `HttpOnly`, because browser-side code needs to read it. If your app is server-only, a session +store is equally valid. If consent is revoked, clear the same cookie or session value. + +## 5. Call `page()` And `identify()` At The Right Time + +The Node SDK returns optimization data from `page()`, `identify()`, `screen()`, `track()`, and +sticky `trackView()` calls. In a typical SSR route, `page()` is the most important entry point. + +This is a minimal Express shape: + +```ts +import type { Request } from 'express' +import type { OptimizationData } from '@contentful/optimization-node/api-schemas' + +function getAuthenticatedUserId(req: Request): string | undefined { + const userId = req.query.userId + + return typeof userId === 'string' && userId.length > 0 ? userId : undefined +} + +app.get('/', async (req, res) => { + const consented = hasOptimizationConsent(req) + if (!consented) { + return res.json({ + profile: undefined, + changes: undefined, + selectedOptimizations: undefined, + }) + } + + const requestContext = getRequestContext(req) + const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) + const existingProfile = getProfileFromRequest(req) + const pageResponse: OptimizationData | undefined = await requestOptimization.page({ + ...requestContext, + profile: existingProfile, + }) + + const userId = getAuthenticatedUserId(req) + + const identifyResponse = userId + ? await requestOptimization.identify({ + ...requestContext, + profile: pageResponse?.profile ?? existingProfile, + userId, + traits: { authenticated: true }, + }) + : undefined + + if (consented) { + persistProfile(res, identifyResponse?.profile?.id ?? pageResponse?.profile?.id) + } + + res.json({ + profile: identifyResponse?.profile ?? pageResponse?.profile, + changes: identifyResponse?.changes ?? pageResponse?.changes, + selectedOptimizations: + identifyResponse?.selectedOptimizations ?? pageResponse?.selectedOptimizations, + }) +}) +``` + +Replace `getAuthenticatedUserId()` with the lookup your app actually uses, such as a session, +cookie, or upstream auth middleware. + +This example shows the common "evaluate first, then identify when the user is known" flow. If your +policy allows a different pre-consent behavior, implement that policy in your application layer +before you call SDK methods. + +That route lets a consumer accomplish two things: + +- anonymous personalization: `page()` evaluates the current request for an anonymous or known + profile +- identity stitching: `identify()` links a known user ID to the current profile before or during the + same request + +The returned `OptimizationData` usually gives you the three values you care about most: + +- `profile`: the current profile, including the profile ID you should persist +- `changes`: Custom Flag inputs +- `selectedOptimizations`: the variant choices to use when resolving Contentful entries + +### Which Order Should `page()` And `identify()` Use? + +Both patterns appear in the reference implementations because they answer slightly different +questions: + +- call `identify()` and then `page()` when the current page view should be attributed to the known + user identity +- call `page()` and then `identify()` when the request arrived anonymous but the response should + still render with data returned from the identify step + +The important rule is simpler than the ordering nuance: always render from the most relevant +response object for the user state you want on that response. + +## 6. Resolve Contentful Entries With `selectedOptimizations` + +Once you have optimization data, fetch the baseline Contentful entry the same way you normally +would, then hand it to `resolveOptimizedEntry()`. + +In the example below, replace `ArticleSkeleton` with the generated Contentful skeleton type your app +already uses. + +```ts +import type { Entry } from 'contentful' +import * as contentful from 'contentful' + +const contentfulClient = contentful.createClient({ + accessToken: required('CONTENTFUL_DELIVERY_TOKEN'), + environment: required('CONTENTFUL_ENVIRONMENT'), + space: required('CONTENTFUL_SPACE_ID'), +}) + +type ArticleEntry = Entry + +async function getArticle(entryId: string): Promise { + return await contentfulClient.getEntry(entryId, { + include: 10, + }) +} + +app.get('/article/:entryId', async (req, res) => { + const consented = hasOptimizationConsent(req) + const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) + const pageResponse = consented + ? await requestOptimization.page({ + ...getRequestContext(req), + profile: getProfileFromRequest(req), + }) + : undefined + + const article = await getArticle(req.params.entryId) + + const { entry: optimizedArticle, selectedOptimization } = optimization.resolveOptimizedEntry( + article, + pageResponse?.selectedOptimizations, + ) + + if (consented) { + persistProfile(res, pageResponse?.profile?.id) + } + + res.render('article', { + article: optimizedArticle, + profile: pageResponse?.profile, + selectedOptimization, + }) +}) +``` + +This is the main server-side personalization loop: + +1. Ask Optimization for the current profile's selected variants. +2. Fetch the baseline Contentful entry. +3. Resolve the optimized entry variant before rendering. + +If your optimized entries contain linked entries or merge tags, fetch with an `include` depth that +matches your content model. The SSR reference implementation uses `include: 10` for that reason. + +## 7. Resolve Merge Tags And Custom Flags + +The Node SDK also exposes helpers for profile-aware merge tags and Custom Flags. + +### Merge Tags + +If a Rich Text field contains merge-tag entries, resolve them against the current profile while +rendering the field: + +```ts +import { documentToHtmlString } from '@contentful/rich-text-html-renderer' +import { INLINES } from '@contentful/rich-text-types' +import { isMergeTagEntry } from '@contentful/optimization-node/api-schemas' + +const html = documentToHtmlString(richTextField, { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: (node) => { + if (!isMergeTagEntry(node.data.target)) return '' + + return optimization.getMergeTagValue(node.data.target, pageResponse?.profile) ?? '' + }, + }, +}) +``` + +That is the pattern used in the SSR-only reference implementation. + +### Custom Flags + +Use `getFlag()` when the response includes Custom Flag changes: + +```ts +const showNewNavigation = optimization.getFlag('new-navigation', pageResponse?.changes) === true +``` + +In the Node SDK, `getFlag()` does not auto-track flag views. If a flag exposure should also be +captured as an Insights event, call `trackFlagView()` explicitly: + +```ts +if (pageResponse?.profile) { + await requestOptimization.trackFlagView({ + ...getRequestContext(req), + componentId: 'new-navigation', + profile: pageResponse.profile, + }) +} +``` + +## 8. Emit Follow-Up Server Events When They Matter + +The Node SDK can send more than page views. Common server-side cases are: + +- `track()`: a business event triggered by a server action +- `trackView()`: a rendered entry view when the server knows exactly which optimized entry was shown +- `screen()`: useful when a Node runtime fronts a non-web screen-based experience +- `trackClick()` and `trackHover()`: available, but usually better emitted from browser code once a + real interaction happens + +Gate these calls with the same consent policy your app applies to `page()` and `identify()`. + +In stateless Node usage, Insights-backed calls need a profile. `trackClick()`, `trackHover()`, +`trackFlagView()`, and non-sticky `trackView()` should use a persisted or freshly returned profile. +Sticky `trackView()` may omit `profile`, because it can reuse the paired Experience response +profile. + +Example custom event: + +```ts +const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) + +await requestOptimization.track({ + ...getRequestContext(req), + profile: pageResponse?.profile, + event: 'quote_requested', + properties: { + plan: 'enterprise', + source: 'pricing-page', + }, +}) +``` + +Example rendered-entry view event: + +```ts +import { randomUUID } from 'node:crypto' + +const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) +const viewPayload = { + ...getRequestContext(req), + componentId: optimizedArticle.sys.id, + experienceId: selectedOptimization?.experienceId, + variantIndex: selectedOptimization?.variantIndex, + viewDurationMs: 0, + viewId: randomUUID(), +} + +if (selectedOptimization?.sticky) { + await requestOptimization.trackView({ + ...viewPayload, + sticky: true, + }) +} else if (pageResponse?.profile) { + await requestOptimization.trackView({ + ...viewPayload, + profile: pageResponse.profile, + }) +} +``` + +## 9. Know When The Web SDK Should Join The Architecture + +Use the Node SDK by itself when the server is responsible for choosing the variant and rendering the +response. + +Add `@contentful/optimization-web` when the browser also needs to participate after hydration. That +is usually the right move when you need: + +- browser-managed consent state +- automatic entry view, click, or hover tracking in the DOM +- cookie-based profile continuity between SSR and client-side code +- follow-up personalization after the first server render + +The hybrid reference implementation shows exactly that setup: + +- [Server integration](../implementations/node-sdk+web-sdk/src/app.ts) +- [Browser integration](../implementations/node-sdk+web-sdk/src/index.ejs) + +## Reference Implementations To Compare Against + +Use these files when you want working repository examples instead of guide snippets: + +- [`implementations/node-sdk/src/app.ts`](../implementations/node-sdk/src/app.ts): server-only SSR + flow with `page()`, `identify()`, `resolveOptimizedEntry()`, and `getMergeTagValue()` +- [`implementations/node-sdk/src/index.ejs`](../implementations/node-sdk/src/index.ejs): rendered + output that consumes resolved entries +- [`implementations/node-sdk+web-sdk/src/app.ts`](../implementations/node-sdk+web-sdk/src/app.ts): + cookie sharing with `ANONYMOUS_ID_COOKIE` for Node and Web SDK continuity +- [`implementations/node-sdk+web-sdk/src/index.ejs`](../implementations/node-sdk+web-sdk/src/index.ejs): + browser-side follow-up tracking and entry resolution diff --git a/implementations/node-sdk+web-sdk/README.md b/implementations/node-sdk+web-sdk/README.md index 9baba79c..9193d418 100644 --- a/implementations/node-sdk+web-sdk/README.md +++ b/implementations/node-sdk+web-sdk/README.md @@ -24,6 +24,9 @@ This is a reference implementation using both the [Optimization Web SDK](../../packages/web/web-sdk/README.md), and is part of the [Contentful Optimization SDK Suite](../../README.md). +On the server side, the stateless Node SDK is created once at module load and each request binds its +own request-scoped options through `forRequest(...)`. + The goal of this reference implementation is to illustrate the usage of cookie-based communication in both the Node and Web SDKs, which is an important component of many server-side/client-side hybrid SSR and ESR solutions. diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index 8e37a16b..aaf0e99a 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -46,6 +46,8 @@ const config = { }, } as const +const sdk = new ContentfulOptimization(config.optimization) + type QsPrimitive = string | ParsedQs type QsArray = QsPrimitive[] // Note: mixed arrays are allowed by ParsedQs type QsValue = QsPrimitive | QsArray | undefined @@ -105,27 +107,26 @@ async function getProfile( userId?: string, anonymousId?: string, ): Promise { - const sdk = new ContentfulOptimization(config.optimization) const args = getUniversalEventBuilderArgs(req) + const requestOptimization = sdk.forRequest({ + locale: args.locale, + }) const cookieProfile = anonymousId ? { id: anonymousId } : undefined if (!userId) { - return await sdk.page({ ...args, profile: cookieProfile }) + return await requestOptimization.page({ ...args, profile: cookieProfile }) } - const { profile } = - (await sdk.identify({ - ...args, - userId, - profile: cookieProfile, - traits: { identified: true }, - })) ?? {} - - if (!profile) return + const identifyResponse = await requestOptimization.identify({ + ...args, + userId, + profile: cookieProfile, + traits: { identified: true }, + }) - return await sdk.page({ + return await requestOptimization.page({ ...args, - profile: cookieProfile ?? { id: profile.id }, + profile: cookieProfile ?? { id: identifyResponse.profile.id }, }) } diff --git a/implementations/node-sdk/README.md b/implementations/node-sdk/README.md index ccb64fc0..5ab3f92e 100644 --- a/implementations/node-sdk/README.md +++ b/implementations/node-sdk/README.md @@ -23,6 +23,9 @@ This is a reference implementation for the [Optimization Node SDK](../../packages/node/node-sdk/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md). +The server creates one stateless Node SDK instance at module load and binds request-specific options +through `forRequest(...)` inside each incoming request handler. + ## Setup All steps should be run from the monorepo root. diff --git a/implementations/node-sdk/src/app.ts b/implementations/node-sdk/src/app.ts index 6e6ed82b..cf822f0a 100644 --- a/implementations/node-sdk/src/app.ts +++ b/implementations/node-sdk/src/app.ts @@ -145,28 +145,29 @@ function getUniversalEventBuilderArgs(req: Request): UniversalEventBuilderArgs { app.get('/', limiter, async (req, res) => { const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) + const requestOptimization = sdk.forRequest({ + locale: universalEventBuilderArgs.locale, + }) const userId = isNonEmptyString(req.query.userId) ? req.query.userId.trim() : undefined - let optimizationResponse: OptimizationData | undefined = undefined - - if (isNonEmptyString(userId)) { - const pageResponse = await sdk.page({ - ...universalEventBuilderArgs, - }) - optimizationResponse = await sdk.identify({ - ...universalEventBuilderArgs, - userId, - traits: { identified: true }, - profile: pageResponse?.profile, - }) - } else { - optimizationResponse = await sdk.page({ - ...universalEventBuilderArgs, - }) - } + const optimizationResponse: OptimizationData = isNonEmptyString(userId) + ? await (async (): Promise => { + const pageResponse = await requestOptimization.page({ + ...universalEventBuilderArgs, + }) + return await requestOptimization.identify({ + ...universalEventBuilderArgs, + userId, + traits: { identified: true }, + profile: pageResponse.profile, + }) + })() + : await requestOptimization.page({ + ...universalEventBuilderArgs, + }) - const { profile, selectedOptimizations } = optimizationResponse ?? {} + const { profile, selectedOptimizations } = optimizationResponse const optimizedEntries = new Map< string, diff --git a/packages/node/node-sdk/README.md b/packages/node/node-sdk/README.md index 8338b15b..9af202d1 100644 --- a/packages/node/node-sdk/README.md +++ b/packages/node/node-sdk/README.md @@ -72,8 +72,16 @@ Configure and initialize the Optimization Node SDK: ```ts const optimization = new ContentfulOptimization({ clientId: 'abc123' }) +const requestOptimization = optimization.forRequest() ``` +Create `optimization` once per module or process, then call `optimization.forRequest(...)` once per +incoming request. + +For a step-by-step Express-style walkthrough that covers request context, profile persistence, +Contentful entry resolution, and hybrid Node + browser setups, see +[Integrating the Optimization Node SDK in a Node App](../../../documentation/integrating-the-node-sdk-in-a-node-app.md). + ## Development Harness The package-local dev harness runs from `packages/node/node-sdk/dev/` and reads `.env` from this @@ -114,20 +122,21 @@ select less-common scenarios, with the most basic example solution possible. ### API Options -| 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 | -| `beaconHandler` | No | `undefined` | Custom handler used to enqueue Insights API event batches | -| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features the Experience API may use for each request | -| `ip` | No | `undefined` | IP address override used by the Experience API 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 API endpoints in plain text | -| `preflight` | No | `false` | Instructs the Experience API to aggregate a new profile state but not store it | +| 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 | -Configuration method signatures: +Request-scoped Experience API options are bound with `optimization.forRequest(...)` instead of the +SDK constructor: -- `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` +| Option | Required? | Default | Description | +| ----------- | --------- | ----------- | ------------------------------------------------------------------------------ | +| `ip` | No | `undefined` | IP address override used by the Experience API for location analysis | +| `locale` | No | `undefined` | Locale used to translate `location.city` and `location.country` | +| `plainText` | No | `undefined` | Sends performance-critical Experience API endpoints in plain text | +| `preflight` | No | `undefined` | Instructs the Experience API to aggregate a new profile state but not store it | ### Event Builder Options @@ -231,6 +240,14 @@ Arguments: ### Experience API and Insights API Event Methods +Create a request scope once per incoming request, then call event methods on that scope: + +```ts +const requestOptimization = optimization.forRequest({ + locale: req.acceptsLanguages()[0] ?? 'en-US', +}) +``` + Only the following methods may return an `OptimizationData` object: - `identify` @@ -246,6 +263,11 @@ contains: - `selectedOptimizations`: Selected optimizations for the profile - `profile`: Profile associated with the evaluated events +In stateless runtimes, Insights-backed methods require a profile for delivery. Non-sticky +`trackView`, `trackClick`, `trackHover`, and `trackFlagView` require `payload.profile.id`. Sticky +`trackView` may omit `profile`, because the returned Experience profile is reused for the paired +Insights event. + #### `identify` Identify the current profile/visitor to associate traits with a profile. @@ -290,8 +312,9 @@ marked as "sticky". Arguments: -- `payload`\*: Entry view event builder arguments object, including an optional `profile` property - with a `PartialProfile` value that requires only an `id` +- `payload`\*: Entry view event builder arguments object. When `payload.sticky` is `true`, `profile` + is optional and the returned Experience profile is reused for Insights delivery. Otherwise, + `profile` is required and must contain at least an `id` #### `trackClick` @@ -303,7 +326,8 @@ Returns: Arguments: -- `payload`\*: Entry click event builder arguments object +- `payload`\*: Entry click event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` #### `trackHover` @@ -315,7 +339,8 @@ Returns: Arguments: -- `payload`\*: Entry hover event builder arguments object +- `payload`\*: Entry hover event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` #### `trackFlagView` @@ -328,7 +353,8 @@ Returns: Arguments: -- `payload`\*: Flag view event builder arguments object +- `payload`\*: Flag view event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` ## Interceptors diff --git a/packages/node/node-sdk/dev/server.ts b/packages/node/node-sdk/dev/server.ts index 6ac83ed8..8065ef82 100644 --- a/packages/node/node-sdk/dev/server.ts +++ b/packages/node/node-sdk/dev/server.ts @@ -166,19 +166,17 @@ app.get('/', limiter, async (req, res) => { const requestProfile: PartialProfile | undefined = typeof profileId === 'string' ? { id: profileId } : undefined - let apiResponse: OptimizationData | undefined = undefined + const requestOptimization = sdk.forRequest() + let apiResponse: OptimizationData = await requestOptimization.page({ profile: requestProfile }) if (isNonEmptyString(userId)) { - await sdk.page({ profile: requestProfile }) - apiResponse = await sdk.identify({ + apiResponse = await requestOptimization.identify({ userId, profile: requestProfile, }) - } else { - apiResponse = await sdk.page({ profile: requestProfile }) } - const { profile, selectedOptimizations } = apiResponse ?? {} + const { profile, selectedOptimizations } = apiResponse const entryIds: string[] = [ '1MwiFl4z7gkwqGYdvCmr8c', // Rich Text field Entry with Merge Tag diff --git a/packages/node/node-sdk/src/ContentfulOptimization.test.ts b/packages/node/node-sdk/src/ContentfulOptimization.test.ts index 74dc4ae5..e6890924 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.test.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.test.ts @@ -17,4 +17,46 @@ describe('ContentfulOptimization', () => { expect(node.config.clientId).toEqual(CLIENT_ID) expect(node.eventBuilder.library.name).toEqual(OPTIMIZATION_NODE_SDK_NAME) }) + + it('binds request-scoped event emitters through forRequest()', async () => { + const node = new ContentfulOptimization(config) + const request = node.forRequest({ locale: 'de-DE', preflight: true }) + const upsertProfile = rs.spyOn(node.api.experience, 'upsertProfile').mockResolvedValue({ + changes: [], + selectedOptimizations: [], + 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, + }, + }, + }) + + await request.page({ profile: { id: 'profile-id' } }) + + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'profile-id', + events: [expect.objectContaining({ type: 'page' })], + }), + expect.objectContaining({ locale: 'de-DE', preflight: true }), + ) + }) }) diff --git a/packages/node/node-sdk/src/ContentfulOptimization.ts b/packages/node/node-sdk/src/ContentfulOptimization.ts index 3846efd7..ce56da92 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.ts @@ -80,7 +80,9 @@ function mergeConfig(config: OptimizationNodeConfig): CoreStatelessConfig { * logLevel: 'info', * }) * - * await sdk.track({ event: 'server_event', properties: { id: 1 } }) + * const request = sdk.forRequest({ locale: 'en-US' }) + * + * await request.track({ event: 'server_event', properties: { id: 1 } }) * ``` * * @see {@link CoreStateless} diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index 802fe0a9..d780613d 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -82,9 +82,9 @@ import { CoreStateless } from '@contentful/optimization-core' Configure and initialize the Core SDK: ```ts -const optimization = new CoreStateful({ clientId: 'abc123' }) -// or -const optimization = new CoreStateless({ clientId: 'abc123' }) +const statefulOptimization = new CoreStateful({ clientId: 'abc123' }) +const statelessOptimization = new CoreStateless({ clientId: 'abc123' }) +const requestOptimization = statelessOptimization.forRequest() ``` ## Working with Stateless Core @@ -97,6 +97,9 @@ In stateless environments, Core will not maintain any internal state, which incl These concerns should be handled by consumers to fit their specific architectural and design specifications. +Request-emitting methods in `CoreStateless` are bound per request via +`optimization.forRequest(...)`. + ## Working with Stateful Core The `CoreStateful` class is intended to be used as the basis for SDKs that would run in stateful @@ -141,26 +144,29 @@ Configuration method signatures: ### API Options -| 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 the Experience API 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 API endpoints in plain text | -| `preflight` | No | `false` | Instructs the Experience API to aggregate a new profile state but not store it | +| 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 | The following configuration option applies only in stateful environments: -| Option | Required? | Default | Description | -| --------------- | --------- | ----------- | ---------------------------------------------------------------------------- | -| `beaconHandler` | No | `undefined` | Handler used to enqueue Insights API events via the Beacon API or equivalent | +| Option | Required? | Default | Description | +| --------------- | --------- | ----------- | ------------------------------------------------------------------------------ | +| `beaconHandler` | No | `undefined` | Handler used to enqueue Insights API events via the Beacon API or equivalent | +| `ip` | No | `undefined` | IP address override used by the Experience API for location analysis | +| `locale` | No | `'en-US'` | Locale used to translate `location.city` and `location.country` | +| `plainText` | No | `false` | Sends performance-critical Experience API endpoints in plain text | +| `preflight` | No | `false` | Instructs the Experience API to aggregate a new profile state but not store it | Configuration method signatures: - `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` +In stateless environments, bind `ip`, `locale`, `plainText`, and `preflight` per request with +`optimization.forRequest(...)` instead of constructor config. + ### Queue Policy Options `queuePolicy` is available only in `CoreStateful` and combines shared flush retry settings with @@ -368,6 +374,15 @@ Arguments: ### Event Methods +In `CoreStateful`, call these methods on the root instance. In `CoreStateless`, call them on the +request scope returned by `optimization.forRequest(...)`: + +```ts +const requestOptimization = optimization.forRequest({ + locale: 'de-DE', +}) +``` + Only the following methods may return an `OptimizationData` object: - `identify` @@ -383,6 +398,11 @@ contains: - `selectedOptimizations`: Selected optimizations for the profile - `profile`: Profile associated with the evaluated events +In stateless runtimes, Insights-backed methods require a profile for delivery. Non-sticky +`trackView`, `trackClick`, `trackHover`, and `trackFlagView` require `payload.profile.id`. Sticky +`trackView` may omit `profile`, because the returned Experience profile is reused for the paired +Insights event. + #### `identify` Identify the current profile/visitor to associate traits with a profile. @@ -427,8 +447,9 @@ marked as "sticky". Arguments: -- `payload`\*: Entry view event builder arguments object, including an optional `profile` property - with a `PartialProfile` value that requires only an `id` +- `payload`\*: Entry view event builder arguments object. When `payload.sticky` is `true`, `profile` + is optional and the returned Experience profile is reused for Insights delivery. Otherwise, + `profile` is required and must contain at least an `id` #### `trackClick` @@ -440,7 +461,8 @@ Returns: Arguments: -- `payload`\*: Entry click event builder arguments object +- `payload`\*: Entry click event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` #### `trackHover` @@ -452,7 +474,8 @@ Returns: Arguments: -- `payload`\*: Entry hover event builder arguments object +- `payload`\*: Entry hover event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` #### `trackFlagView` @@ -465,7 +488,8 @@ Returns: Arguments: -- `payload`\*: Flag view event builder arguments object +- `payload`\*: Flag view event builder arguments object, including a required `profile` property + with a `PartialProfile` value that requires only an `id` ## Stateful-only Core Methods diff --git a/packages/universal/core-sdk/src/CoreApiConfig.ts b/packages/universal/core-sdk/src/CoreApiConfig.ts new file mode 100644 index 00000000..4176ee6d --- /dev/null +++ b/packages/universal/core-sdk/src/CoreApiConfig.ts @@ -0,0 +1,50 @@ +import type { + ExperienceApiClientConfig, + InsightsApiClientConfig, +} from '@contentful/optimization-api-client' + +/** + * Shared API configuration for all Core runtimes. + * + * @public + */ +export interface CoreSharedApiConfig { + /** Base URL override for Experience API requests. */ + experienceBaseUrl?: ExperienceApiClientConfig['baseUrl'] + /** Base URL override for Insights API requests. */ + insightsBaseUrl?: InsightsApiClientConfig['baseUrl'] + /** Experience API features enabled for outgoing requests. */ + enabledFeatures?: ExperienceApiClientConfig['enabledFeatures'] +} + +/** + * API configuration for stateful Core runtimes. + * + * @public + */ +export interface CoreStatefulApiConfig extends CoreSharedApiConfig { + /** Beacon-like handler used by Insights event delivery when available. */ + beaconHandler?: InsightsApiClientConfig['beaconHandler'] + /** 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'] +} + +/** + * API configuration for stateless Core runtimes. + * + * @public + */ +export interface CoreStatelessApiConfig extends CoreSharedApiConfig {} + +/** + * API configuration union for Core runtimes. + * + * @public + */ +export type CoreApiConfig = CoreStatefulApiConfig | CoreStatelessApiConfig diff --git a/packages/universal/core-sdk/src/CoreBase.test.ts b/packages/universal/core-sdk/src/CoreBase.test.ts index 161f9198..1db7d879 100644 --- a/packages/universal/core-sdk/src/CoreBase.test.ts +++ b/packages/universal/core-sdk/src/CoreBase.test.ts @@ -1,47 +1,13 @@ +import type { ApiClientConfig } from '@contentful/optimization-api-client' import { EXPERIENCE_BASE_URL } from '@contentful/optimization-api-client' -import type { ChangeArray, ExperienceEvent, InsightsEvent, PartialProfile } from './api-schemas' +import type { ChangeArray } from './api-schemas' import { OPTIMIZATION_CORE_SDK_NAME } from './constants' import CoreBase, { type CoreConfig } from './CoreBase' import { FlagsResolver } from './resolvers' class TestCore extends CoreBase { - 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 } + constructor(config: CoreConfig, api: Pick = {}) { + super(config, api) } } @@ -88,23 +54,33 @@ describe('CoreBase', () => { }) it('keeps Insights API and Experience API client config isolated', () => { - const core = new TestCore({ - clientId: CLIENT_ID, - api: { - insightsBaseUrl: 'https://ingest.example.test/', - experienceBaseUrl: 'https://experience.example.test/', + const core = new TestCore( + { + clientId: CLIENT_ID, }, - }) + { + insights: { + baseUrl: 'https://ingest.example.test/', + }, + experience: { + baseUrl: 'https://experience.example.test/', + }, + }, + ) expect(Reflect.get(core.api.insights, 'baseUrl')).toBe('https://ingest.example.test/') expect(Reflect.get(core.api.experience, 'baseUrl')).toBe('https://experience.example.test/') }) it('falls back to default base URLs when only one side is configured', () => { - const core = new TestCore({ - clientId: CLIENT_ID, - api: { insightsBaseUrl: 'https://ingest.example.test/' }, - }) + const core = new TestCore( + { + clientId: CLIENT_ID, + }, + { + insights: { baseUrl: 'https://ingest.example.test/' }, + }, + ) expect(Reflect.get(core.api.insights, 'baseUrl')).toBe('https://ingest.example.test/') expect(Reflect.get(core.api.experience, 'baseUrl')).toBe(EXPERIENCE_BASE_URL) @@ -128,59 +104,11 @@ describe('CoreBase', () => { it('resolves custom flags by key without auto-tracking in non-stateful environments', () => { const core = new TestCore(config) - const trackFlagView = rs.spyOn(core, 'trackFlagView').mockResolvedValue(undefined) expect(core.getFlag('dark-mode', CHANGES)).toBe(true) expect(core.getFlag('price', CHANGES)).toEqual({ amount: 10, currency: 'USD', }) - expect(trackFlagView).not.toHaveBeenCalled() - }) - - it('routes sticky entry views through both the Experience API and Insights API', 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 entry 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 6f708199..d339f28c 100644 --- a/packages/universal/core-sdk/src/CoreBase.ts +++ b/packages/universal/core-sdk/src/CoreBase.ts @@ -1,20 +1,15 @@ import { ApiClient, type ApiClientConfig, - type ExperienceApiClientConfig, type GlobalApiConfigProperties, - type InsightsApiClientConfig, } from '@contentful/optimization-api-client' import type { ChangeArray, ExperienceEvent as ExperienceEventPayload, - ExperienceEventType, InsightsEvent as InsightsEventPayload, - InsightsEventType, Json, MergeTagEntry, OptimizationData, - PartialProfile, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' @@ -22,53 +17,11 @@ 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 { OPTIMIZATION_CORE_SDK_NAME, OPTIMIZATION_CORE_SDK_VERSION } from './constants' -import { - type ClickBuilderArgs, - EventBuilder, - type EventBuilderConfig, - type FlagViewBuilderArgs, - type HoverBuilderArgs, - type IdentifyBuilderArgs, - type PageViewBuilderArgs, - type ScreenViewBuilderArgs, - type TrackBuilderArgs, - type ViewBuilderArgs, -} from './events' +import { EventBuilder, type EventBuilderConfig } from './events' import { InterceptorManager } from './lib/interceptor' import type { ResolvedData } from './resolvers' import { FlagsResolver, MergeTagValueResolver, OptimizedEntryResolver } 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. * @@ -87,11 +40,6 @@ export interface LifecycleInterceptors { * @public */ export interface CoreConfig extends Pick { - /** - * Unified API configuration used by Experience and Insights clients. - */ - api?: CoreApiConfig - /** * Event builder configuration (channel/library metadata, etc.). */ @@ -101,18 +49,23 @@ export interface CoreConfig extends Pick { /** Shared Optimization API client instance. */ readonly api: ApiClient /** Shared event builder instance. */ readonly eventBuilder: EventBuilder /** Resolved core configuration. */ - readonly config: CoreConfig + readonly config: TConfig /** Static resolver for evaluating optimized custom flags. */ readonly flagsResolver = FlagsResolver /** Static resolver for merge-tag lookups against profile data. */ @@ -135,10 +88,10 @@ abstract class CoreBase { * const sdk = new CoreStateless({ clientId: 'abc123', environment: 'prod' }) * ``` */ - constructor(config: CoreConfig) { + constructor(config: TConfig, api: CoreBaseApiClientConfig = {}) { this.config = config - const { api, eventBuilder, logLevel, environment, clientId, fetchOptions } = config + const { eventBuilder, logLevel, environment, clientId, fetchOptions } = config logger.addSink(new ConsoleLogSink(logLevel)) @@ -146,8 +99,8 @@ abstract class CoreBase { clientId, environment, fetchOptions, - insights: CoreBase.createInsightsApiConfig(api), - experience: CoreBase.createExperienceApiConfig(api), + experience: api.experience, + insights: api.insights, } this.api = new ApiClient(apiConfig) @@ -160,48 +113,6 @@ abstract class CoreBase { ) } - private static createExperienceApiConfig( - api: CoreApiConfig | undefined, - ): ApiClientConfig['experience'] { - 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 - } - - return { - baseUrl, - enabledFeatures, - ip, - locale, - plainText, - preflight, - } - } - - private static createInsightsApiConfig( - api: CoreApiConfig | undefined, - ): ApiClientConfig['insights'] { - if (api === undefined) return undefined - - const { beaconHandler, insightsBaseUrl: baseUrl } = api - - if (baseUrl === undefined && beaconHandler === undefined) { - return undefined - } - - return { baseUrl, beaconHandler } - } - /** * Get the value of a custom flag derived from a set of optimization changes. * @@ -275,198 +186,6 @@ abstract class CoreBase { getMergeTagValue(embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile): string | undefined { 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 through the Experience path. - * - * @param payload - Identify builder arguments. - * @returns The resulting {@link OptimizationData} for the identified user. - * @example - * ```ts - * const data = await core.identify({ userId: 'user-123', traits: { plan: 'pro' } }) - * ``` - */ - async identify( - payload: IdentifyBuilderArgs & { profile?: PartialProfile }, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - 'identify', - [payload], - this.eventBuilder.buildIdentify(builderArgs), - profile, - ) - } - - /** - * 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. - * @example - * ```ts - * const data = await core.page({ properties: { title: 'Home' } }) - * ``` - */ - async page( - payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - 'page', - [payload], - this.eventBuilder.buildPageView(builderArgs), - profile, - ) - } - - /** - * 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. - * @example - * ```ts - * const data = await core.screen({ name: 'HomeScreen' }) - * ``` - */ - async screen( - payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - 'screen', - [payload], - this.eventBuilder.buildScreenView(builderArgs), - profile, - ) - } - - /** - * 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. - * @example - * ```ts - * const data = await core.track({ event: 'button_click', properties: { label: 'Buy' } }) - * ``` - */ - async track( - payload: TrackBuilderArgs & { profile?: PartialProfile }, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - 'track', - [payload], - this.eventBuilder.buildTrack(builderArgs), - profile, - ) - } - - /** - * Track an entry view through Insights and, when sticky, Experience. - * - * @param payload - Entry view builder arguments. When `payload.sticky` is - * `true`, the event will also be sent through Experience as a sticky - * entry view. - * @returns A promise that resolves when all delegated calls complete. - * @remarks - * Experience receives sticky entry views only; Insights is always invoked - * regardless of `sticky`. - * @example - * ```ts - * await core.trackView({ componentId: 'entry-123', sticky: true }) - * ``` - */ - async trackView( - payload: ViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { - const { profile, ...builderArgs } = payload - let result = undefined - - if (payload.sticky) { - result = await this.sendExperienceEvent( - 'trackView', - [payload], - this.eventBuilder.buildView(builderArgs), - profile, - ) - } - - await this.sendInsightsEvent( - 'trackView', - [payload], - this.eventBuilder.buildView(builderArgs), - profile, - ) - - return result - } - - /** - * Track an entry click through Insights. - * - * @param payload - Entry click builder arguments. - * @returns A promise that resolves when processing completes. - * @example - * ```ts - * await core.trackClick({ componentId: 'entry-123' }) - * ``` - */ - async trackClick(payload: ClickBuilderArgs): Promise { - await this.sendInsightsEvent('trackClick', [payload], this.eventBuilder.buildClick(payload)) - } - - /** - * Track an entry hover through Insights. - * - * @param payload - Entry hover builder arguments. - * @returns A promise that resolves when processing completes. - * @example - * ```ts - * await core.trackHover({ componentId: 'entry-123' }) - * ``` - */ - async trackHover(payload: HoverBuilderArgs): Promise { - await this.sendInsightsEvent('trackHover', [payload], this.eventBuilder.buildHover(payload)) - } - - /** - * Track a feature flag view through Insights. - * - * @param payload - Flag view builder arguments used to build the flag view event. - * @returns A promise that resolves when processing completes. - * @example - * ```ts - * await core.trackFlagView({ componentId: 'feature-flag-123' }) - * ``` - */ - async trackFlagView(payload: FlagViewBuilderArgs): Promise { - await this.sendInsightsEvent( - 'trackFlagView', - [payload], - this.eventBuilder.buildFlagView(payload), - ) - } } export default CoreBase diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 513016c0..fb1c0729 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -1,21 +1,20 @@ +import type { ApiClientConfig } from '@contentful/optimization-api-client' import type { ChangeArray, ExperienceEvent as ExperienceEventPayload, + ExperienceEventType, InsightsEvent as InsightsEventPayload, + InsightsEventType, Json, - MergeTagEntry, - OptimizationData, - PartialProfile, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger, logger } 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 { ConsentController, ConsentGuard } from './Consent' -import CoreBase, { type CoreConfig, type EventType } from './CoreBase' -import type { FlagViewBuilderArgs } from './events' +import type { CoreStatefulApiConfig } from './CoreApiConfig' +import type { CoreConfig } from './CoreBase' +import CoreStatefulEventEmitter from './CoreStatefulEventEmitter' import { toPositiveInt } from './lib/number' import { type QueueFlushPolicy, resolveQueueFlushPolicy } from './lib/queue' import { @@ -24,7 +23,6 @@ import { } from './lib/singleton/StatefulRuntimeSingleton' import { ExperienceQueue, type ExperienceQueueDropContext } from './queues/ExperienceQueue' import { InsightsQueue } from './queues/InsightsQueue' -import type { ResolvedData } from './resolvers' import { batch, blockedEvent as blockedEventSignal, @@ -41,25 +39,58 @@ import { selectedOptimizations as selectedOptimizationsSignal, signalFns, type SignalFns, - signals, type Signals, - toDistinctObservable, + signals, toObservable, } from './signals' import { PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, PREVIEW_PANEL_SIGNALS_SYMBOL } from './symbols' const coreLogger = createScopedLogger('CoreStateful') +/** + * Union of all event type keys that stateful Core may emit. + * + * @public + */ +export type EventType = InsightsEventType | ExperienceEventType + 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' +const hasDefinedValues = (record: Record): boolean => + Object.values(record).some((value) => value !== undefined) + +const createStatefulExperienceApiConfig = ( + api: CoreStatefulApiConfig | undefined, +): ApiClientConfig['experience'] => { + if (api === undefined) return undefined + + const experienceConfig = { + baseUrl: api.experienceBaseUrl, + enabledFeatures: api.enabledFeatures, + ip: api.ip, + locale: api.locale, + plainText: api.plainText, + preflight: api.preflight, + } + + return hasDefinedValues(experienceConfig) ? experienceConfig : undefined +} + +const createStatefulInsightsApiConfig = ( + api: CoreStatefulApiConfig | undefined, +): ApiClientConfig['insights'] => { + if (api === undefined) return undefined + + const insightsConfig = { + baseUrl: api.insightsBaseUrl, + beaconHandler: api.beaconHandler, + } + + return hasDefinedValues(insightsConfig) ? insightsConfig : undefined +} + /** * Unified queue policy for stateful Core. * @@ -146,6 +177,11 @@ export interface CoreConfigDefaults { * @public */ export interface CoreStatefulConfig extends CoreConfig { + /** + * Unified API configuration for stateful environments. + */ + api?: CoreStatefulApiConfig + /** * Allow-listed event type strings permitted when consent is not set. */ @@ -173,14 +209,13 @@ let statefulInstanceCounter = 0 * * @public */ -class CoreStateful extends CoreBase implements ConsentController, ConsentGuard { +class CoreStateful extends CoreStatefulEventEmitter implements ConsentController, ConsentGuard { private readonly singletonOwner: string private destroyed = false - private readonly flagObservables = new Map>() - private readonly allowedEventTypes: EventType[] - private readonly experienceQueue: ExperienceQueue - private readonly insightsQueue: InsightsQueue - private readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] + protected readonly allowedEventTypes: EventType[] + protected readonly experienceQueue: ExperienceQueue + protected readonly insightsQueue: InsightsQueue + protected readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] /** * Expose merged observable state for consumers. @@ -198,7 +233,10 @@ class CoreStateful extends CoreBase implements ConsentController, ConsentGuard { } constructor(config: CoreStatefulConfig) { - super(config) + super(config, { + experience: createStatefulExperienceApiConfig(config.api), + insights: createStatefulInsightsApiConfig(config.api), + }) this.singletonOwner = `CoreStateful#${++statefulInstanceCounter}` acquireStatefulRuntimeSingleton(this.singletonOwner) @@ -247,170 +285,6 @@ class CoreStateful extends CoreBase implements ConsentController, ConsentGuard { } } - override getFlag(name: string, changes: ChangeArray | undefined = changesSignal.value): Json { - const value = super.getFlag(name, changes) - const payload = this.buildFlagViewBuilderArgs(name, changes) - - void this.trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) - - return value - } - - override resolveOptimizedEntry< - S extends EntrySkeletonType = EntrySkeletonType, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedOptimizations?: SelectedOptimizationArray, - ): ResolvedData - override resolveOptimizedEntry< - S extends EntrySkeletonType, - M extends ChainModifiers = ChainModifiers, - L extends LocaleCode = LocaleCode, - >(entry: Entry, selectedOptimizations?: SelectedOptimizationArray): ResolvedData - override resolveOptimizedEntry< - S extends EntrySkeletonType, - M extends ChainModifiers, - L extends LocaleCode = LocaleCode, - >( - entry: Entry, - selectedOptimizations: - | SelectedOptimizationArray - | undefined = selectedOptimizationsSignal.value, - ): ResolvedData { - return super.resolveOptimizedEntry(entry, selectedOptimizations) - } - - 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, - ): FlagViewBuilderArgs { - const change = changes?.find((candidate) => candidate.key === name) - - return { - componentId: name, - experienceId: change?.meta.experienceId, - variantIndex: change?.meta.variantIndex, - } - } - - private getFlagObservable(name: string): Observable { - const existingObservable = this.flagObservables.get(name) - if (existingObservable) return existingObservable - - const trackFlagView = this.trackFlagView.bind(this) - const buildFlagViewBuilderArgs = this.buildFlagViewBuilderArgs.bind(this) - const valueSignal = signalFns.computed(() => super.getFlag(name, changesSignal.value)) - const distinctObservable = toDistinctObservable(valueSignal, isEqual) - - const trackedObservable: Observable = { - get current() { - const { current: value } = distinctObservable - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) - - return value - }, - - subscribe: (next) => - distinctObservable.subscribe((value) => { - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) - next(value) - }), - - subscribeOnce: (next) => - distinctObservable.subscribeOnce((value) => { - const payload = buildFlagViewBuilderArgs(name, changesSignal.value) - - void trackFlagView(payload).catch((error: unknown) => { - logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) - }) - next(value) - }), - } - - this.flagObservables.set(name, trackedObservable) - - return trackedObservable - } - - 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( diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts new file mode 100644 index 00000000..43b5c95b --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -0,0 +1,407 @@ +import type { + ChangeArray, + ExperienceEvent as ExperienceEventPayload, + InsightsEvent as InsightsEventPayload, + Json, + MergeTagEntry, + OptimizationData, + PartialProfile, + Profile, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import { createScopedLogger, logger } 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 CoreBase from './CoreBase' +import type { CoreStatefulConfig, EventType } from './CoreStateful' +import type { + ClickBuilderArgs, + FlagViewBuilderArgs, + HoverBuilderArgs, + IdentifyBuilderArgs, + PageViewBuilderArgs, + ScreenViewBuilderArgs, + TrackBuilderArgs, + ViewBuilderArgs, +} from './events' +import type { ExperienceQueue } from './queues/ExperienceQueue' +import type { InsightsQueue } from './queues/InsightsQueue' +import type { ResolvedData } from './resolvers' +import { + blockedEvent as blockedEventSignal, + changes as changesSignal, + consent as consentSignal, + type Observable, + profile as profileSignal, + selectedOptimizations as selectedOptimizationsSignal, + signalFns, + toDistinctObservable, +} from './signals' + +const coreLogger = createScopedLogger('CoreStateful') + +const CONSENT_EVENT_TYPE_MAP: Readonly>> = { + trackView: 'component', + trackFlagView: 'component', + trackClick: 'component_click', + trackHover: 'component_hover', +} + +/** + * Shared stateful event-emission surface extracted to keep `CoreStateful.ts` + * below the local max-lines limit while preserving the public API. + * + * @internal + */ +abstract class CoreStatefulEventEmitter + extends CoreBase + implements ConsentGuard +{ + protected readonly flagObservables = new Map>() + + protected abstract readonly allowedEventTypes: EventType[] + protected abstract readonly experienceQueue: ExperienceQueue + protected abstract readonly insightsQueue: InsightsQueue + protected abstract readonly onEventBlocked?: CoreStatefulConfig['onEventBlocked'] + + override getFlag(name: string, changes: ChangeArray | undefined = changesSignal.value): Json { + const value = super.getFlag(name, changes) + const payload = this.buildFlagViewBuilderArgs(name, changes) + + void this.trackFlagView(payload).catch((error: unknown) => { + logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) + }) + + return value + } + + override resolveOptimizedEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations?: SelectedOptimizationArray, + ): ResolvedData + override resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >(entry: Entry, selectedOptimizations?: SelectedOptimizationArray): ResolvedData + override resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations: + | SelectedOptimizationArray + | undefined = selectedOptimizationsSignal.value, + ): ResolvedData { + return super.resolveOptimizedEntry(entry, selectedOptimizations) + } + + override getMergeTagValue( + embeddedEntryNodeTarget: MergeTagEntry, + profile: Profile | undefined = profileSignal.value, + ): string | undefined { + return super.getMergeTagValue(embeddedEntryNodeTarget, profile) + } + + /** + * 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. + * @example + * ```ts + * const data = await core.identify({ userId: 'user-123', traits: { plan: 'pro' } }) + * ``` + */ + async identify( + payload: IdentifyBuilderArgs & { profile?: PartialProfile }, + ): Promise { + const { profile, ...builderArgs } = payload + return await this.sendExperienceEvent( + 'identify', + [payload], + this.eventBuilder.buildIdentify(builderArgs), + profile, + ) + } + + /** + * 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. + * @example + * ```ts + * const data = await core.page({ properties: { title: 'Home' } }) + * ``` + */ + async page( + payload: PageViewBuilderArgs & { profile?: PartialProfile } = {}, + ): Promise { + const { profile, ...builderArgs } = payload + return await this.sendExperienceEvent( + 'page', + [payload], + this.eventBuilder.buildPageView(builderArgs), + profile, + ) + } + + /** + * 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. + * @example + * ```ts + * const data = await core.screen({ name: 'HomeScreen' }) + * ``` + */ + async screen( + payload: ScreenViewBuilderArgs & { profile?: PartialProfile }, + ): Promise { + const { profile, ...builderArgs } = payload + return await this.sendExperienceEvent( + 'screen', + [payload], + this.eventBuilder.buildScreenView(builderArgs), + profile, + ) + } + + /** + * 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. + * @example + * ```ts + * const data = await core.track({ event: 'button_click', properties: { label: 'Buy' } }) + * ``` + */ + async track( + payload: TrackBuilderArgs & { profile?: PartialProfile }, + ): Promise { + const { profile, ...builderArgs } = payload + return await this.sendExperienceEvent( + 'track', + [payload], + this.eventBuilder.buildTrack(builderArgs), + profile, + ) + } + + /** + * Track an entry view through Insights and, when sticky, Experience. + * + * @param payload - Entry view builder arguments. When `payload.sticky` is + * `true`, the event will also be sent through Experience as a sticky + * entry view. + * @returns A promise that resolves when all delegated calls complete. + * @remarks + * Experience receives sticky entry views only; Insights is always invoked + * regardless of `sticky`. + * @example + * ```ts + * await core.trackView({ componentId: 'entry-123', sticky: true }) + * ``` + */ + async trackView( + payload: ViewBuilderArgs & { profile?: PartialProfile }, + ): Promise { + const { profile, ...builderArgs } = payload + let result: OptimizationData | undefined = undefined + + if (payload.sticky) { + result = await this.sendExperienceEvent( + 'trackView', + [payload], + this.eventBuilder.buildView(builderArgs), + profile, + ) + } + await this.sendInsightsEvent( + 'trackView', + [payload], + this.eventBuilder.buildView(builderArgs), + profile, + ) + + return result + } + + /** + * Track an entry click through Insights. + * + * @param payload - Entry click builder arguments. + * @returns A promise that resolves when processing completes. + * @example + * ```ts + * await core.trackClick({ componentId: 'entry-123' }) + * ``` + */ + async trackClick(payload: ClickBuilderArgs): Promise { + await this.sendInsightsEvent('trackClick', [payload], this.eventBuilder.buildClick(payload)) + } + + /** + * Track an entry hover through Insights. + * + * @param payload - Entry hover builder arguments. + * @returns A promise that resolves when processing completes. + * @example + * ```ts + * await core.trackHover({ componentId: 'entry-123' }) + * ``` + */ + async trackHover(payload: HoverBuilderArgs): Promise { + await this.sendInsightsEvent('trackHover', [payload], this.eventBuilder.buildHover(payload)) + } + + /** + * Track a feature flag view through Insights. + * + * @param payload - Flag view builder arguments used to build the flag view event. + * @returns A promise that resolves when processing completes. + * @example + * ```ts + * await core.trackFlagView({ componentId: 'feature-flag-123' }) + * ``` + */ + async trackFlagView(payload: FlagViewBuilderArgs): Promise { + await this.sendInsightsEvent( + 'trackFlagView', + [payload], + this.eventBuilder.buildFlagView(payload), + ) + } + + 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) + } + + protected 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 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 buildFlagViewBuilderArgs( + name: string, + changes: ChangeArray | undefined = changesSignal.value, + ): FlagViewBuilderArgs { + const change = changes?.find((candidate) => candidate.key === name) + + return { + componentId: name, + experienceId: change?.meta.experienceId, + variantIndex: change?.meta.variantIndex, + } + } + + protected getFlagObservable(name: string): Observable { + const existingObservable = this.flagObservables.get(name) + if (existingObservable) return existingObservable + + const trackFlagView = this.trackFlagView.bind(this) + const buildFlagViewBuilderArgs = this.buildFlagViewBuilderArgs.bind(this) + const valueSignal = signalFns.computed(() => super.getFlag(name, changesSignal.value)) + const distinctObservable = toDistinctObservable(valueSignal, isEqual) + + const trackedObservable: Observable = { + get current() { + const { current: value } = distinctObservable + const payload = buildFlagViewBuilderArgs(name, changesSignal.value) + + void trackFlagView(payload).catch((error: unknown) => { + logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) + }) + + return value + }, + + subscribe: (next) => + distinctObservable.subscribe((value) => { + const payload = buildFlagViewBuilderArgs(name, changesSignal.value) + + void trackFlagView(payload).catch((error: unknown) => { + logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) + }) + next(value) + }), + + subscribeOnce: (next) => + distinctObservable.subscribeOnce((value) => { + const payload = buildFlagViewBuilderArgs(name, changesSignal.value) + + void trackFlagView(payload).catch((error: unknown) => { + logger.warn(`Failed to emit "flag view" event for "${name}"`, String(error)) + }) + next(value) + }), + } + + this.flagObservables.set(name, trackedObservable) + + return trackedObservable + } + + 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 + } +} + +export default CoreStatefulEventEmitter diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index 2a2a33fa..8e848a8f 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -1,6 +1,17 @@ import type { OptimizationData } from './api-schemas' import CoreStateless from './CoreStateless' +const TRACK_CLICK_PROFILE_ERROR = + 'CoreStateless.forRequest().trackClick() requires `payload.profile.id` for Insights delivery.' +const TRACK_HOVER_PROFILE_ERROR = + 'CoreStateless.forRequest().trackHover() requires `payload.profile.id` for Insights delivery.' +const TRACK_FLAG_VIEW_PROFILE_ERROR = + 'CoreStateless.forRequest().trackFlagView() requires `payload.profile.id` for Insights delivery.' +const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = + 'CoreStateless.forRequest().trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' + +type CoreStatelessRequest = ReturnType['forRequest']> + const EMPTY_OPTIMIZATION_DATA: OptimizationData = { changes: [], selectedOptimizations: [], @@ -29,8 +40,22 @@ const EMPTY_OPTIMIZATION_DATA: OptimizationData = { }, } +async function invokeUntypedRequestMethod( + request: CoreStatelessRequest, + method: 'trackClick' | 'trackHover' | 'trackFlagView' | 'trackView', + payload: Record, +): Promise { + const methodRef = Reflect.get(request, method) + + if (typeof methodRef !== 'function') { + throw new Error(`Expected "${method}" to be a function`) + } + + return await Reflect.apply(methodRef, request, [payload]) +} + describe('CoreStateless', () => { - it('strips beaconHandler from stateless api config', () => { + it('strips stateful-only api config from stateless construction', () => { const beaconHandler = rs.fn(() => true) const core: unknown = Reflect.construct(CoreStateless, [ { @@ -39,6 +64,10 @@ describe('CoreStateless', () => { api: { beaconHandler, insightsBaseUrl: 'https://ingest.example.test/', + ip: '198.51.100.5', + locale: 'de-DE', + plainText: false, + preflight: true, }, }, ]) @@ -48,15 +77,25 @@ describe('CoreStateless', () => { } expect(Reflect.get(core.api.insights, 'beaconHandler')).toBeUndefined() + expect(Reflect.get(core.api.experience, 'ip')).toBeUndefined() + expect(Reflect.get(core.api.experience, 'locale')).toBeUndefined() + expect(Reflect.get(core.api.experience, 'plainText')).toBeUndefined() + expect(Reflect.get(core.api.experience, 'preflight')).toBeUndefined() }) - it('sends explicit profiles through Experience upserts', async () => { + it('forwards request-bound options and explicit profiles through Experience upserts', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ + ip: '203.0.113.10', + locale: 'de-DE', + plainText: false, + preflight: true, + }) const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - await core.identify({ + await request.identify({ userId: 'user-123', profile: { id: 'profile-123' }, }) @@ -66,17 +105,74 @@ describe('CoreStateless', () => { profileId: 'profile-123', events: [expect.objectContaining({ type: 'identify' })], }), + { + ip: '203.0.113.10', + locale: 'de-DE', + plainText: false, + preflight: true, + }, + ) + }) + + it('keeps request locale and event locale separate', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const request = core.forRequest({ locale: 'de-DE' }) + + await request.page({ + locale: 'en-US', + profile: { id: 'profile-123' }, + }) + + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'profile-123', + events: [ + expect.objectContaining({ context: expect.objectContaining({ locale: 'en-US' }) }), + ], + }), + expect.objectContaining({ locale: 'de-DE' }), + ) + }) + + it('isolates request-bound options between separate request scopes', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + + await Promise.all([ + core.forRequest({ ip: '203.0.113.10', locale: 'de-DE' }).track({ event: 'first' }), + core.forRequest({ ip: '198.51.100.5', locale: 'en-US', plainText: false }).track({ + event: 'second', + }), + ]) + + const requestOptions = upsertProfile.mock.calls.map(([, options]) => options) + + expect(requestOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ip: '203.0.113.10', locale: 'de-DE' }), + expect.objectContaining({ + ip: '198.51.100.5', + locale: 'en-US', + plainText: false, + }), + ]), ) }) it('sends sticky entry views through both the Experience API and Insights API', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ preflight: true }) 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({ + await request.trackView({ componentId: 'hero-banner', sticky: true, viewId: 'hero-banner-view', @@ -89,7 +185,60 @@ describe('CoreStateless', () => { profileId: 'profile-123', events: [expect.objectContaining({ type: 'component' })], }), + expect.objectContaining({ preflight: true }), ) + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: EMPTY_OPTIMIZATION_DATA.profile, + events: [expect.objectContaining({ type: 'component' })], + }, + ]) + }) + + it('rejects insights-only stateless methods without a profile id', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest() + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await expect( + invokeUntypedRequestMethod(request, 'trackClick', { + componentId: 'hero-banner', + }), + ).rejects.toThrow(TRACK_CLICK_PROFILE_ERROR) + await expect( + invokeUntypedRequestMethod(request, 'trackHover', { + componentId: 'hero-banner', + hoverDurationMs: 1000, + hoverId: 'hover-id', + }), + ).rejects.toThrow(TRACK_HOVER_PROFILE_ERROR) + await expect( + invokeUntypedRequestMethod(request, 'trackFlagView', { + componentId: 'new-navigation', + }), + ).rejects.toThrow(TRACK_FLAG_VIEW_PROFILE_ERROR) + + expect(sendBatchEvents).not.toHaveBeenCalled() + }) + + it('keeps non-sticky entry views on Insights only', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ preflight: true }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await expect( + request.trackView({ + componentId: 'hero-banner', + viewId: 'hero-banner-view', + viewDurationMs: 1000, + profile: { id: 'profile-123' }, + }), + ).resolves.toBeUndefined() + + expect(upsertProfile).not.toHaveBeenCalled() expect(sendBatchEvents).toHaveBeenCalledWith([ { profile: { id: 'profile-123' }, @@ -97,4 +246,107 @@ describe('CoreStateless', () => { }, ]) }) + + it('rejects non-sticky entry views without a profile id', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ preflight: true }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await expect( + invokeUntypedRequestMethod(request, 'trackView', { + componentId: 'hero-banner', + viewDurationMs: 1000, + viewId: 'hero-banner-view', + }), + ).rejects.toThrow(NON_STICKY_TRACK_VIEW_PROFILE_ERROR) + + expect(sendBatchEvents).not.toHaveBeenCalled() + }) + + it('reuses the Experience response profile for sticky entry views without an input profile', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ preflight: true }) + const responseProfile = { ...EMPTY_OPTIMIZATION_DATA.profile, id: 'profile-from-experience' } + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + ...EMPTY_OPTIMIZATION_DATA, + profile: responseProfile, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + const result = await request.trackView({ + componentId: 'hero-banner', + sticky: true, + viewDurationMs: 1000, + viewId: 'hero-banner-view', + }) + + expect(result).toEqual({ + ...EMPTY_OPTIMIZATION_DATA, + profile: responseProfile, + }) + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: undefined, + events: [expect.objectContaining({ type: 'component' })], + }), + expect.objectContaining({ preflight: true }), + ) + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: responseProfile, + events: [expect.objectContaining({ type: 'component' })], + }, + ]) + }) + + it('prefers the Experience response profile over a stale input profile for sticky entry views', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ preflight: true }) + const staleProfile = { id: 'stale-profile' } + const responseProfile = { ...EMPTY_OPTIMIZATION_DATA.profile, id: 'fresh-profile' } + rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + ...EMPTY_OPTIMIZATION_DATA, + profile: responseProfile, + }) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await request.trackView({ + componentId: 'hero-banner', + sticky: true, + viewDurationMs: 1000, + viewId: 'hero-banner-view', + profile: staleProfile, + }) + + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: responseProfile, + events: [expect.objectContaining({ type: 'component' })], + }, + ]) + }) + + it('keeps request-bound options off insights-only methods', async () => { + const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) + const request = core.forRequest({ + ip: '203.0.113.10', + locale: 'de-DE', + plainText: false, + preflight: true, + }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await request.trackClick({ componentId: 'hero-banner', profile: { id: 'profile-123' } }) + + expect(upsertProfile).not.toHaveBeenCalled() + expect(sendBatchEvents).toHaveBeenCalledWith([ + { + profile: { id: 'profile-123' }, + events: [expect.objectContaining({ type: 'component_click' })], + }, + ]) + }) }) diff --git a/packages/universal/core-sdk/src/CoreStateless.ts b/packages/universal/core-sdk/src/CoreStateless.ts index 00c1535b..218ce634 100644 --- a/packages/universal/core-sdk/src/CoreStateless.ts +++ b/packages/universal/core-sdk/src/CoreStateless.ts @@ -1,17 +1,24 @@ -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 { + ApiClientConfig, + ExperienceApiClientRequestOptions, +} from '@contentful/optimization-api-client' +import type { CoreStatelessApiConfig } from './CoreApiConfig' +import CoreBase, { type CoreConfig } from './CoreBase' +import { CoreStatelessRequestScope } from './CoreStatelessRequestScope' import type { EventBuilderConfig } from './events' /** - * Configuration for the Node-specific Optimization SDK. + * Request-bound Experience API options for stateless runtimes. + * + * @public + */ +export interface CoreStatelessRequestOptions extends Pick< + ExperienceApiClientRequestOptions, + 'ip' | 'locale' | 'plainText' | 'preflight' +> {} + +/** + * Configuration for stateless Optimization Core runtimes. * * @public * @remarks @@ -19,12 +26,11 @@ import type { EventBuilderConfig } from './events' * of the event-builder configuration. SDKs commonly inject their own library * metadata or channel definitions. */ -export interface CoreStatelessConfig extends Omit { +export interface CoreStatelessConfig extends CoreConfig { /** - * Unified API configuration for stateless environments. Omits stateful-only - * delivery hooks such as `beaconHandler`. + * Unified API configuration for stateless environments. */ - api?: Omit + api?: CoreStatelessApiConfig /** * Overrides for the event builder configuration. Omits methods that are only @@ -33,53 +39,58 @@ export interface CoreStatelessConfig extends Omit { eventBuilder?: Omit } +const hasDefinedValues = (record: Record): boolean => + Object.values(record).some((value) => value !== undefined) + +const createStatelessExperienceApiConfig = ( + api: CoreStatelessConfig['api'] | undefined, +): ApiClientConfig['experience'] => { + if (api === undefined) return undefined + + const experienceConfig = { + baseUrl: api.experienceBaseUrl, + enabledFeatures: api.enabledFeatures, + } + + return hasDefinedValues(experienceConfig) ? experienceConfig : undefined +} + +const createStatelessInsightsApiConfig = ( + api: CoreStatelessConfig['api'] | undefined, +): ApiClientConfig['insights'] => { + if (api?.insightsBaseUrl === undefined) return undefined + + return { + baseUrl: api.insightsBaseUrl, + } +} + /** * Core runtime for stateless environments. * * @public - * Built on top of `CoreBase`. + * Built on top of `CoreBase`. Request-emitting methods are exposed through + * {@link CoreStateless.forRequest}. */ -class CoreStateless extends CoreBase { +class CoreStateless extends CoreBase { constructor(config: CoreStatelessConfig) { - super({ - ...config, - api: config.api ? { ...config.api, beaconHandler: undefined } : undefined, - }) - } - - 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], + super(config, { + experience: createStatelessExperienceApiConfig(config.api), + insights: createStatelessInsightsApiConfig(config.api), }) } - 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) + /** + * Bind request-scoped Experience API options for a single stateless request. + * + * @param options - Request-scoped Experience API options. + * @returns A lightweight request scope for stateless event emission. + */ + forRequest(options: CoreStatelessRequestOptions = {}): CoreStatelessRequestScope { + return new CoreStatelessRequestScope(this, options) } } +export { CoreStatelessRequestScope } + export default CoreStateless diff --git a/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts b/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts new file mode 100644 index 00000000..59e3e7d4 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts @@ -0,0 +1,215 @@ +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 { PartialProfile, type OptimizationData } from './api-schemas' +import type CoreStateless from './CoreStateless' +import type { CoreStatelessRequestOptions } from './CoreStateless' +import type { + ClickBuilderArgs, + FlagViewBuilderArgs, + HoverBuilderArgs, + IdentifyBuilderArgs, + PageViewBuilderArgs, + ScreenViewBuilderArgs, + TrackBuilderArgs, + ViewBuilderArgs, +} from './events' + +type StatelessExperiencePayload = TPayload & { profile?: PartialProfile } +type StatelessInsightsPayload = TPayload & { profile: PartialProfile } +type StatelessStickyTrackViewPayload = ViewBuilderArgs & { + profile?: PartialProfile + sticky: true +} +type StatelessNonStickyTrackViewPayload = Omit & { + profile: PartialProfile + sticky?: false | undefined +} + +const TRACK_CLICK_PROFILE_ERROR = + 'CoreStateless.forRequest().trackClick() requires `payload.profile.id` for Insights delivery.' +const TRACK_HOVER_PROFILE_ERROR = + 'CoreStateless.forRequest().trackHover() requires `payload.profile.id` for Insights delivery.' +const TRACK_FLAG_VIEW_PROFILE_ERROR = + 'CoreStateless.forRequest().trackFlagView() requires `payload.profile.id` for Insights delivery.' +const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = + 'CoreStateless.forRequest().trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' +const STICKY_TRACK_VIEW_PROFILE_ERROR = + 'CoreStateless.forRequest().trackView() could not derive a profile from the sticky Experience response. Pass `payload.profile.id` explicitly if you need a fallback.' + +const requireInsightsProfile = ( + profile: PartialProfile | undefined, + errorMessage: string, +): PartialProfile => { + if (profile !== undefined) return profile + + throw new Error(errorMessage) +} + +/** + * Stateless request scope created by {@link CoreStateless.forRequest}. + * + * @public + */ +export class CoreStatelessRequestScope { + private readonly core: CoreStateless + private readonly options: Readonly + + constructor(core: CoreStateless, options: CoreStatelessRequestOptions = {}) { + this.core = core + this.options = Object.freeze({ ...options }) + } + + async identify( + payload: StatelessExperiencePayload, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.core.eventBuilder.buildIdentify(builderArgs), + profile, + ) + } + + async page( + payload: StatelessExperiencePayload = {}, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.core.eventBuilder.buildPageView(builderArgs), + profile, + ) + } + + async screen( + payload: StatelessExperiencePayload, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.core.eventBuilder.buildScreenView(builderArgs), + profile, + ) + } + + async track(payload: StatelessExperiencePayload): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent(this.core.eventBuilder.buildTrack(builderArgs), profile) + } + + /** + * Record an entry view in a stateless runtime. + * + * @remarks + * Non-sticky entry views require `payload.profile.id` for Insights delivery. + * Sticky entry views may omit `profile`, because the returned Experience + * profile is reused for the paired Insights event. + */ + async trackView( + payload: StatelessStickyTrackViewPayload | StatelessNonStickyTrackViewPayload, + ): Promise { + const { profile, ...builderArgs } = payload + let result: OptimizationData | undefined = undefined + let insightsProfile: PartialProfile | undefined = profile + + if (payload.sticky) { + result = await this.sendExperienceEvent( + this.core.eventBuilder.buildView(builderArgs), + profile, + ) + const { profile: responseProfile } = result + insightsProfile = responseProfile + } + + await this.sendInsightsEvent( + this.core.eventBuilder.buildView(builderArgs), + requireInsightsProfile( + insightsProfile, + payload.sticky ? STICKY_TRACK_VIEW_PROFILE_ERROR : NON_STICKY_TRACK_VIEW_PROFILE_ERROR, + ), + ) + + return result + } + + /** + * Record an entry click in a stateless runtime. + * + * @remarks + * Stateless Insights delivery requires `payload.profile.id`. + */ + async trackClick(payload: StatelessInsightsPayload): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.core.eventBuilder.buildClick(builderArgs), + requireInsightsProfile(profile, TRACK_CLICK_PROFILE_ERROR), + ) + } + + /** + * Record an entry hover in a stateless runtime. + * + * @remarks + * Stateless Insights delivery requires `payload.profile.id`. + */ + async trackHover(payload: StatelessInsightsPayload): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.core.eventBuilder.buildHover(builderArgs), + requireInsightsProfile(profile, TRACK_HOVER_PROFILE_ERROR), + ) + } + + /** + * Record a Custom Flag view in a stateless runtime. + * + * @remarks + * Stateless Insights delivery requires `payload.profile.id`. + */ + async trackFlagView(payload: StatelessInsightsPayload): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.core.eventBuilder.buildFlagView(builderArgs), + requireInsightsProfile(profile, TRACK_FLAG_VIEW_PROFILE_ERROR), + ) + } + + private async sendExperienceEvent( + event: ExperienceEventPayload, + profile?: PartialProfile, + ): Promise { + const intercepted = await this.core.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) + + return await this.core.api.experience.upsertProfile( + { + profileId: profile?.id, + events: [validEvent], + }, + this.options, + ) + } + + private async sendInsightsEvent( + event: InsightsEventPayload, + profile: PartialProfile, + ): Promise { + const intercepted = await this.core.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) + const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ + { profile: parseWithFriendlyError(PartialProfile, profile), events: [validEvent] }, + ]) + + await this.core.api.insights.sendBatchEvents(batchEvent) + } +} diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index b6f56759..00f9c463 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -21,9 +21,11 @@ export { export type * from './BlockedEvent' export type { ConsentController, ConsentGuard } from './Consent' export * from './constants' +export type * from './CoreApiConfig' export * from './CoreBase' export * from './CoreStateful' export * from './CoreStateless' +export * from './CoreStatelessRequestScope' export * from './events' export * from './lib/decorators' export * from './lib/interceptor'