From 9696acc4195d42f9fd6af767f2c95875e57be70a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:07:35 +0000 Subject: [PATCH] Gate Fullstory capture on Osano Analytics consent Osano blocks fs.js on page load when a visitor has declined Analytics, but it can't stop a script that's already running. A visitor who declined mid-session kept being recorded until the next page load, and a visitor who accepted mid-session wasn't recorded until then either. Capture also depended on Osano recognizing Fullstory's script URL. - Queue FS('init', { env: { captureOnStartup: false } }) right after the snippet, so Fullstory never starts capture on its own. - Read Osano's consent on load and on every osano-cm-consent-saved event: start capture when Analytics is accepted, shut it down when it's withdrawn, and restart it if it's granted again. - Never start capture when osano.js is blocked, since there's no consent to read. - Add plugins/fullstory.test.js and run it from npm run test:plugins. US visitors still get Osano's default of accepting Analytics on load, so this doesn't change when their capture starts. That default is an Osano dashboard setting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUv8r1UmXksNKFmsHaYoTF --- package.json | 3 +- plugins/AGENTS.md | 3 +- plugins/fullstory.js | 40 +++++++++++++ plugins/fullstory.test.js | 123 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 plugins/fullstory.test.js diff --git a/package.json b/package.json index 1181ed3dee..ed2fa93683 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "preserve": "npm run build:plugins", "serve": "docusaurus serve --dir dist", "test": "cypress run", - "test:plugins": "npm --prefix ./plugins/cypressRemarkPlugins run test && npm --prefix ./plugins/llm run test && npm run test:faq-plugin && npm run test:og-plugin && npm run test:video-structured-data", + "test:plugins": "npm --prefix ./plugins/cypressRemarkPlugins run test && npm --prefix ./plugins/llm run test && npm run test:faq-plugin && npm run test:fullstory-plugin && npm run test:og-plugin && npm run test:video-structured-data", "test:faq-plugin": "vitest run plugins/faq-structured-data.test.js", + "test:fullstory-plugin": "vitest run plugins/fullstory.test.js", "test:og-plugin": "vitest run plugins/og-image-cards.test.js", "test:video-structured-data": "vitest run src/components/video-youtube/structured-data.test.ts", "test:search": "vitest run src/theme/SearchBar/searchRanking.test.js scripts/search/algolia-client.test.mjs scripts/search/scrape-and-compare-algolia-index.test.mjs", diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index b85a7ad38d..5dc79ce4b1 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -38,7 +38,8 @@ npm run build:plugins # tsc for both sub-packages ## Verifying a change - `npm run test:plugins` runs the vitest suites in both sub-packages plus the - FAQ plugin test, the OG card sizing test (`og-image-cards.test.js`), and the + FAQ plugin test, the Fullstory consent gate test (`fullstory.test.js`), the + OG card sizing test (`og-image-cards.test.js`), and the video structured-data test in `src/components/video-youtube/`. - `npm run build` is the real check for `cypressRemarkPlugins`: it backs the `:::cypress-config-example`, `:::cypress-config-plugin-example`, and diff --git a/plugins/fullstory.js b/plugins/fullstory.js index 8a9a8d63eb..4f371c8eb5 100644 --- a/plugins/fullstory.js +++ b/plugins/fullstory.js @@ -11,6 +11,39 @@ const FULLSTORY_HOST = 'fullstory.com' const FULLSTORY_SCRIPT = 'edge.fullstory.com/s/fs.js' const FULLSTORY_NAMESPACE = 'FS' +// Capture follows the visitor's Osano Analytics choice, including a change +// mid-session: Osano's own script blocking only takes effect on the next page +// load, so without this, fs.js keeps recording after a visitor declines. When +// osano.js is blocked (by an ad blocker, for example) there's no consent to +// read, so capture never starts. +const consentGate = ` +(function () { + var fs = window[${JSON.stringify(FULLSTORY_NAMESPACE)}]; + // _v is only set when the snippet installed itself rather than backing off + // from another script's global of the same name. + if (!fs || !fs._v) return; + fs('init', { env: { captureOnStartup: false } }); + var cm = window.Osano && window.Osano.cm; + if (!cm || !cm.addEventListener || !cm.getConsent) return; + var state = 'waiting'; + function apply(consent) { + var granted = !!consent && consent.ANALYTICS === 'ACCEPT'; + if (granted && state === 'waiting') { + fs('start'); + state = 'capturing'; + } else if (granted && state === 'stopped') { + fs('restart'); + state = 'capturing'; + } else if (!granted && state === 'capturing') { + fs('shutdown'); + state = 'stopped'; + } + } + cm.addEventListener('osano-cm-consent-saved', apply); + apply(cm.getConsent()); +})(); +` + module.exports = async function fullStory({ context, options }) { const isProd = process.env.NODE_ENV === 'production' const orgId = process.env.FULLSTORY_ORG_ID @@ -42,8 +75,15 @@ return g._w[u].apply(this,arguments)}),g("init",{env:{orgId:o,host:e,script:t}}) }(window,document,${JSON.stringify(FULLSTORY_HOST)},${JSON.stringify(FULLSTORY_SCRIPT)},${JSON.stringify(FULLSTORY_NAMESPACE)},${JSON.stringify(orgId)}); `, }, + { + tagName: 'script', + innerHTML: consentGate, + }, ], } }, } } + +// Exported for standalone testing. +module.exports.consentGate = consentGate diff --git a/plugins/fullstory.test.js b/plugins/fullstory.test.js new file mode 100644 index 0000000000..ed00a0dda7 --- /dev/null +++ b/plugins/fullstory.test.js @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test } from 'vitest' +import fullStory from './fullstory.js' + +const { consentGate } = fullStory + +const ACCEPT = { ANALYTICS: 'ACCEPT', MARKETING: 'ACCEPT' } +const DENY = { ANALYTICS: 'DENY', MARKETING: 'ACCEPT' } + +function fakeFs() { + const calls = [] + const fs = (method, args) => { + calls.push(args === undefined ? [method] : [method, args]) + } + fs._v = '2.1.0' + return { fs, calls } +} + +function fakeOsano(initialConsent) { + let consent = initialConsent + const listeners = [] + const cm = { + getConsent: () => consent, + addEventListener: (name, listener) => { + if (name === 'osano-cm-consent-saved') listeners.push(listener) + }, + } + const save = (next) => { + consent = next + listeners.forEach((listener) => listener(next)) + } + return { Osano: { cm }, save } +} + +function runGate(window) { + new Function('window', consentGate)(window) +} + +describe('consentGate', () => { + test('delays capture on startup', () => { + const { fs, calls } = fakeFs() + runGate({ FS: fs, Osano: fakeOsano(DENY).Osano }) + expect(calls[0]).toEqual(['init', { env: { captureOnStartup: false } }]) + }) + + test('starts capture when Analytics is already accepted', () => { + const { fs, calls } = fakeFs() + runGate({ FS: fs, Osano: fakeOsano(ACCEPT).Osano }) + expect(calls.slice(1)).toEqual([['start']]) + }) + + test('waits while Analytics is denied, then starts once accepted', () => { + const { fs, calls } = fakeFs() + const osano = fakeOsano(DENY) + runGate({ FS: fs, Osano: osano.Osano }) + expect(calls.slice(1)).toEqual([]) + + osano.save(ACCEPT) + expect(calls.slice(1)).toEqual([['start']]) + }) + + test('shuts down when consent is withdrawn and restarts when it returns', () => { + const { fs, calls } = fakeFs() + const osano = fakeOsano(ACCEPT) + runGate({ FS: fs, Osano: osano.Osano }) + + osano.save(DENY) + osano.save(DENY) + osano.save(ACCEPT) + osano.save(ACCEPT) + expect(calls.slice(1)).toEqual([['start'], ['shutdown'], ['restart']]) + }) + + test('never starts capture without Osano', () => { + const { fs, calls } = fakeFs() + runGate({ FS: fs }) + expect(calls).toEqual([['init', { env: { captureOnStartup: false } }]]) + }) + + test('leaves another script’s FS global alone', () => { + const calls = [] + const otherFs = (...args) => calls.push(args) + runGate({ FS: otherFs, Osano: fakeOsano(ACCEPT).Osano }) + expect(calls).toEqual([]) + }) +}) + +describe('fullStory plugin', () => { + const originalEnv = { ...process.env } + + afterEach(() => { + process.env = { ...originalEnv } + }) + + async function headTags(env) { + process.env = { ...originalEnv, ...env } + if (env.FULLSTORY_ORG_ID === undefined) delete process.env.FULLSTORY_ORG_ID + const plugin = await fullStory({}) + return plugin.injectHtmlTags({}).headTags + } + + test('adds nothing without an org ID', async () => { + expect(await headTags({ NODE_ENV: 'production' })).toBeUndefined() + expect( + await headTags({ NODE_ENV: 'production', FULLSTORY_ORG_ID: '' }) + ).toBeUndefined() + }) + + test('adds nothing outside production', async () => { + expect( + await headTags({ NODE_ENV: 'development', FULLSTORY_ORG_ID: 'test-org' }) + ).toBeUndefined() + }) + + test('adds the snippet, then the consent gate', async () => { + const tags = await headTags({ + NODE_ENV: 'production', + FULLSTORY_ORG_ID: 'test-org', + }) + expect(tags).toHaveLength(2) + expect(tags[0].innerHTML).toContain('"FS","test-org")') + expect(tags[1].innerHTML).toBe(consentGate) + }) +})