Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/main-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1063,12 +1063,12 @@ jobs:
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1

# Smoke-test packaging: assemble the AAR + sources/javadoc/POM the release would publish
# (no Central access, no signing) so packaging breaks are caught on PRs.
# (no Central access, no signing) so packaging breaks are caught on PRs. Release publishing
# separately generates and requires Android third-party notices.
- name: Verify Maven publishing assembles
working-directory: packages/android/ContentfulOptimization
run: |
./gradlew publishToMavenLocal -Pcontentful.optimization.version=0.0.0-ci \
-Pcontentful.optimization.thirdPartyNoticesFile=THIRD_PARTY_NOTICES.txt \
--no-configuration-cache --no-daemon --console=plain

e2e-react-native-android:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/publish-android.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
run: |
./gradlew publishToMavenLocal \
-Pcontentful.optimization.version="$RELEASE_VERSION" \
-Pcontentful.optimization.requireThirdPartyNotices=true \
--no-configuration-cache --no-daemon --console=plain

# Build the signed AAR (+ sources/javadoc/POM) and publish+release it to Maven Central via the
Expand All @@ -119,4 +120,5 @@ jobs:
run: |
./gradlew publishAndReleaseToMavenCentral \
-Pcontentful.optimization.version="$RELEASE_VERSION" \
-Pcontentful.optimization.requireThirdPartyNotices=true \
--no-configuration-cache --no-daemon --stacktrace
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,14 @@ Allow-listed events can emit before event consent is granted, but their SDK-buil
When the consent guard blocks an event, the SDK writes blocked-event metadata and calls the
configured `onEventBlocked` callback. You can also subscribe to `states.blockedEventStream` in
stateful JavaScript SDKs. Blocked events are dropped at the SDK boundary and are not replayed after
`consent(true)`. If the application needs an event after consent, emit a fresh `page()`,
`identify()`, `screen()`, or interaction event when policy allows.
`consent(true)`. Do not store or resend suppressed interaction events such as clicks, taps, hovers,
or custom interactions.

SDK-owned current-state surfaces can still emit a fresh event after consent when the underlying
condition is still current and the event has not already been accepted. Automatic page or screen
trackers can emit the active page or screen after tracking becomes allowed, and active flag
subscriptions can emit a flag-view event for the current flag value. Those emissions represent the
current application state after consent, not replay of the previously blocked event.

### Revocation and profile cleanup

Expand Down Expand Up @@ -366,5 +372,5 @@ Before releasing a consent-aware Optimization SDK integration, verify these impl
third-party destinations.
- Subscribe to `states.blockedEventStream` or use `onEventBlocked` during validation to confirm
denied events are blocked.
- Verify post-consent flows emit fresh events instead of relying on replay of previously blocked
events.
- Verify post-consent flows never replay suppressed interactions, and that page, screen, or flag
current-state emitters send only fresh events for conditions that are still current.
22 changes: 13 additions & 9 deletions documentation/concepts/core-state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ change Core state or event streams are:
| `trackClick(payload)` | Sends an Insights event. |
| `trackHover(payload)` | Sends an Insights event. |
| `trackFlagView(payload)` | Sends an Insights event recording a Custom Flag observation. |
| `getFlag(name)` | Resolves a Custom Flag value from `changes`; emits a flag view event when the resolved value changes. |
| `states.flag(name).current` | Reads the current Custom Flag value and emits a flag view event for that read. |
| `states.flag(name).subscribe()` | Subscribes to distinct Custom Flag values and emits a flag view event for each delivered value. |
| `states.flag(name).subscribeOnce()` | Waits for the first non-nullish Custom Flag value and emits a flag view event for that value. |
| `getFlag(name)` | Resolves a Custom Flag value from `changes`; emits a deduped flag view event for the resolved value. |
| `states.flag(name).current` | Reads the current Custom Flag value and emits a deduped flag view event for that value. |
| `states.flag(name).subscribe()` | Subscribes to distinct Custom Flag values and emits deduped flag view events for delivered values. |
| `states.flag(name).subscribeOnce()` | Waits for the first non-nullish Custom Flag value and emits a deduped flag view event for that value. |
| `reset()` | Clears `blockedEvent`, `event`, `changes`, `profile`, and `selectedOptimizations` in a single batch. |
| `flush()` | Triggers immediate queue flushes without writing to any signal directly. |
| `destroy()` | Flushes both queues and releases the singleton lock. |
Expand Down Expand Up @@ -181,7 +181,9 @@ via the `allowedEventTypes` configuration option.
Calling `consent(true)` unblocks all gated events going forward and grants durable
profile-continuity persistence consent. Calling `consent({ events: true, persistence: false })`
allows event emission while keeping profile continuity session-only. Blocked events are not replayed
after consent is granted.
after consent is granted. Current-state SDK surfaces, such as automatic page or screen trackers and
active flag subscriptions, may emit a fresh event after consent when the same page, screen, or flag
value is still current and has not already produced an accepted event.

## What consumers receive: the `states` surface

Expand Down Expand Up @@ -300,10 +302,12 @@ const darkModeSubscription = sdk.states.flag('dark-mode').subscribe((value) => {
})
```

`states.flag(name).subscribe()` suppresses duplicate emitted values using deep equality and emits a
flag view event for each delivered value. `states.flag(name).current` represents a direct read, so
each `current` read emits a flag view event. `getFlag(name)` is nonreactive and deduplicates flag
view events when repeated calls resolve the same value.
`states.flag(name).subscribe()` suppresses duplicate emitted values using deep equality and attempts
to emit a flag view event for each delivered value. `states.flag(name).current` represents a direct
read, and `getFlag(name)` is nonreactive. All flag-view paths deduplicate accepted flag-view
signatures, so repeated reads of the same value for the same active profile do not emit duplicate
flag-view events. A read blocked before consent does not count as accepted, so the current flag
value can still produce a fresh flag-view event after consent is granted.

If you forward Custom Flag values to a third-party analytics destination, use the same flag read or
render path that your application already owns. Adding a `states.flag(name)` subscription only for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import com.contentful.optimization.app.screens.MainScreen
import com.contentful.optimization.compose.OptimizationRoot
import com.contentful.optimization.core.OptimizationConfig
import com.contentful.optimization.core.StorageDefaults
import com.contentful.optimization.preview.PreviewPanelConfig
import com.contentful.optimization.shared.AppConfig
import com.contentful.optimization.shared.MockPreviewContentfulClient
Expand Down Expand Up @@ -47,6 +48,7 @@ class MainActivity : ComponentActivity() {
experienceBaseUrl = AppConfig.experienceBaseUrl,
insightsBaseUrl = AppConfig.insightsBaseUrl,
locale = AppConfig.defaultContentfulLocale,
defaults = StorageDefaults(consent = true),
debug = true,
),
trackViews = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ fun MainScreen() {
var entries by remember { mutableStateOf<List<Map<String, Any>>>(emptyList()) }
var showNavigationTest by remember { mutableStateOf(false) }
var showLiveUpdatesTest by remember { mutableStateOf(false) }
var flagSubscribed by remember { mutableStateOf(false) }
var viewportHeight by remember { mutableStateOf(0f) }

LaunchedEffect(Unit) {
EventStore.subscribe(client.events, scope)
client.consent(true)
client.subscribeToFlag("boolean")
try { client.page(mapOf("url" to "app")) } catch (_: Exception) {}
}

Expand All @@ -71,10 +70,6 @@ fun MainScreen() {
AppConfig.entryIds,
AppConfig.defaultContentfulLocale,
)
if (!flagSubscribed) {
flagSubscribed = true
client.subscribeToFlag("boolean")
}
}
}

Expand Down Expand Up @@ -123,7 +118,6 @@ fun MainScreen() {
if (entries.isEmpty()) {
Text("Loading...")
} else {

val scrollContext = remember(viewportHeight) {
ScrollContext(scrollY = 0f, viewportHeight = viewportHeight)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.contentful.optimization.app.views.components.NestedContentEntryViewBi
import com.contentful.optimization.app.views.components.isNestedContent
import com.contentful.optimization.app.views.support.setTestTag
import com.contentful.optimization.core.OptimizationConfig
import com.contentful.optimization.core.StorageDefaults
import com.contentful.optimization.preview.PreviewPanelConfig
import com.contentful.optimization.shared.AppConfig
import com.contentful.optimization.shared.ContentfulFetcher
Expand Down Expand Up @@ -70,6 +71,7 @@ class MainActivity : AppCompatActivity() {
experienceBaseUrl = AppConfig.experienceBaseUrl,
insightsBaseUrl = AppConfig.insightsBaseUrl,
locale = AppConfig.defaultContentfulLocale,
defaults = StorageDefaults(consent = true),
debug = true,
),
trackViews = true,
Expand Down Expand Up @@ -117,15 +119,15 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, LiveUpdatesTestActivity::class.java))
}

// Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, consent, page, optional
// Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, flag, page, optional
// offline. The Compose impl gates rendering on `client.isInitialized` via
// OptimizationRoot, so its content's LaunchedEffects always see an initialized client.
// The Views impl renders immediately, so we wait for init here before driving the SDK —
// otherwise consent/page silently no-op and the profile state flow never advances.
EventStore.subscribe(client.events, lifecycleScope)
lifecycleScope.launch {
client.isInitialized.first { it }
client.consent(true)
client.subscribeToFlag("boolean")
try {
client.page(mapOf("url" to "app"))
} catch (_: Exception) {
Expand Down Expand Up @@ -154,7 +156,6 @@ class MainActivity : AppCompatActivity() {
// diffing keeps existing nodes when the data is identical.
if (entriesLoaded) return@collect
entriesLoaded = true
client.subscribeToFlag("boolean")
val entries = ContentfulFetcher.fetchEntries(
AppConfig.entryIds,
AppConfig.defaultContentfulLocale,
Expand Down
12 changes: 4 additions & 8 deletions implementations/ios-sdk/swiftui/Screens/MainScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ struct MainScreen: View {
// subscription (e.g. in a child view's onAppear) would miss it —
// `eventPublisher` is a PassthroughSubject and does not buffer.
EventStore.shared.subscribe(to: client.eventPublisher)
client.consent(true)
if !flagSubscribed {
flagSubscribed = true
client.subscribeToFlag("boolean")
}
_ = try? await client.page(properties: ["url": "app"])
}
.onReceive(
Expand All @@ -98,13 +101,6 @@ struct MainScreen: View {
}
) { profile in
guard profile != nil else { return }
// Subscribe to the `boolean` flag once a profile (and consent) is
// available so a flag-view `component` event is emitted — mirrors
// the React Native app's gated `sdk.states.flag(...).subscribe(...)`.
if !flagSubscribed {
flagSubscribed = true
client.subscribeToFlag("boolean")
}
Task {
entries = await ContentfulFetcher.fetchEntries(
ids: AppConfig.entryIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ final class MainViewController: UIViewController {
private let client: OptimizationClient
private var entries: [[String: Any]] = []
private var firstAppearHandled = false
private var flagSubscribed = false
private var cancellables = Set<AnyCancellable>()

private let identifyButton = UIButton(type: .system)
Expand Down Expand Up @@ -39,6 +38,7 @@ final class MainViewController: UIViewController {

EventStore.shared.subscribe(to: client.eventPublisher)
analyticsView.bind(to: EventStore.shared)
client.subscribeToFlag("boolean")

client.$state
.map { $0.profile }
Expand All @@ -52,12 +52,6 @@ final class MainViewController: UIViewController {
guard let self else { return }
self.updateIdentifyControls(profile: profile)
guard profile != nil else { return }
// Subscribe to the `boolean` flag once a profile (and consent)
// is available so a flag-view `component` event is emitted.
if !self.flagSubscribed {
self.flagSubscribed = true
self.client.subscribeToFlag("boolean")
}
Task { @MainActor in
let fetched = await ContentfulFetcher.fetchEntries(
ids: AppConfig.entryIds,
Expand All @@ -75,7 +69,6 @@ final class MainViewController: UIViewController {
guard !firstAppearHandled else { return }
firstAppearHandled = true

client.consent(true)
Task { @MainActor in
_ = try? await client.page(properties: ["url": "app"])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import XCTest
/// Verifies that when an app subscribes to a flag through the SDK, the SDK emits a
/// view event for that flag so the value's exposure can be measured downstream.
///
/// Platform note: the pseudocode contract subscribes to the `boolean` flag on app
/// launch and asserts a view event for it. The iOS implementation app shells
/// (`swiftui`/`uikit`) do not currently call `sdk.states.flag("boolean").subscribe()`,
/// so the `event-count-boolean` stats label is not produced. See the report for this
/// app-side gap.
/// Platform note: both iOS implementation app shells subscribe to the `boolean`
/// flag on launch through the native SDK wrapper so this E2E test can assert
/// the SDK-emitted flag view event without app-level consent workarounds.
final class FlagViewTrackingTests: XCTestCase {
let app = XCUIApplication()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ test.describe('entry view tracking', () => {
).toBeVisible()
})

test('same-route page event is not duplicated by repeated consent acceptance', async ({
page,
}) => {
const pageEvents = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'page' }) })

await expect(pageEvents).toHaveCount(1)

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

await expect(pageEvents).toHaveCount(1)
})

test('entry view events have been emitted', async ({ page }) => {
for (const entryId of Object.keys(variantEntryTexts)) {
const entryText = variantEntryTexts[entryId]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ test.describe('flag view tracking', () => {
await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible()
})

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

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

Expand Down
2 changes: 1 addition & 1 deletion implementations/node-sdk+web-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"serve:mocks": "pm2 start --name esr-mocks \"pnpm --dir ../../lib/mocks serve\"",
"serve:mocks:stop": "pm2 stop esr-mocks && pm2 delete esr-mocks",
"serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop",
"test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT",
"test:e2e": "sh -c 'pnpm serve && playwright test \"$@\"; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT' --",
"test:e2e:codegen": "playwright codegen",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --ui",
Expand Down
7 changes: 6 additions & 1 deletion implementations/node-sdk+web-sdk/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface RenderResponseOptions {
readonly appConsent: boolean | undefined
readonly appLocale: string
readonly id?: string
readonly optimizationData?: OptimizationData
readonly userId?: string
}

Expand Down Expand Up @@ -127,7 +128,7 @@ function getAppConsentFromCookies(cookies: unknown): boolean | undefined {

function respond(
res: Response,
{ appConsent, appLocale, id, userId }: RenderResponseOptions,
{ appConsent, appLocale, id, optimizationData, userId }: RenderResponseOptions,
): void {
if (appConsent === true && id) {
res.cookie(ANONYMOUS_ID_COOKIE, id, {
Expand All @@ -143,6 +144,7 @@ function respond(
appConsent: appConsent ?? null,
appLocale,
identified: userId,
optimizationData: optimizationData ?? null,
})
}

Expand Down Expand Up @@ -194,13 +196,15 @@ app.get('/', limiter, async (req, res) => {
appConsent,
appLocale,
id: optimizationData?.profile.id,
optimizationData,
})
})
app.get('/smoke-test', limiter, (_, res) => {
res.render('index', {
appConsent: null,
config,
appLocale: APP_LOCALE,
optimizationData: null,
})
})
app.get('/user/:id', limiter, async (req, res) => {
Expand All @@ -213,6 +217,7 @@ app.get('/user/:id', limiter, async (req, res) => {
appConsent,
appLocale,
id: optimizationData?.profile.id,
optimizationData,
userId,
})
})
Expand Down
Loading
Loading