diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts index d5341e41154..ee31258beca 100644 --- a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts @@ -141,14 +141,55 @@ export const buildBrazeMessaging = async ( ); } - // Trigger the Braze Banners System refresh (Ask Braze to fetch data) - // (Requests banners by a list of placement IDs from the Braze backend.) - // (Note that this method can only be called once per session.) - // Since we want to suppress In-App Messages if a banner exists, we must - // call requestBannersRefresh before openSession. + // Open the Braze session + braze.openSession(); + + // Trigger the Braze Banners System refresh (fetch banner content for all placement IDs). await refreshBanners(braze); - braze.openSession(); + /** + * Re-request banner eligibility whenever the user returns to this tab. + * + * After the initial requestBannersRefresh above, the Braze SDK manages + * subsequent session starts silently: once the user has been inactive for + * sessionTimeoutInSeconds (1800s / 30 minutes), the SDK internally starts a + * new session and fires a "Start Session" event to the Braze backend. This + * can trigger Canvas re-entry and make a banner available again, but the SDK + * does not expose a session lifecycle hook (there is no subscribeToSessionUpdates + * in SDK v6.5.0). This means even if the Canvas has re-entered the user and a + * new banner is ready, the SDK keeps serving its locally cached null because it + * was never instructed to fetch new data. + * + * We use the document visibilitychange event as a proxy for "user has returned + * after a potentially session-creating absence". This is the same signal the + * SDK itself uses internally to detect inactivity and start new sessions, so it + * is the correct moment to ask Braze for updated eligibility. + * + * The Braze SDK's built-in token bucket rate limiting (5 tokens per session, + * 1 refill every 3 minutes) ensures that rapid or accidental tab switches + * do not trigger unnecessary network requests. If no tokens are available, the + * SDK fires the error callback in refreshBanners, which resolves gracefully + * without blocking the page or throwing. + * + * We also keep a local timestamp of the last refresh. The initial page-load + * requestBannersRefresh above counts as the first refresh, so lastRefreshAt + * starts at Date.now(). Inside the listener we skip the call if fewer than + * 5 seconds have elapsed — this prevents burning two tokens on a trivial + * background tab open (e.g. the user Command+clicks a link and instantly + * switches back), while still refreshing on any meaningful return to the tab. + * + * This fix was validated with Braze support, who confirmed that + * requestBannersRefresh must be called at the start of each new session for + * Canvas re-eligibility to work as expected. + */ + let lastRefreshAt = Date.now(); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (Date.now() - lastRefreshAt < 5000) return; + lastRefreshAt = Date.now(); + // void refreshBanners(braze); + } + }); const brazeCards = window.guardian.config.switches.brazeContentCards ? new BrazeCards(braze, errorHandler) diff --git a/dotcom-rendering/src/lib/braze/initialiseBraze.ts b/dotcom-rendering/src/lib/braze/initialiseBraze.ts index ab56e38d366..8b348547a85 100644 --- a/dotcom-rendering/src/lib/braze/initialiseBraze.ts +++ b/dotcom-rendering/src/lib/braze/initialiseBraze.ts @@ -4,6 +4,41 @@ import { isUndefined, log } from '@guardian/libs'; // Define the type alias export type BrazeInstance = typeof braze; +/** + * Braze SDK initialisation options. + * + * IMPORTANT: sessionTimeoutInSeconds is set to 1800 (30 minutes, the SDK default). + * + * We previously had this set to 1 second, which caused two critical problems with + * the Braze Banners System when used with a Braze Canvas: + * + * 1. Canvas entry chaos: + * Braze Canvas campaigns that use "Start Session" as their entry trigger rely on + * a meaningful definition of a session. With a 1-second timeout, the SDK was + * firing hundreds of "Start Session" events per user per day (one per second of + * inactivity). A Canvas configured with "re-entry after 10 seconds" was never + * designed to cycle every 10-11 seconds, so Canvas step advancement became + * chaotic and banner availability was unpredictable. + * Any Canvas using session-based delay steps (e.g., "wait 2 sessions") would + * also behave incorrectly, since "2 sessions" was effectively "2 seconds". + * + * 2. Silent requestBannersRefresh drops: + * The Braze SDK enforces a "once per session" constraint on requestBannersRefresh. + * With a 1-second timeout, a fast page reload (under 1 second since the previous + * page unload) was seen by the SDK as still within the previous session, causing + * the requestBannersRefresh call at init to be silently dropped. getBanner() would + * then return the stale cached null from the previous session, and the banner would + * not appear. This produced the intermittent "sometimes I see it, sometimes I do not" + * behaviour observed in production. + * + * Setting this to 1800 aligns with the SDK default and ensures that "Start Session" + * events map to genuine user visits rather than sub-second inactivity timeouts. + * + * NOTE ON ANALYTICS IMPACT: Changing from 1 to 1800 will dramatically reduce the + * session count reported in Braze. The previous count was heavily inflated (every + * 1 second of inactivity = new session). Any dashboards or audience segments built + * on session counts will need to be reviewed by CRM after this change. + */ const SDK_OPTIONS: braze.InitializationOptions = { enableLogging: true, noCookies: true,