Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lintstagedrc.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'*.{js,jsx,cjs,mjs,ts,tsx}':
- eslint --fix
- eslint --fix --no-warn-ignored
- prettier --check --write
'*.{json,yaml,yml,md,mdx,mdc,html,css,less}':
- prettier --check --write
7 changes: 6 additions & 1 deletion .specify/research/repository-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,13 @@ graph LR
- lifecycle interceptor managers (`event`, `state`)
- `CoreBase` exposes high-level methods:
- personalization path: `identify`, `page`, `screen`, `track`
- personalization resolution path: `getFlag`, `personalizeEntry`, `getMergeTagValue`
- mixed path: `trackView` (sticky routes through personalization; non-sticky routes through
analytics)
- analytics path: `trackClick`, `trackHover`, `trackFlagView`
- Resolver utilities are centralized and surfaced through core methods:
- flags resolution
- key-scoped flag resolution (`getFlag`)
- advanced full-map flag resolution through `flagsResolver.resolve(...)`
- personalized entry resolution
- merge-tag value resolution

Expand All @@ -142,11 +144,14 @@ graph LR
- `CoreStateless`:
- no state signal model
- composes `AnalyticsStateless` + `PersonalizationStateless`
- `getFlag(...)` does not auto-emit `trackFlagView`
- intended for server/SSR usage
- `CoreStateful`:
- module-global signal model (`@preact/signals-core`) for `consent`, `profile`, `changes`,
`selectedPersonalizations`, `event`, `blockedEvent`, `online`, `previewPanelAttached`,
`previewPanelOpen`
- key-scoped reactive flag access via `states.flag(name)`
- `getFlag(...)` and `states.flag(name)` auto-emit flag-view analytics
- singleton lock enforced via `StatefulRuntimeSingleton` keyed in `globalThis`
- explicit lifecycle controls: `destroy()`, `flush()`, `reset()`
- preview bridge method `registerPreviewPanel(...)` exposes mutable `signals` and `signalFns`
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ automatically with each new version.
- `pnpm docs:watch` watches for file updates and rebuilds documentation output; useful while writing
and updating documentation

When changing public SDK behavior in this pre-release alpha period, update the same pull request to
keep these artifacts aligned:

- TSDoc/JSDoc comments near changed API surfaces
- package READMEs that document those surfaces
- SpecKit artifacts under `specs/**` and `.specify/research/**`

## Troubleshooting CI Issues

### E2E Coverage and Environment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const variantEntryTexts: Record<string, string> = {
'This is a baseline content entry for all identified or unidentified users.',
}

test.describe('events', () => {
test.describe('entry view tracking', () => {
test.describe('without consent', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date() })
Expand Down Expand Up @@ -78,7 +78,9 @@ test.describe('events', () => {
).toBeVisible()
}

const allComponentEvents = page.locator('#event-stream li button[data-view-id]')
const allComponentEvents = page.locator(
'#event-stream li button[data-view-id]:not([data-component-id="boolean"])',
)

await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10)

Expand Down
33 changes: 33 additions & 0 deletions implementations/node-sdk+web-sdk/e2e/flag-view-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test, type Locator, type Page } from '@playwright/test'

function getFlagAccessComponentEvents(page: Page): Locator {
return page
.locator('#event-stream li button[data-component-id="boolean"]')
.filter({ hasText: /^component$/ })
}

test.describe('flag view tracking', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await page.getByRole('button', { name: 'Accept Consent' }).click()
await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible()
})

test('flag access emits a component event', async ({ page }) => {
await page.goto('/user/flag-access-e2e')
await page.waitForLoadState('domcontentloaded')

const flagAccessEvents = getFlagAccessComponentEvents(page)
await expect
.poll(async () => await flagAccessEvents.count(), {
message: 'flag access should append a component event in the event stream',
})
.toBeGreaterThan(0)

const latestFlagAccessEvent = flagAccessEvents.last()

await expect(latestFlagAccessEvent).toHaveText('component')
expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull()
})
})
8 changes: 8 additions & 0 deletions implementations/node-sdk+web-sdk/src/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,14 @@

void scheduleRenderEntries()
})

contentfulOptimization.states.profile.subscribe((profile) => {
if (!profile) return

// Emit and log flag access
const booleanFlag = contentfulOptimization.getFlag('boolean')
console.log('Flag "boolean" value:', booleanFlag)
})
</script>
</body>
</html>
5 changes: 1 addition & 4 deletions implementations/node-sdk/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ app.get('/', limiter, async (req, res) => {
})
}

const { profile, selectedPersonalizations, changes } = optimizationResponse ?? {}
const { profile, selectedPersonalizations } = optimizationResponse ?? {}

const personalizedEntries = new Map<
string,
Expand Down Expand Up @@ -205,13 +205,10 @@ app.get('/', limiter, async (req, res) => {
personalizedEntries.set(entryId, personalizedEntry)
})

const flags = sdk.getCustomFlags(changes)

const pageData = {
profile,
selectedPersonalizations,
entries: personalizedEntries,
flags,
}

res.render('index', { ...pageData })
Expand Down
21 changes: 21 additions & 0 deletions implementations/react-native-sdk/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ function AppContent(): React.JSX.Element {
const sdk = useOptimization()
const [entries, setEntries] = useState<Entry[]>([])
const [sdkError, setSdkError] = useState<string | null>(null)
const [hasConsent, setHasConsent] = useState<boolean>(false)
const [hasProfile, setHasProfile] = useState<boolean>(false)
const [isIdentified, setIsIdentified] = useState<boolean>(false)
const [showNavigationTest, setShowNavigationTest] = useState<boolean>(false)
const [showLiveUpdatesTest, setShowLiveUpdatesTest] = useState<boolean>(false)
Expand All @@ -41,18 +43,37 @@ function AppContent(): React.JSX.Element {
void sdk.page({ properties: { url: 'app' } })

const subscription = sdk.states.profile.subscribe((profile) => {
setHasProfile(profile !== undefined)

if (!profile) {
return
}

void fetchEntries(ENTRY_IDS, setEntries, setSdkError)
})

const consentSubscription = sdk.states.consent.subscribe((consent) => {
setHasConsent(consent === true)
})

return () => {
subscription.unsubscribe()
consentSubscription.unsubscribe()
}
}, [sdk])

useEffect(() => {
if (!hasConsent || !hasProfile) {
return
}

const flagSubscription = sdk.states.flag('boolean').subscribe(() => undefined)

return () => {
flagSubscription.unsubscribe()
}
}, [hasConsent, hasProfile, sdk])

const handleIdentify = (): void => {
void sdk.identify({ userId: 'charles', traits: { identified: true } })
setIsIdentified(true)
Expand Down
22 changes: 22 additions & 0 deletions implementations/react-native-sdk/e2e/flag-view-tracking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const {
clearProfileState,
ELEMENT_VISIBILITY_TIMEOUT,
waitForComponentEventCount,
} = require('./helpers')

describe('Flag View Tracking', () => {
beforeAll(async () => {
await device.launchApp()
})

beforeEach(async () => {
await clearProfileState({ requireFreshAppInstance: true })
})

it('should emit component events for the subscribed boolean flag', async () => {
const analyticsTitle = element(by.text('Analytics Events'))
await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT)

await waitForComponentEventCount('boolean', 1, ELEMENT_VISIBILITY_TIMEOUT)
})
})
101 changes: 101 additions & 0 deletions implementations/web-sdk/e2e/entry-view-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { expect, test } from '@playwright/test'

const variantEntryTexts: Record<string, string> = {
'1JAU028vQ7v6nB2swl3NBo': 'This is a level 0 nested baseline entry.',
'5i4SdJXw9oDEY0vgO7CwF4': 'This is a level 1 nested baseline entry.',
uaNY4YJ0HFPAX3gKXiRdX: 'This is a level 2 nested baseline entry.',
'1MwiFl4z7gkwqGYdvCmr8c':
'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.',
'4k6ZyFQnR2POY5IJLLlJRb': 'This is a variant content entry for visitors from Europe.',
'6iyPl6vfDH5AoClf3MtYlh': 'This is a variant content entry for visitors using a desktop browser.',
'1UFf7qr4mHET3HYuYmcpEj': 'This is a variant content entry for new visitors.',
'4bmHsNUaEibELHwWCon3dt': 'This is a variant content entry for an A/B/C experiment: B',
'6zqoWXyiSrf0ja7I2WGtYj':
'This is a baseline content entry for all visitors with or without a custom event.',
'7pa5bOx8Z9NmNcr7mISvD':
'This is a baseline content entry for all identified or unidentified users.',
}

test.describe('entry view tracking', () => {
test.describe('without consent', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date() })
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
})

test('page event has been emitted', async ({ page }) => {
await expect(
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }),
).toBeVisible()
})

test('component view events have not been emitted', async ({ page }) => {
for (const entryText of Object.values(variantEntryTexts)) {
const element = page.getByText(entryText)

await element.scrollIntoViewIfNeeded()

await page.clock.fastForward('02:00')
}

await expect(page.locator('#event-stream li button[data-view-id]')).toHaveCount(0)
})
})

test.describe('with consent', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date() })
await page.goto('/')
await page.waitForLoadState('domcontentloaded')

const consent = page.getByRole('button', { name: 'Accept Consent' })
await consent.click()
})

test('page event has been emitted', async ({ page }) => {
await expect(
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }),
).toBeVisible()
})

test('component view events have been emitted', async ({ page }) => {
for (const entryId of Object.keys(variantEntryTexts)) {
const entryText = variantEntryTexts[entryId]

if (!entryText) continue

const element = page.getByText(entryText)

await element.scrollIntoViewIfNeeded()

await page.clock.fastForward('02:00')

await expect(
page
.locator(`#event-stream li button[data-component-id="${entryId}"][data-view-id]`)
.first(),
).toBeVisible()
}

const allComponentEvents = page.locator(
'#event-stream li button[data-view-id]:not([data-component-id="boolean"])',
)

await expect.poll(async () => await allComponentEvents.count()).toBeGreaterThanOrEqual(10)

const viewIdButtons = allComponentEvents
const viewIds: string[] = []
const viewIdCount = await viewIdButtons.count()

for (let index = 0; index < viewIdCount; index += 1) {
const viewId = await viewIdButtons.nth(index).getAttribute('data-view-id')

expect(viewId).not.toBeNull()
if (viewId) viewIds.push(viewId)
}

expect(new Set(viewIds).size).toEqual(viewIds.length)
})
})
})
34 changes: 34 additions & 0 deletions implementations/web-sdk/e2e/flag-view-tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test, type Locator, type Page } from '@playwright/test'

function getFlagAccessComponentEvents(page: Page): Locator {
return page
.locator('#event-stream li button[data-component-id="boolean"]')
.filter({ hasText: /^component$/ })
}

test.describe('flag view tracking', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await page.getByRole('button', { name: 'Accept Consent' }).click()
})

test('flag access emits a component event', async ({ page }) => {
const flagAccessEvents = getFlagAccessComponentEvents(page)
const baselineFlagEventCount = await flagAccessEvents.count()

await page.getByRole('button', { name: 'Identify' }).click()
await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible()

await expect
.poll(async () => await flagAccessEvents.count(), {
message: 'flag access should append a component event in the event stream',
})
.toBeGreaterThan(baselineFlagEventCount)

const latestFlagAccessEvent = flagAccessEvents.last()

await expect(latestFlagAccessEvent).toHaveText('component')
expect(await latestFlagAccessEvent.getAttribute('data-view-id')).toBeNull()
})
})
5 changes: 4 additions & 1 deletion implementations/web-sdk/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -576,10 +576,13 @@ <h2>Event Stream</h2>
},
)

// Subscribe to profile state, find entries in the markup, and render them
contentfulOptimization.states.profile.subscribe((profile) => {
if (!profile) return

// Emit and log flag access
const booleanFlag = contentfulOptimization.getFlag('boolean')
console.log('Flag "boolean" value:', booleanFlag)

toggleIdentity(profile.traits && Object.keys(profile.traits).length)
})
</script>
Expand Down
Loading
Loading