From f84fd008c3a32e56365e050bd296edba6de7434f Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:00:23 -0700 Subject: [PATCH 01/17] refactor(rum): resolve the rates a draw would use in one place The rate a session is drawn on is the console's value falling back to init, with the application's beforeSampling given the last word. That resolution was written inline in the only branch that draws, which is fine as long as a draw is the only thing that needs to know the answer. Move it into a function that resolves and never draws, so the same question can be asked without spending a lottery ticket to find out. No behaviour changes. --- .../rum-core/src/domain/rumSessionManager.ts | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 423c3c9c27..e25bf8da37 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -387,34 +387,7 @@ function computeSessionState( // the decision it was created with: settings arriving mid-session never start or stop // collecting for a visitor already on the site. const remote = readRemoteConfig(configuration.remoteConfig) - - let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate - let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate - - // FLASHCAT FORK - the application gets the last word, right at the draw. This is what turns the - // delivered custom values into sampling decisions without a wasted first draw or a session - // restart: the console ships the data (an allow-list, a cohort rule), the application's own - // code interprets it here. Its failure modes must never reach session creation, so a thrown - // error or a value outside 0..100 leaves the incoming rate in place. - if (configuration.beforeSampling) { - try { - const override = configuration.beforeSampling({ - sessionSampleRate, - sessionReplaySampleRate, - custom: remote.custom, - }) - if (override) { - if (isRate(override.sessionSampleRate)) { - sessionSampleRate = override.sessionSampleRate - } - if (isRate(override.sessionReplaySampleRate)) { - sessionReplaySampleRate = override.sessionReplaySampleRate - } - } - } catch (e) { - display.error('beforeSampling threw an error:', e) - } - } + const { sessionSampleRate, sessionReplaySampleRate } = resolveSampleRates(configuration, remote) reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) @@ -432,6 +405,44 @@ function computeSessionState( } } +/** + * FLASHCAT FORK - the rates a draw would use right now: what the console delivered, falling back to + * what the site passed to init, with the application's `beforeSampling` given the last word. This + * is what turns the delivered custom values into sampling decisions without a wasted first draw or + * a session restart: the console ships the data (an allow-list, a cohort rule), the application's + * own code interprets it here. Its failure modes must never reach session creation, so a thrown + * error or a value outside 0..100 leaves the incoming rate in place. + * + * Resolving is all it does — it never draws on the rates it returns — so the same question can be + * asked away from a draw. + */ +function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfigValues) { + let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate + let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate + + if (configuration.beforeSampling) { + try { + const override = configuration.beforeSampling({ + sessionSampleRate, + sessionReplaySampleRate, + custom: remote.custom, + }) + if (override) { + if (isRate(override.sessionSampleRate)) { + sessionSampleRate = override.sessionSampleRate + } + if (isRate(override.sessionReplaySampleRate)) { + sessionReplaySampleRate = override.sessionReplaySampleRate + } + } + } catch (e) { + display.error('beforeSampling threw an error:', e) + } + } + + return { sessionSampleRate, sessionReplaySampleRate } +} + /** * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses From 2acc5d1f859741bba5b3a887cc7525fea7b7bf65 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:00:41 -0700 Subject: [PATCH 02/17] feat(rum): end the session when new settings decide its fate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings published from the console applied to sessions created after they arrived, and to nothing else. For a visitor who never goes idle that is hours: a session ends after fifteen minutes without activity or four hours outright, so the change everyone is waiting on reaches the people generating the most data last. Three changes cannot wait, and they are exactly the three whose effect on the running session can be told without drawing again: - a session sample rate of 0 while the visitor is being collected; - a rate of 100 while they are not; - a stricter defaultPrivacyLevel, where every further second recorded is a second of plaintext uploaded that masking cannot reach back for. Each of them ends the current session; the visitor's next action starts a new one under the new settings. Ending rather than flipping is the point: the old session is collected to its end as it was begun, so no replay is masked in one half and plain in the other, and no session is invented that starts in the middle of a visit. No other rate says anything about whether THIS session should have been kept. Only a second draw could, and drawing twice quietly turns a rate p into p², so every other change waits for the next session — a loosening privacy level included, where being slow is what leaves room to undo a mistake. It needs no bookkeeping to stay idempotent: what it compares is what the session was drawn under against what a draw would use now, and ending the session is exactly what makes that difference disappear. The same response arriving again, in another tab or after a reload, finds nothing left to act on. beforeSampling is now called outside a draw as well, to resolve the rate that would actually apply, so the documentation asks for a callback free of side effects and stable for the same input. --- .../src/domain/configuration/configuration.ts | 20 +- .../configuration/remoteConfiguration.spec.ts | 61 ++++ .../configuration/remoteConfiguration.ts | 44 ++- packages/rum-core/src/domain/lifeCycle.ts | 8 + .../src/domain/rumSessionManager.spec.ts | 297 ++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 92 +++++- 6 files changed, 505 insertions(+), 17 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index ee15cedb80..5d5a4c1025 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -62,6 +62,12 @@ export interface RumInitConfiguration extends InitConfiguration { * a single session. Keep it a pure decision: side effects will be repeated, and only the last * call's return value is used. * + * The SDK also calls it away from a draw: when new settings arrive it asks which rate would + * apply now, to decide whether the running session has to end for them to take effect. So it + * must answer the same way for the same input — one that answers differently each time can keep + * ending the session it was just asked about — and anything it does besides returning a rate (a + * metric, a log, a counter) happens more often than there are sessions. + * * Its failure modes never reach session creation: a thrown error or an out-of-range value leaves * the incoming rate in place, and a value that is not a function at all is reported once and * then ignored rather than refusing `init`. @@ -86,9 +92,17 @@ export interface RumInitConfiguration extends InitConfiguration { * Take the sampling rates from the application's settings in the console instead of only from the * values passed here, so they can be changed without releasing a new version of this site. * - * A change applies to sessions started after it arrives; a session already under way keeps the - * decision it was created with. The values below stay in use until the first settings arrive, and - * whenever the settings cannot be reached. + * A change applies to sessions started after it arrives, and a session already under way is never + * re-decided in place. Three changes do not wait for that session to end on its own, because + * their effect on it can be told without drawing again: a session sample rate of 0 while the + * visitor is being collected, a rate of 100 while they are not, and a stricter + * `defaultPrivacyLevel`. Each of those ends the current session, and the visitor's next action + * starts a new one under the new settings — the old session is collected to its end as it was + * begun, so no recording is left masked in one half and plain in the other. Every other change, + * a loosening privacy level included, waits for the next session. + * + * The values below stay in use until the first settings arrive, and whenever the settings cannot + * be reached. * * Requires `localStorage`. Sessions themselves are kept in a cookie unless `sessionPersistence` * says otherwise, but this SDK already reads one `localStorage` entry on every site — the record diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 214a097342..e81b8c4c2a 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -196,6 +196,67 @@ describe('remoteConfiguration', () => { }) }) + describe('announcing that new settings are in storage', () => { + function watchStoredNotifications() { + const notified = jasmine.createSpy('remoteConfigurationStored') + lifeCycle.subscribe(LifeCycleEventType.REMOTE_CONFIGURATION_STORED, notified) + return notified + } + + it('announces settings that reached storage, so a subscriber can act on them', (done) => { + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) + + expect(notified).toHaveBeenCalledTimes(1) + done() + }) + start(configurationWith()) + }) + + it('stays silent about settings it refused as older than the ones it holds', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 8 })) + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 0 } })) + + // Nothing changed in storage, so nothing downstream may behave as though it had. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + + it('stays silent when the answer never reached storage', (done) => { + const notified = watchStoredNotifications() + spyOn(Storage.prototype, 'setItem').and.throwError('storage is full') + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) + + // The next draw will not find these settings, so ending a session for their sake would end + // it for nothing. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + + it('stays silent about an answer that never made it', (done) => { + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(500) + + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + }) + describe('refusing a payload it cannot read', () => { const STORED = { sessionSampleRate: 42, version: 2 } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 2180dfbc8f..e883e86486 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -20,17 +20,23 @@ declare const __BUILD_ENV__SDK_VERSION__: string * masks a page by default. * * A change only affects sessions created after it arrives, so a visitor is never dropped halfway - * through and never starts being recorded halfway through. Fetching follows the same rhythm: once - * at start-up and once whenever a new session begins — a change can only matter at the next draw, - * so asking more often than sessions are drawn would be requests for nothing. There is no timer - * between sessions. + * through and never starts being recorded halfway through. What "immediately" means for the + * handful of changes that cannot wait is therefore not a flip of the running session but its end: + * see `endSessionIfSettingsAreDecisive` in the session manager, which subscribes to the event this + * module emits once new settings are in storage. + * + * Fetching follows the session's rhythm: once at start-up and once whenever a new session begins — + * a change can only matter at a draw, and every draw is a new session — so asking more often than + * sessions are drawn would be requests for nothing. There is no timer between sessions. The cost + * of that rhythm is that a visitor who never goes idle stays on one session, and so on one set of + * settings, for as long as they keep using the site. * * Three fields the server sends are accepted and ignored, deliberately: `ttl` and * `refresh_on_foreground`, which describe when to ask again and are moot without a timer, and - * `activation`, which offers to end a running session so a change applies at once. Everything here - * is next-session, so a console that ever offers "apply immediately" would not be obeyed by this - * build — named here so the mismatch is found by reading rather than by an operator wondering why - * nothing happened. + * `activation`, which offers to end a running session so a change applies at once. This build ends + * a running session on its own reading of what changed rather than on the server's say-so, so a + * console that offers "apply immediately" as a switch would not be obeyed — named here so the + * mismatch is found by reading rather than by an operator wondering why nothing happened. * * Nothing here runs unless `remoteConfigurationEnabled: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. @@ -112,6 +118,9 @@ export interface BeforeSamplingContext { * The application's last word on the sampling of the session about to be drawn — see the * `beforeSampling` init option. Returning nothing, or an out-of-range rate, leaves the incoming * value in place. + * + * Must be free of side effects and answer the same way for the same input: it is also called away + * from a draw, to work out which rate newly delivered settings would actually apply. */ export type BeforeSamplingCallback = ( context: BeforeSamplingContext @@ -277,7 +286,12 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet } if (response) { failedAttempts = 0 - store(setup, response) + if (store(setup, response)) { + // Announced only once the settings are in storage, because that is where the next draw + // reads them: a subscriber that ends the running session so the new values can take + // effect immediately has to be sure the draw that follows will find them. + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + } return } if (failedAttempts < RETRY_DELAYS.length) { @@ -387,6 +401,11 @@ function fetchRemoteConfiguration( } } +/** + * Writes the response to storage, and answers whether it actually landed there. A refused or + * unwritable response answers `false`: nothing changed for the next draw, so nothing downstream + * should act as if it had. + */ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { // Settings are published under a number that only ever goes up — rolling back republishes the // old settings under a new, higher one — so a response numbered below what is already stored is @@ -403,7 +422,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // requests that can cross are two pages, and storage is the only thing they share. const storedVersion = readRemoteConfig(setup).version if (storedVersion !== undefined && response.version < storedVersion) { - return + return false } const values: RemoteConfigValues = { version: response.version } @@ -437,10 +456,13 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // settings" looks like — so that the version is kept either way and the console can still see // that this client is up to date with the change that turned it off. localStorage.setItem(setup.storeKey, JSON.stringify(values)) + return true } catch { // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which // is the same "keep what is already working" answer a failed request gets — the client goes on - // applying the settings it last stored, and goes on reporting their version. + // applying the settings it last stored, and goes on reporting their version. Reported as a + // failure all the same: nothing downstream may act on settings the next draw will not find. + return false } } diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index b1abd3fb46..c7453faadc 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -32,6 +32,12 @@ export const enum LifeCycleEventType { // on the same domain. SESSION_EXPIRED, SESSION_RENEWED, + + // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only + // when the write actually happened, so a response refused as stale and a storage failure both + // stay silent: a subscriber acting on settings that are not in storage would act on values the + // next draw is not going to read. + REMOTE_CONFIGURATION_STORED, PAGE_MAY_EXIT, PAGE_REACTIVATED, RAW_RUM_EVENT_COLLECTED, @@ -64,6 +70,7 @@ declare const LifeCycleEventTypeAsConst: { REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED + REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT PAGE_REACTIVATED: LifeCycleEventType.PAGE_REACTIVATED RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED @@ -85,6 +92,7 @@ export interface LifeCycleEventMap { [LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent [LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void [LifeCycleEventTypeAsConst.SESSION_RENEWED]: void + [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void [LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent [LifeCycleEventTypeAsConst.PAGE_REACTIVATED]: void [LifeCycleEventTypeAsConst.RAW_RUM_EVENT_COLLECTED]: RawRumEventCollectedData diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index f122708907..87bbf29d60 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -820,6 +820,303 @@ describe('rum session manager', () => { }) }) + describe('restarting the session when the settings are decisive', () => { + const STORE_KEY = 'test-decisive-settings' + const DRAW_KEY = 'test-decisive-settings-draw' + const REMOTE_SETUP = { + buildUrl: () => 'https://example.com/config', + storeKey: STORE_KEY, + fetchTimeout: 3000, + } + + afterEach(() => localStorage.removeItem(DRAW_KEY)) + + function storeRemote(stored: object) { + localStorage.setItem(STORE_KEY, JSON.stringify(stored)) + registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) + } + + function startWith(configuration: Partial = {}) { + return startRumSessionManagerWithDefaults({ + configuration: { remoteConfig: REMOTE_SETUP, drawStoreKey: DRAW_KEY, ...configuration }, + }) + } + + // Settings reach storage first and are announced afterwards, the order the fetcher uses: the + // draw that may follow reads storage, so it has to find them already there. + function deliver(stored: object) { + storeRemote(stored) + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + } + + function isSessionEnded() { + return getSessionState(SESSION_STORE_KEY).isExpired === '1' + } + + describe('the three changes it can decide on its own', () => { + it('ends a session being collected when the rate goes to zero', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + + deliver({ version: 2, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session that is not being collected when the rate goes to a hundred', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends the session when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends the session on the tightening step that masks everything', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('draws the session that follows on the settings that have just landed', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + }) + + describe('everything else waits for the next session', () => { + it('leaves the session alone when the rate moves to a value it cannot decide on', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves a session that is not collected alone when the rate merely rises', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when the privacy level loosens', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + startWith({ sessionSampleRate: 100 }) + + // Being slow here is the point: it leaves an operator time to undo a mistake, and what it + // costs meanwhile is more of the data already being collected. + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the custom bag changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, custom: { cohort: 'a' } }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 100, custom: { cohort: 'b' } }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the trace rate changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, traceSampleRate: 10 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 100, traceSampleRate: 90 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the replay rate changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + + // The replay rate is deliberately not one of the three: it decides a draw nested inside the + // session draw, and a rule for it would have to say what happens to a replay the host + // application forced on. Until that is settled, a replay rate change waits for the next + // session like every other change. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('ignores what is in storage when the site did not opt in', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) + + deliver({ version: 2, sessionSampleRate: 100 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('has nothing to end when the session is already over', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expireCookie() + clock.tick(STORAGE_POLL_DELAY) + expireSessionSpy.calls.reset() + + // There is no session to read a decision off, and nothing to end: the next activity draws + // on what has just been stored, which is all this change needs. + expect(() => deliver({ version: 2, sessionSampleRate: 100 })).not.toThrow() + expect(expireSessionSpy).not.toHaveBeenCalled() + }) + }) + + describe('what it compares', () => { + it('never draws again to reach its decision', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + const draw = spyOn(Math, 'random').and.callThrough() + + deliver({ version: 2, sessionSampleRate: 30 }) + + // Drawing here would be a second lottery on top of the one the next session runs, quietly + // turning a rate p into p². + expect(draw).not.toHaveBeenCalled() + }) + + it('compares against the level the session was drawn under, not the settings stored since', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + // A loosening leaves the running session masking everything, as it was drawn to. + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + expect(expireSessionSpy).not.toHaveBeenCalled() + + // Stricter than what was stored a moment ago, still looser than what this session actually + // masks with. Judged against the stored settings it would end a session with nothing to + // gain from restarting. + deliver({ version: 3, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('lets beforeSampling have the last word on the rate it judges', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ + sessionSampleRate: 100, + beforeSampling: ({ sessionSampleRate }) => ({ sessionSampleRate: sessionSampleRate === 0 ? 50 : 100 }), + }) + + // The console says zero, the application puts it back in the middle: the rate that would + // actually apply is fifty, which decides nothing. + deliver({ version: 2, sessionSampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + }) + + describe('arriving more than once', () => { + it('does not end the session a second time when the same settings arrive again', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 0 }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + // Another tab, a retry, a reload: the same answer arrives again and finds the difference + // that justified ending a session already gone. + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('stops tightening the privacy level once the session is drawn under it', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expireSessionSpy.calls.reset() + + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + }) + + describe('a session the host application forced', () => { + function startForced(configuration: Partial = {}) { + const rumSessionManager = startWith({ sessionSampleRate: 0, ...configuration }) + rumSessionManager.setForcedSession() + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expireSessionSpy.calls.reset() + return rumSessionManager + } + + it('is not ended by a rate, since every draw it makes is collected anyway', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startForced() + + // Ending it would only replace it with another forced session — the same difference, for + // as long as the page lives. + deliver({ version: 2, sessionSampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('is still ended when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startForced({ defaultPrivacyLevel: 'allow' }) + + // Forcing decides whether this visitor is collected. It says nothing about how much of + // their page may be uploaded in the clear. + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + }) + }) + function startRumSessionManagerWithDefaults({ configuration, trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e25bf8da37..ff71bdc22c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -1,6 +1,7 @@ -import type { DefaultPrivacyLevel, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' +import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { BridgeCapability, + DefaultPrivacyLevel, Observable, SESSION_TIME_OUT_DELAY, STORAGE_POLL_DELAY, @@ -206,6 +207,78 @@ export function startRumSessionManager( lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) + // FLASHCAT FORK - a change published mid-session normally waits for that session to end on its + // own, which for a visitor who never goes idle is hours away. Three changes cannot afford the + // wait, and what makes exactly those three special is that their outcome for the running session + // can be asserted without drawing again: + // + // - a session sample rate of 0 while this session is being collected: nothing is meant to be + // collected any more, and this is the emergency stop the console offers; + // - a session sample rate of 100 while this session is not: everything is meant to be + // collected, and this visitor is the exception; + // - a stricter default privacy level: every further second recorded is a second of plaintext + // uploaded, and masking cannot reach back for it. + // + // No other rate says anything about whether THIS session should have been kept — only a second + // draw could, and drawing twice silently turns a rate p into p². So everything else waits for + // the next session, a loosening privacy level included. Loosening waits on purpose: the delay + // is what leaves an operator room to undo a mistake, and what it costs meanwhile is more of the + // data already being collected. + // + // The action is always to end the session and let the next activity start a new one — never to + // flip the running one, which would leave a replay masked in its first half and plain in its + // second, or invent a session that begins in the middle of a visit. + // + // It stays idempotent with no bookkeeping at all: it compares what this session was drawn under + // against what a draw would use now, and ending the session is exactly what makes that + // difference disappear. The same response arriving again — another tab, a retry, a reload — + // finds nothing left to act on. + function endSessionIfSettingsAreDecisive() { + if (!configuration.remoteConfig) { + return + } + const session = sessionManager.findSession() + if (!session) { + // Nothing to end. Whatever starts the next session draws on the settings just stored, which + // is the ordinary path and already gives them their effect. + return + } + + const remote = readRemoteConfig(configuration.remoteConfig) + + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // session. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return + } + + if (forcedSession) { + // The host application has taken this page off the rates deliberately, and every draw it + // makes from now on is collected whatever the console says. Ending the session on a rate + // would only replace it with another forced one — the same difference, forever. + return + } + + // Whether this session is collected is read off the session itself rather than reconstructed + // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only + // thing 0 and 100 let us assert anything about. + const isCollected = isTypeTracked(session.trackingType) + const { sessionSampleRate } = resolveSampleRates(configuration, remote) + if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { + sessionManager.expire() + } + } + + const remoteConfigSubscription = lifeCycle.subscribe( + LifeCycleEventType.REMOTE_CONFIGURATION_STORED, + endSessionIfSettingsAreDecisive + ) + sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { if (!previousState.forcedReplay && newState.forcedReplay) { const sessionEntity = sessionManager.findSession() @@ -238,6 +311,7 @@ export function startRumSessionManager( expireObservable: sessionManager.expireObservable, stop: () => { consentSubscription.unsubscribe() + remoteConfigSubscription.unsubscribe() drawnHistory.stop() }, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), @@ -413,8 +487,9 @@ function computeSessionState( * own code interprets it here. Its failure modes must never reach session creation, so a thrown * error or a value outside 0..100 leaves the incoming rate in place. * - * Resolving is all it does — it never draws on the rates it returns — so the same question can be - * asked away from a draw. + * It resolves rates and never draws on them, which is what lets the same question be asked away + * from a draw — see `endSessionIfSettingsAreDecisive`, which needs to know which rate would apply + * without spending a lottery ticket to find out. */ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfigValues) { let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate @@ -443,6 +518,17 @@ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfi return { sessionSampleRate, sessionReplaySampleRate } } +/** + * FLASHCAT FORK - how much of a page each level keeps out of a recording, ordered so two levels can + * be compared. Only the direction matters: tightening is the change that cannot be undone after the + * fact, because a second already recorded in the clear has already been uploaded in the clear. + */ +const PRIVACY_LEVEL_STRICTNESS: { [level in DefaultPrivacyLevel]: number } = { + [DefaultPrivacyLevel.ALLOW]: 0, + [DefaultPrivacyLevel.MASK_USER_INPUT]: 1, + [DefaultPrivacyLevel.MASK]: 2, +} + /** * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses From f6d9b5c893fa5575e9b1bdd8bbcf5fce7f3b3090 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:04:01 -0700 Subject: [PATCH 03/17] refactor(rum): keep the fork's lifecycle event out of upstream's numbering A const enum's values are inlined at build time and every entry after an insertion shifts, so an entry wedged into the middle of a list that is otherwise upstream's is both a renumbering and a conflict on the next upstream merge. Move it to the end. Also drop a guard that restated its caller's precondition: the event is only ever emitted by the fetcher, which does not exist unless the site opted in, and reading the settings already answers with nothing when it did not. --- packages/rum-core/src/domain/lifeCycle.ts | 19 ++++++++++++------- .../src/domain/rumSessionManager.spec.ts | 5 ++++- .../rum-core/src/domain/rumSessionManager.ts | 3 --- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index c7453faadc..b185daa394 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -32,17 +32,22 @@ export const enum LifeCycleEventType { // on the same domain. SESSION_EXPIRED, SESSION_RENEWED, + PAGE_MAY_EXIT, + PAGE_REACTIVATED, + RAW_RUM_EVENT_COLLECTED, + RUM_EVENT_COLLECTED, + RAW_ERROR_COLLECTED, // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only // when the write actually happened, so a response refused as stale and a storage failure both // stay silent: a subscriber acting on settings that are not in storage would act on values the // next draw is not going to read. + // + // Added last on purpose. The values of a const enum are inlined at build time and shift when an + // entry is inserted, and everything above this line is upstream's — keeping the fork's own entry + // at the end leaves upstream's numbering alone and keeps this file out of the way of the next + // upstream merge. REMOTE_CONFIGURATION_STORED, - PAGE_MAY_EXIT, - PAGE_REACTIVATED, - RAW_RUM_EVENT_COLLECTED, - RUM_EVENT_COLLECTED, - RAW_ERROR_COLLECTED, } // This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript @@ -70,12 +75,12 @@ declare const LifeCycleEventTypeAsConst: { REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED - REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT PAGE_REACTIVATED: LifeCycleEventType.PAGE_REACTIVATED RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED + REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED } // Note: this interface needs to be exported even if it is not used outside of this module, else TS @@ -92,7 +97,6 @@ export interface LifeCycleEventMap { [LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent [LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void [LifeCycleEventTypeAsConst.SESSION_RENEWED]: void - [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void [LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent [LifeCycleEventTypeAsConst.PAGE_REACTIVATED]: void [LifeCycleEventTypeAsConst.RAW_RUM_EVENT_COLLECTED]: RawRumEventCollectedData @@ -101,6 +105,7 @@ export interface LifeCycleEventMap { error: RawError customerContext?: Context } + [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void } export interface RawRumEventCollectedData { diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 87bbf29d60..1417bfc34c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -971,10 +971,13 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('ignores what is in storage when the site did not opt in', () => { + it('reads nothing out of the settings store when the site did not opt in', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) + // Such a site never fetches, so this can only ever be reached by hand. What matters is that + // the settings store is out of reach without the opt-in: the rate that would apply is the + // one init passed, which is the one this session was already drawn on. deliver({ version: 2, sessionSampleRate: 100 }) expect(expireSessionSpy).not.toHaveBeenCalled() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index ff71bdc22c..a25813f526 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -234,9 +234,6 @@ export function startRumSessionManager( // difference disappear. The same response arriving again — another tab, a retry, a reload — // finds nothing left to act on. function endSessionIfSettingsAreDecisive() { - if (!configuration.remoteConfig) { - return - } const session = sessionManager.findSession() if (!session) { // Nothing to end. Whatever starts the next session draws on the settings just stored, which From c370e928a61694599a25fbab48850cc914dacff9 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:03:03 -0700 Subject: [PATCH 04/17] fix(rum): stop ending the sessions of visitors who are not collected A session that is not being collected is given no id, so no record of its draw is kept and the privacy level it was drawn under cannot be read back. The comparison fell through to the init value on every announcement and kept answering "tighter", so once an operator tightened `defaultPrivacyLevel` from the console, every sampled-out visitor was put on a loop: end the session, renew on the next click, refetch, end it again. It bought no privacy either -- a visitor who is not collected records nothing, so a stricter level has no plaintext to catch there. The rule now carries its own precondition and applies only while the session is being collected, which is also the only state in which a recording exists. Its fuel was the announcement firing on settings that had not changed: `store()` answered "stored" for a response repeating the version already held, which is the ordinary answer, since every new session refetches and most find nothing new. It now answers whether the stored version actually advanced. Three tests, each checked against the unfixed source first: a sampled-out session is left alone when the level tightens, it is still left alone as further settings arrive, and a response repeating the stored version is not announced. --- .../src/domain/configuration/configuration.ts | 16 ++++--- .../configuration/remoteConfiguration.spec.ts | 16 +++++++ .../configuration/remoteConfiguration.ts | 22 +++++++--- packages/rum-core/src/domain/lifeCycle.ts | 9 ++-- .../src/domain/rumSessionManager.spec.ts | 29 +++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 43 ++++++++++++------- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 5d5a4c1025..62cc56db14 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -64,9 +64,9 @@ export interface RumInitConfiguration extends InitConfiguration { * * The SDK also calls it away from a draw: when new settings arrive it asks which rate would * apply now, to decide whether the running session has to end for them to take effect. So it - * must answer the same way for the same input — one that answers differently each time can keep - * ending the session it was just asked about — and anything it does besides returning a rate (a - * metric, a log, a counter) happens more often than there are sessions. + * must answer the same way for the same input — one that answers differently each time can end a + * session that a steady one would have left running — and anything it does besides returning a + * rate (a metric, a log, a counter) happens more often than there are sessions. * * Its failure modes never reach session creation: a thrown error or an out-of-range value leaves * the incoming rate in place, and a value that is not a function at all is reported once and @@ -96,10 +96,12 @@ export interface RumInitConfiguration extends InitConfiguration { * re-decided in place. Three changes do not wait for that session to end on its own, because * their effect on it can be told without drawing again: a session sample rate of 0 while the * visitor is being collected, a rate of 100 while they are not, and a stricter - * `defaultPrivacyLevel`. Each of those ends the current session, and the visitor's next action - * starts a new one under the new settings — the old session is collected to its end as it was - * begun, so no recording is left masked in one half and plain in the other. Every other change, - * a loosening privacy level included, waits for the next session. + * `defaultPrivacyLevel` while they are being collected — a visitor who is not being collected + * records nothing, so a stricter level has no plaintext to catch there. Each of those ends the + * current session, and the visitor's next action starts a new one under the new settings — the + * old session is collected to its end as it was begun, so no recording is left masked in one + * half and plain in the other. Every other change, a loosening privacy level included, waits for + * the next session. * * The values below stay in use until the first settings arrive, and whenever the settings cannot * be reached. diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index e81b8c4c2a..7ba6456c20 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -229,6 +229,22 @@ describe('remoteConfiguration', () => { start(configurationWith()) }) + it('stays silent about an answer that repeats the settings it already holds', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 7 })) + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 42 } })) + + // The ordinary answer: every new session asks again and most find nothing changed. A + // subscriber woken by those would act on no news, once per session, for as long as the + // visitor stays. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + it('stays silent when the answer never reached storage', (done) => { const notified = watchStoredNotifications() spyOn(Storage.prototype, 'setItem').and.throwError('storage is full') diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index e883e86486..190e6df96f 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -287,9 +287,10 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet if (response) { failedAttempts = 0 if (store(setup, response)) { - // Announced only once the settings are in storage, because that is where the next draw + // Announced only once new settings are in storage, because that is where the next draw // reads them: a subscriber that ends the running session so the new values can take - // effect immediately has to be sure the draw that follows will find them. + // effect immediately has to be sure the draw that follows will find them, and must not + // be woken by an answer that changed nothing. lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) } return @@ -402,9 +403,10 @@ function fetchRemoteConfiguration( } /** - * Writes the response to storage, and answers whether it actually landed there. A refused or - * unwritable response answers `false`: nothing changed for the next draw, so nothing downstream - * should act as if it had. + * Writes the response to storage, and answers whether it brought settings this client did not + * already hold. A refused or unwritable response answers `false`, and so does one that repeats the + * version already stored: settings only ever change under a higher number, so by that contract a + * repeat leaves the next draw reading what it would have read anyway. */ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { // Settings are published under a number that only ever goes up — rolling back republishes the @@ -425,6 +427,14 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) return false } + // Settings only ever change under a higher number, so a response repeating the number already + // stored carries nothing new — and that is the ordinary answer, since every new session refetches + // and most of them find the settings unchanged. It is written anyway, which costs one small + // `setItem` and keeps the entry in the shape this build writes, but it is not announced: a + // subscriber that ends the running session must hear about changes only, or an unchanged answer + // arriving at every renewal would end a session per renewal, forever. + const isNew = storedVersion === undefined || response.version > storedVersion + const values: RemoteConfigValues = { version: response.version } if (response.enabled && response.rum) { // Each value is copied only when the server actually sent it. A knob nobody configured must @@ -456,7 +466,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // settings" looks like — so that the version is kept either way and the console can still see // that this client is up to date with the change that turned it off. localStorage.setItem(setup.storeKey, JSON.stringify(values)) - return true + return isNew } catch { // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which // is the same "keep what is already working" answer a failed request gets — the client goes on diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index b185daa394..78b6d9fccb 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -38,10 +38,11 @@ export const enum LifeCycleEventType { RUM_EVENT_COLLECTED, RAW_ERROR_COLLECTED, - // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only - // when the write actually happened, so a response refused as stale and a storage failure both - // stay silent: a subscriber acting on settings that are not in storage would act on values the - // next draw is not going to read. + // FLASHCAT FORK - a remote configuration response has just changed what is in storage. Emitted + // only when the write actually happened and actually changed something, so a response refused as + // stale, one that merely repeats the settings already held, and a storage failure all stay + // silent: a subscriber acting on settings the next draw would have read anyway would be acting + // on no news at all. // // Added last on purpose. The values of a const enum are inlined at build time and shift when an // entry is inserted, and everything above this line is upstream's — keeping the fork's own entry diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 1417bfc34c..261906ed64 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -925,6 +925,35 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('leaves a session that is not being collected alone when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Nothing is being recorded for this visitor, so there is no plaintext for the stricter + // level to catch and nothing to gain by ending their session. + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('does not end one sampled-out session after another as settings keep arriving', () => { + // A session that is not collected is given no id, so no record of its draw is kept and the + // level it was drawn under cannot be read back. Ending it would not change that, so acting + // on the comparison would end every session this visitor is ever given. + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + deliver({ version: 3, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('leaves the session alone when the privacy level loosens', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) startWith({ sessionSampleRate: 100 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index a25813f526..af55201dbe 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -216,8 +216,8 @@ export function startRumSessionManager( // collected any more, and this is the emergency stop the console offers; // - a session sample rate of 100 while this session is not: everything is meant to be // collected, and this visitor is the exception; - // - a stricter default privacy level: every further second recorded is a second of plaintext - // uploaded, and masking cannot reach back for it. + // - a stricter default privacy level while this session is being collected: every further + // second recorded is a second of plaintext uploaded, and masking cannot reach back for it. // // No other rate says anything about whether THIS session should have been kept — only a second // draw could, and drawing twice silently turns a rate p into p². So everything else waits for @@ -243,28 +243,39 @@ export function startRumSessionManager( const remote = readRemoteConfig(configuration.remoteConfig) - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under whatever - // was stored before that. No record means the draw used the init value, and so does the - // session. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { - sessionManager.expire() - return + // Whether this session is collected is read off the session itself rather than reconstructed + // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only + // thing 0 and 100 let us assert anything about. + const isCollected = isTypeTracked(session.trackingType) + + // Only a session that is being collected can be recording, and only a recording can be too + // plain. A sampled-out visitor uploads nothing, so a stricter level has nothing to protect + // there — and nothing to compare against either: a session that is not collected is given no + // id, so no draw is recorded for it and what it was drawn under cannot be read back here. The + // comparison would fall through to the init value on every announcement and keep answering + // "tighter", ending one empty session after another for as long as the visitor stays. + if (isCollected) { + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under + // whatever was stored before that. No record means the draw used the init value, and so does + // the recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return + } } if (forcedSession) { // The host application has taken this page off the rates deliberately, and every draw it // makes from now on is collected whatever the console says. Ending the session on a rate - // would only replace it with another forced one — the same difference, forever. + // would only replace it with another forced one — the same difference, forever. The flag is + // this page's: another tab of the same visitor that never called `setForcedSession` reads + // the shared session as an ordinary one and may end it on a rate. return } - // Whether this session is collected is read off the session itself rather than reconstructed - // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only - // thing 0 and 100 let us assert anything about. - const isCollected = isTypeTracked(session.trackingType) const { sessionSampleRate } = resolveSampleRates(configuration, remote) if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { sessionManager.expire() From 738ef961c7ac5956020bd168ddcb93e28fd49c12 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:13:31 -0700 Subject: [PATCH 05/17] test(rum): cover the decision paths nothing was holding Three paths the implementation documents had no test standing on them, each found by mutating the source and watching the suite stay green: - A session drawn before any settings arrived. A draw that lands exactly on the init values records nothing, so the level such a session runs under can only be read back off init -- the fallback every existing privacy test stepped around by storing settings before starting. Deleting that fallback passed the whole suite. - A response that carries no rate at all, with `beforeSampling` turning the delivered custom values into the decision. This is the "called away from a draw" contract, and both resolving the rate without the callback and bailing out when the console sends no rate passed the whole suite. - The console's kill switch, which stores a version and nothing else and so puts the rates back to the ones init passed. That is a change like any other, and where init never collected it is the decisive one. Also renames the opt-out test to what it actually pins down. Its store key is one no implementation could derive, so it cannot witness the store being left alone; what it does witness is the decision surviving an undefined `remoteConfig`. --- .../src/domain/rumSessionManager.spec.ts | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 261906ed64..3ba8dfa368 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -892,6 +892,47 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) + it('ends a session drawn before any settings arrived when the first ones tighten the level', () => { + // Nothing in storage yet, so this session was drawn on the init values — and a draw that + // lands exactly on them records nothing, which is why the level it runs under can only be + // read back off init. The recorder falls back the same way, so this is the level the page + // is really being masked with. + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a collected session when the callback turns the delivered values into a zero', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ + sessionSampleRate: 100, + beforeSampling: ({ custom }) => (custom?.optOut === true ? { sessionSampleRate: 0 } : undefined), + }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + // The response carries no rate at all: the console ships the data and the application's own + // code turns it into the decision. Asking the callback away from a draw is the whole reason + // that decision can reach the session already running. + deliver({ version: 2, custom: { optOut: true } }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a collected session when the settings are switched off and init never collected', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + // Turning remote configuration off in the console stores the version and nothing else, so + // the rates go back to the ones the site passed to init. That is a change like any other, + // and here it is the decisive one. + deliver({ version: 2 }) + + expect(isSessionEnded()).toBeTrue() + }) + it('draws the session that follows on the settings that have just landed', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startWith({ sessionSampleRate: 0 }) @@ -1000,13 +1041,14 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('reads nothing out of the settings store when the site did not opt in', () => { + it('does not fall over when the site never opted in and has no settings store', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) - // Such a site never fetches, so this can only ever be reached by hand. What matters is that - // the settings store is out of reach without the opt-in: the rate that would apply is the - // one init passed, which is the one this session was already drawn on. + // Such a site never fetches, so the announcement can only ever be reached by hand and the + // store key below is one nothing would look under. All this pins down is that the decision + // survives `remoteConfig` being undefined; that the opt-out is respected is settled where + // the fetcher is never started, not here. deliver({ version: 2, sessionSampleRate: 100 }) expect(expireSessionSpy).not.toHaveBeenCalled() From 20fbc0b47bcd0693e0f04cf6f4bffc95780f2079 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:21:30 -0700 Subject: [PATCH 06/17] docs(changelog): say what a decisive publish does to a running visit --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4999e4a1d3..aded3bd2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,42 @@ --- +## Unreleased + +- ✨ Three changes published from the console now end the running session, so they reach the + visitor at their next interaction instead of waiting for that session to end on its own: a + session sample rate of 0 while the visitor is being collected — the emergency stop — a rate of + 100 while they are not, and a stricter Session Replay privacy level while they are being + collected. The session that ends is collected to its end as it began, so no recording is left + masked in one half and plain in the other. Every other change — any rate between 0 and 100, a + loosening privacy level, the replay and trace rates, the custom values — still waits for the next + session. Nothing here happens without `remoteConfigurationEnabled: true`. +- 📝 What you will see on the day you publish one of those three: session counts rise and average + session length drops, because each affected visitor's running session is split at that moment; a + replay in progress ends at the split and a new one starts under the new settings; a rate of 100 + makes previously invisible visitors appear within hours rather than the next day, so collected + volume climbs the same day. That is the change taking effect, not a defect. +- 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load + and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next + session boundary — at most four hours away, the cap on a session's life. Opening a tab or + reloading any page fetches immediately and ends the session every tab shares, which is why a + visitor who touches the site converges in seconds. A change that is not one of the three still + takes effect one session after that. +- 📝 The three act on what actually changed, not on the activation mode recorded with the publish: + a change the console files as "next session" still ends the running session if it is one of them. +- 📝 `beforeSampling` is now also consulted when settings arrive, away from any draw, to work out + which rate would apply. It must stay free of side effects and answer the same way for the same + input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session + that a steady answer would have left running. +- 📝 A session forced with `setForcedSession()` is never ended by a rate: forcing decides whether + this visitor is collected, and every draw it makes is collected whatever the console says. A + stricter privacy level still ends it, because forcing says nothing about how much of the page may + be uploaded in the clear. The page forces the next session on its own, so the visit continues as + two sessions. +- 📝 Turning remote configuration off is itself a change: the rates go back to the ones passed to + `init`. On a site whose init rate is 0, switching it off stops collection at once rather than at + the next session. + ## v0.2.0 - 💥 **Breaking**: `remoteConfigurationId` is gone from `RumInitConfiguration`. It fetched a From 9c76b2062744f5d4f54d4e03e43101ea388a0e89 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:34:06 -0700 Subject: [PATCH 07/17] fix(rum): let a rate of a hundred through to a forced page that collects nothing The exemption that keeps a rate from ending a forced session was written for the case where ending it changes nothing: the page collects this visitor whatever the console says, so the replacement session would be the same session again. That reasoning runs out when the session is not collected. A page can adopt one drawn by a tab that never forced anything, and there a rate of 100 has something to change -- it is exactly the draw the page asked for. The guard now carries the precondition its own reasoning rests on. Also corrects two claims in the changelog entry that the code does not make good on: custom values do not always wait for the next session, since `beforeSampling` can turn them into a decisive rate -- the flagship pattern for this feature, and something the suite already pins down -- and the session after a split carries a new recording only if its draw keeps one. --- CHANGELOG.md | 18 +++++++++------ .../src/domain/rumSessionManager.spec.ts | 22 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 12 +++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aded3bd2ec..b97349e43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,13 +26,16 @@ 100 while they are not, and a stricter Session Replay privacy level while they are being collected. The session that ends is collected to its end as it began, so no recording is left masked in one half and plain in the other. Every other change — any rate between 0 and 100, a - loosening privacy level, the replay and trace rates, the custom values — still waits for the next - session. Nothing here happens without `remoteConfigurationEnabled: true`. + loosening privacy level, the replay and trace rates — still waits for the next session. Custom + values wait on their own too, but not once `beforeSampling` turns them into one of the three: a + callback answering 0 for the values just published ends the session exactly as a published 0 + would. Nothing here happens without `remoteConfigurationEnabled: true`. - 📝 What you will see on the day you publish one of those three: session counts rise and average session length drops, because each affected visitor's running session is split at that moment; a - replay in progress ends at the split and a new one starts under the new settings; a rate of 100 - makes previously invisible visitors appear within hours rather than the next day, so collected - volume climbs the same day. That is the change taking effect, not a defect. + replay in progress ends at the split, and the session that follows draws again, so it carries a + new recording only if that draw keeps one; a rate of 100 makes previously invisible visitors + appear within hours rather than the next day, so collected volume climbs the same day. That is + the change taking effect, not a defect. - 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next session boundary — at most four hours away, the cap on a session's life. Opening a tab or @@ -45,8 +48,9 @@ which rate would apply. It must stay free of side effects and answer the same way for the same input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session that a steady answer would have left running. -- 📝 A session forced with `setForcedSession()` is never ended by a rate: forcing decides whether - this visitor is collected, and every draw it makes is collected whatever the console says. A +- 📝 A session forced with `setForcedSession()` is not ended by a rate while it is being collected: + forcing decides whether this visitor is collected, and every draw the page makes is collected + whatever the console says, so ending it would only produce the same session again. A stricter privacy level still ends it, because forcing says nothing about how much of the page may be uploaded in the clear. The page forces the next session on its own, so the visit continues as two sessions. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 3ba8dfa368..cfa6debf94 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1166,6 +1166,28 @@ describe('rum session manager', () => { return rumSessionManager } + it('is still ended by a rate of a hundred when the session it adopted collects nothing', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + const rumSessionManager = startWith({ sessionSampleRate: 0 }) + + // Forcing ends a session that collects nothing, so that the next draw can be the forced + // one. Before that draw happens, a tab that never forced anything starts a session of its + // own, and this page adopts it: the page is forced while the session it holds is not. + rumSessionManager.setForcedSession() + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + // Here the rate has something to change, so the exemption does not apply: ending the + // session is what lets the next draw be the forced one this page asked for. + deliver({ version: 2, sessionSampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + it('is not ended by a rate, since every draw it makes is collected anyway', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startForced() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index af55201dbe..dc82b415c9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -267,12 +267,14 @@ export function startRumSessionManager( } } - if (forcedSession) { + if (forcedSession && isCollected) { // The host application has taken this page off the rates deliberately, and every draw it - // makes from now on is collected whatever the console says. Ending the session on a rate - // would only replace it with another forced one — the same difference, forever. The flag is - // this page's: another tab of the same visitor that never called `setForcedSession` reads - // the shared session as an ordinary one and may end it on a rate. + // makes from now on is collected whatever the console says. Ending a collected session on a + // rate would only replace it with another collected one — the same difference, forever. That + // reasoning runs out when the session is not collected: this page can adopt one an unforced + // tab drew, and there a rate of 100 has something to change, so it is left to the rule + // below. The flag is this page's either way — another tab that never called + // `setForcedSession` reads the shared session as an ordinary one. return } From 9be78e77fb24d5c50b2bd76d88e46a8918050d18 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 23:51:53 -0700 Subject: [PATCH 08/17] refactor(rum): stop acting on a rate that rises to a hundred Of the three changes that did not wait for the running session to end, this was the one with the weakest claim to a place. `setForcedSession()` already exists for "collect this visitor now" and is precise where a global rate is blunt; it was the only one of the three that raises volume, and does so the same day nobody asked for it; and nothing about "let us see more" is urgent enough that waiting for the next session costs anything that cannot be had later. The other two both undo something that cannot be undone later -- a second of plaintext already uploaded, an event already ingested. It was also what made the remaining rules hard to state. Both survivors are about a session that is being collected, so that precondition rises to the top of the function: the nesting around the privacy comparison goes, the rate check loses its conjunction, and the guard for a forced session goes back to being simply true -- ending a collected forced session on a rate really would only produce the same session again. The rule is nineteen lines with no nesting. The motivation is corrected everywhere it was stated, in the option's own documentation and in the changelog. It said this was for visitors who never go idle. It is not: settings are fetched at page load and at each new session and never on a timer, so a single tab that is never reloaded hears nothing until the four-hour cap -- an always-on screen is the case this does least for. What it actually changes is the ordinary visit, where the client downloads the new settings on the next page load and, until now, went on under the old decision for the rest of that visit. --- CHANGELOG.md | 60 +++++----- .../src/domain/configuration/configuration.ts | 22 ++-- .../src/domain/rumSessionManager.spec.ts | 56 ++++----- .../rum-core/src/domain/rumSessionManager.ts | 107 +++++++++--------- 4 files changed, 116 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97349e43d..a224436f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,40 +20,36 @@ ## Unreleased -- ✨ Three changes published from the console now end the running session, so they reach the - visitor at their next interaction instead of waiting for that session to end on its own: a - session sample rate of 0 while the visitor is being collected — the emergency stop — a rate of - 100 while they are not, and a stricter Session Replay privacy level while they are being - collected. The session that ends is collected to its end as it began, so no recording is left - masked in one half and plain in the other. Every other change — any rate between 0 and 100, a - loosening privacy level, the replay and trace rates — still waits for the next session. Custom - values wait on their own too, but not once `beforeSampling` turns them into one of the three: a - callback answering 0 for the values just published ends the session exactly as a published 0 - would. Nothing here happens without `remoteConfigurationEnabled: true`. -- 📝 What you will see on the day you publish one of those three: session counts rise and average - session length drops, because each affected visitor's running session is split at that moment; a - replay in progress ends at the split, and the session that follows draws again, so it carries a - new recording only if that draw keeps one; a rate of 100 makes previously invisible visitors - appear within hours rather than the next day, so collected volume climbs the same day. That is - the change taking effect, not a defect. -- 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load - and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next - session boundary — at most four hours away, the cap on a session's life. Opening a tab or - reloading any page fetches immediately and ends the session every tab shares, which is why a - visitor who touches the site converges in seconds. A change that is not one of the three still - takes effect one session after that. -- 📝 The three act on what actually changed, not on the activation mode recorded with the publish: - a change the console files as "next session" still ends the running session if it is one of them. +- ✨ Two changes published from the console now end the running session, so they reach the visitor + at their next interaction instead of waiting for that session to end on its own: a stricter + Session Replay privacy level, and a session sample rate of 0 — the emergency stop, which took up + to four hours to stop anything before this. Both apply only while the visitor is being collected; + one who is not records nothing and sends nothing, so neither change has anything to act on there. + The session that ends is collected to its end as it began, so no recording is left masked in one + half and plain in the other. Every other change still waits for the next session, including a + loosening privacy level and a rate rising to 100 — for "collect this visitor now" there is + `setForcedSession()`. Custom values wait on their own too, but not once `beforeSampling` turns + them into a rate of 0. Nothing here happens without `remoteConfigurationEnabled: true`. +- 📝 How soon "does not wait" is depends on when this client next hears of the change, and it hears + only at page load and at each new session — there is no timer. A visitor who keeps loading pages + hears within seconds of the publish and their session ends there. A single tab that is never + reloaded hears nothing until its session reaches the four-hour cap, so an always-on screen is the + case this does least for; any other tab the same visitor loads ends the session they share. +- 📝 What you will see on the day you publish one of the two: session counts rise and average + session length drops, because each affected visitor's running session is split at that moment, + and a replay in progress ends at the split — the session that follows draws again, so it carries + a new recording only if that draw keeps one. That is the change taking effect, not a defect. +- 📝 The two act on what actually changed, not on the activation mode recorded with the publish: a + change the console files as "next session" still ends the running session if it is one of them. - 📝 `beforeSampling` is now also consulted when settings arrive, away from any draw, to work out which rate would apply. It must stay free of side effects and answer the same way for the same - input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session - that a steady answer would have left running. -- 📝 A session forced with `setForcedSession()` is not ended by a rate while it is being collected: - forcing decides whether this visitor is collected, and every draw the page makes is collected - whatever the console says, so ending it would only produce the same session again. A - stricter privacy level still ends it, because forcing says nothing about how much of the page may - be uploaded in the clear. The page forces the next session on its own, so the visit continues as - two sessions. + input: a callback that draws its own lottery — answering 0 at random — can end a session that a + steady answer would have left running. +- 📝 A session forced with `setForcedSession()` is not ended by a rate: forcing decides whether this + visitor is collected, and every draw the page makes is collected whatever the console says, so + ending it would only produce the same session again. A stricter privacy level still ends it, + because forcing says nothing about how much of the page may be uploaded in the clear. The page + forces the next session on its own, so the visit continues as two sessions. - 📝 Turning remote configuration off is itself a change: the rates go back to the ones passed to `init`. On a site whose init rate is 0, switching it off stops collection at once rather than at the next session. diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 62cc56db14..9ea4ca9ea7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -93,15 +93,19 @@ export interface RumInitConfiguration extends InitConfiguration { * values passed here, so they can be changed without releasing a new version of this site. * * A change applies to sessions started after it arrives, and a session already under way is never - * re-decided in place. Three changes do not wait for that session to end on its own, because - * their effect on it can be told without drawing again: a session sample rate of 0 while the - * visitor is being collected, a rate of 100 while they are not, and a stricter - * `defaultPrivacyLevel` while they are being collected — a visitor who is not being collected - * records nothing, so a stricter level has no plaintext to catch there. Each of those ends the - * current session, and the visitor's next action starts a new one under the new settings — the - * old session is collected to its end as it was begun, so no recording is left masked in one - * half and plain in the other. Every other change, a loosening privacy level included, waits for - * the next session. + * re-decided in place. Two changes do not wait for that session to end on its own, because their + * effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a session + * sample rate of 0. Both apply only while the visitor is being collected — one who is not records + * nothing and sends nothing, so neither has anything to act on there. Either ends the current + * session, and the visitor's next action starts a new one under the new settings; the old session + * is collected to its end as it was begun, so no recording is left masked in one half and plain + * in the other. Every other change waits for the next session, a loosening privacy level and a + * rate rising to 100 included — for "collect this visitor now" there is `setForcedSession()`. + * + * How soon "does not wait" is depends on when this client next hears of the change, and it hears + * only at page load and at each new session. A visitor who keeps loading pages hears within + * seconds; a single tab that is never reloaded hears nothing until its session reaches the + * four-hour cap. * * The values below stay in use until the first settings arrive, and whenever the settings cannot * be reached. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index cfa6debf94..40878e2db0 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -853,7 +853,7 @@ describe('rum session manager', () => { return getSessionState(SESSION_STORE_KEY).isExpired === '1' } - describe('the three changes it can decide on its own', () => { + describe('the two changes it can decide on its own', () => { it('ends a session being collected when the rate goes to zero', () => { storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -864,16 +864,6 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) - it('ends a session that is not being collected when the rate goes to a hundred', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) - - expect(isSessionEnded()).toBeTrue() - }) - it('ends the session when the privacy level tightens', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) @@ -934,14 +924,14 @@ describe('rum session manager', () => { }) it('draws the session that follows on the settings that have just landed', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + deliver({ version: 2, sessionSampleRate: 0 }) clock.tick(STORAGE_POLL_DELAY) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) }) }) @@ -956,6 +946,20 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('leaves a session that is not being collected alone when the rate goes to a hundred', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // The one rate whose outcome could be asserted and deliberately is not: `setForcedSession` + // already covers "collect this visitor now", raising volume unannounced is the one + // direction that surprises, and nothing about it is urgent. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('leaves a session that is not collected alone when the rate merely rises', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startWith({ sessionSampleRate: 0 }) @@ -1166,28 +1170,6 @@ describe('rum session manager', () => { return rumSessionManager } - it('is still ended by a rate of a hundred when the session it adopted collects nothing', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) - const rumSessionManager = startWith({ sessionSampleRate: 0 }) - - // Forcing ends a session that collects nothing, so that the next draw can be the forced - // one. Before that draw happens, a tab that never forced anything starts a session of its - // own, and this page adopts it: the page is forced while the session it holds is not. - rumSessionManager.setForcedSession() - setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) - clock.tick(STORAGE_POLL_DELAY) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - expireSessionSpy.calls.reset() - - // Here the rate has something to change, so the exemption does not apply: ending the - // session is what lets the next draw be the forced one this page asked for. - deliver({ version: 2, sessionSampleRate: 100 }) - - expect(isSessionEnded()).toBeTrue() - }) - it('is not ended by a rate, since every draw it makes is collected anyway', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startForced() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index dc82b415c9..9e742043ee 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -207,79 +207,84 @@ export function startRumSessionManager( lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) - // FLASHCAT FORK - a change published mid-session normally waits for that session to end on its - // own, which for a visitor who never goes idle is hours away. Three changes cannot afford the - // wait, and what makes exactly those three special is that their outcome for the running session - // can be asserted without drawing again: + // FLASHCAT FORK - by the time this runs the client has already downloaded the new settings and + // filed them away, and without this it would then do nothing with them until the running session + // ends on its own — up to four hours. That wait is the whole problem: a visitor who keeps loading + // pages fetches the change within seconds and then carries on under the old decision for the rest + // of their visit. // - // - a session sample rate of 0 while this session is being collected: nothing is meant to be - // collected any more, and this is the emergency stop the console offers; - // - a session sample rate of 100 while this session is not: everything is meant to be - // collected, and this visitor is the exception; - // - a stricter default privacy level while this session is being collected: every further - // second recorded is a second of plaintext uploaded, and masking cannot reach back for it. + // Two changes are not made to wait, and what makes exactly those two special is that their + // outcome for the running session can be asserted without drawing again: // - // No other rate says anything about whether THIS session should have been kept — only a second - // draw could, and drawing twice silently turns a rate p into p². So everything else waits for - // the next session, a loosening privacy level included. Loosening waits on purpose: the delay - // is what leaves an operator room to undo a mistake, and what it costs meanwhile is more of the - // data already being collected. + // - a stricter default privacy level: every further second recorded is a second of plaintext + // uploaded, and masking cannot reach back for it. This is the one whose cost is not + // recoverable, and the reason the rest of this exists; + // - a session sample rate of 0: nothing is meant to be collected any more, and this is the + // emergency stop the console offers — one that took four hours would not be one. // - // The action is always to end the session and let the next activity start a new one — never to - // flip the running one, which would leave a replay masked in its first half and plain in its - // second, or invent a session that begins in the middle of a visit. + // Both are about a session that is being collected, which is why that is the first thing checked. + // A visitor who is not being collected records nothing and uploads nothing, so neither rule has + // anything to act on for them. + // + // No rate other than 0 says anything about whether THIS session should have been kept — only a + // second draw could, and drawing twice silently turns a rate p into p². A rate of 100 could be + // asserted about a session that is not collected, and deliberately is not acted on: `setForcedSession` + // already exists for "collect this visitor now", it is the one direction that raises volume + // unannounced, and nothing about it is urgent. So everything else waits for the next session, a + // loosening privacy level included. Loosening waits on purpose: the delay is what leaves an + // operator room to undo a mistake, and what it costs meanwhile is more of the data already being + // collected. + // + // The action is to end the session and let the next activity start a new one — never to flip the + // running one, which would leave a replay masked in its first half and plain in its second, or + // invent a session that begins in the middle of a visit. // // It stays idempotent with no bookkeeping at all: it compares what this session was drawn under // against what a draw would use now, and ending the session is exactly what makes that // difference disappear. The same response arriving again — another tab, a retry, a reload — // finds nothing left to act on. + // + // What it cannot reach: settings are fetched at start-up and on session renewal only, so a page + // that is never reloaded never hears of the change. A single always-visible tab is exactly that + // page — the visibility timer keeps renewing it, so it fetches nothing until the four-hour cap. + // Any other tab of the same visitor that does load a page ends the session they share. function endSessionIfSettingsAreDecisive() { const session = sessionManager.findSession() - if (!session) { - // Nothing to end. Whatever starts the next session draws on the settings just stored, which - // is the ordinary path and already gives them their effect. + if (!session || !isTypeTracked(session.trackingType)) { + // Nothing here that ending would change. Whatever starts this visitor's next session draws + // on the settings just stored, which is the ordinary path and already gives them effect. + // + // It also could not be decided if we wanted to: a session that is not collected is given no + // id, so no record is kept of what it was drawn under. The comparison below would fall + // through to the init value on every announcement and keep answering "tighter", ending one + // empty session after another for as long as the visitor stayed. return } const remote = readRemoteConfig(configuration.remoteConfig) - // Whether this session is collected is read off the session itself rather than reconstructed - // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only - // thing 0 and 100 let us assert anything about. - const isCollected = isTypeTracked(session.trackingType) - - // Only a session that is being collected can be recording, and only a recording can be too - // plain. A sampled-out visitor uploads nothing, so a stricter level has nothing to protect - // there — and nothing to compare against either: a session that is not collected is given no - // id, so no draw is recorded for it and what it was drawn under cannot be read back here. The - // comparison would fall through to the init value on every announcement and keep answering - // "tighter", ending one empty session after another for as long as the visitor stays. - if (isCollected) { - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under - // whatever was stored before that. No record means the draw used the init value, and so does - // the recorder — see `startRecording`, which falls back the same way. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { - sessionManager.expire() - return - } + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return } - if (forcedSession && isCollected) { + if (forcedSession) { // The host application has taken this page off the rates deliberately, and every draw it - // makes from now on is collected whatever the console says. Ending a collected session on a - // rate would only replace it with another collected one — the same difference, forever. That - // reasoning runs out when the session is not collected: this page can adopt one an unforced - // tab drew, and there a rate of 100 has something to change, so it is left to the rule - // below. The flag is this page's either way — another tab that never called - // `setForcedSession` reads the shared session as an ordinary one. + // makes from now on is collected whatever the console says. Ending it on a rate would only + // replace it with an identical forced session — the same difference, forever. The flag is + // this page's: another tab of the same visitor that never called `setForcedSession` reads + // the shared session as an ordinary one and may end it on a rate. return } const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { + if (sessionSampleRate === 0) { sessionManager.expire() } } From 2ff548f35693094305da9a0ead3dc7f8804cf633 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 01:20:58 -0700 Subject: [PATCH 09/17] v0.2.1 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a224436f0a..056cc32b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.1 - ✨ Two changes published from the console now end the running session, so they reach the visitor at their next interaction instead of waiting for that session to end on its own: a stricter diff --git a/developer-extension/package.json b/developer-extension/package.json index 64e65b75a2..644f7f7c68 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.0", + "version": "0.2.1", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index 3b191b9b28..ff78f6529b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.0" + "version": "0.2.1" } diff --git a/packages/core/package.json b/packages/core/package.json index 5f28ebec36..d15dea1f32 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/flagging/package.json b/packages/flagging/package.json index 11ca96cea3..40f30188e9 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.0" + "@flashcatcloud/browser-rum": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 389efefc03..6873a9bd30 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.0" + "@flashcatcloud/browser-rum": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index a289b5f804..a605341b7e 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 4385086930..35a9dde8af 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index 457cc004bd..fa9f68b058 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/rum-slim/package.json b/packages/rum-slim/package.json index 55ca8aafa2..82ebe14a55 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.0" + "@flashcatcloud/browser-logs": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index a733ea737c..a9f961e247 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.0" + "@flashcatcloud/browser-logs": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index ac71b95551..aae687d99d 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index d7338cac34..f385bbcd42 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.0", + "version": "0.2.1", "scripts": { "start": "ts-node ./src/main.ts" }, From ff4ce8a932a3e6f853eeb132e2ecab77567fc1b8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 01:21:07 -0700 Subject: [PATCH 10/17] chore: refresh the lockfile for the 0.2.1 workspace versions --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 432b6fd53e..89c4bb495b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.0 + "@flashcatcloud/browser-rum": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.0 + "@flashcatcloud/browser-rum": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.0 + "@flashcatcloud/browser-logs": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.0 + "@flashcatcloud/browser-logs": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From 0f866c1fc8279e50bb9fa52cae0d97b721f61ce5 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 05:21:27 -0700 Subject: [PATCH 11/17] fix(rum): sweep the settings entries of releases nobody runs The settings cache is keyed by application version, because two releases served at the same time are entitled to different rates and one entry between them would have each overwrite the other's at every fetch. The cost of that was an entry per release: nothing ever read or removed the one a previous release used, so on a site that deploys often they accumulated for good in a quota the host application shares. Every write now stamps when it happened, through the one path that writes an entry, and initialisation removes the entries nothing has refreshed for two days. Age is the only thing that can tell an abandoned entry from the entry of a tab still open on yesterday's release: a page that still reads its entry rewrites it at every session renewal, so the threshold only has to clear the longest session plus the longest outage worth surviving. The sweep runs before the first request rather than after each write. A session renewal is a hot path and localStorage is synchronous, and going first is what lets it free room on an origin that is already out of it - the very state the leak produces. This page's own entry is never a candidate: it holds the version floor that lets a late answer be refused. The one path that reaches an entry without storing anything - a response refused for carrying an older version - now rewrites it unchanged, so the entry a client is still asking for cannot be swept out from under it. --- CHANGELOG.md | 9 + .../configuration/remoteConfiguration.spec.ts | 99 ++++++++++- .../configuration/remoteConfiguration.ts | 157 +++++++++++++++--- 3 files changed, 241 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056cc32b0b..e4c364446f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ --- +## Unreleased + +- 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by + application version, because two releases served at the same time are entitled to different rates + — but the entry a previous release used was never read or removed again, so on a site that + deploys often they accumulated in the storage quota the page shares. An entry that nothing has + refreshed for two days is now removed when the SDK starts. A page still reading its entry + rewrites it at every session renewal, so only the entries of releases nobody runs are swept. + ## v0.2.1 - ✨ Two changes published from the console now end the running session, so they reach the visitor diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 7ba6456c20..2ee8cf5824 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,4 +1,4 @@ -import { INTAKE_SITE_US1, ONE_SECOND, display, isIntakeUrl } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, ONE_DAY, ONE_SECOND, dateNow, display, isIntakeUrl } from '@flashcatcloud/browser-core' import type { Clock, MockXhr } from '@flashcatcloud/browser-core/test' import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' import { mockRumConfiguration } from '../../../test' @@ -749,6 +749,103 @@ describe('remoteConfiguration', () => { }) }) + describe('sweeping the entries of releases nobody runs', () => { + // The key carries the application version, so every release leaves one behind. Without a sweep + // they accumulate for good in a quota the host application shares. + const otherReleaseKey = buildRemoteConfigSetup({ ...INIT_CONFIGURATION, version: '0.9.0' })!.storeKey + const drawKey = buildDrawStoreKey(INIT_CONFIGURATION) + const foreignKey = 'a-key-the-host-application-owns' + + beforeEach(() => { + registerCleanupTask(() => { + localStorage.removeItem(otherReleaseKey) + localStorage.removeItem(drawKey) + localStorage.removeItem(foreignKey) + }) + }) + + function writeEntryAged(key: string, age: number, values: Record = { version: 4 }) { + localStorage.setItem(key, JSON.stringify({ ...values, t: dateNow() - age })) + } + + function writeTimeOf(key: string) { + return (JSON.parse(localStorage.getItem(key)!) as { t?: number }).t + } + + it('removes an entry nothing has refreshed for longer than the threshold', () => { + writeEntryAged(otherReleaseKey, 3 * ONE_DAY) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).toBeNull() + }) + + it('keeps an entry a page refreshed recently, which is how a live one looks', () => { + writeEntryAged(otherReleaseKey, ONE_DAY) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() + }) + + it('removes an entry left by a build that did not record when it was written', () => { + // Everything stored before the write time existed. Taken for abandoned rather than kept: the + // accumulated orphans are the whole reason this exists, and a page still on the old build + // writes its entry back at its next renewal. + localStorage.setItem(otherReleaseKey, JSON.stringify({ version: 4, sessionSampleRate: 42 })) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).toBeNull() + }) + + it("never removes this page's own entry, however old it looks", () => { + // It holds the version floor that lets a late answer be refused, and the request this very + // initialisation is starting is about to read it. + localStorage.setItem(setup!.storeKey, JSON.stringify({ version: 8, sessionSampleRate: 42 })) + + start(configurationWith()) + + expect(readRemoteConfig(setup).version).toBe(8) + }) + + it('leaves alone every key it did not write', () => { + writeEntryAged(drawKey, 3 * ONE_DAY) + localStorage.setItem(foreignKey, 'not ours to parse') + + start(configurationWith()) + + expect(localStorage.getItem(drawKey)).not.toBeNull() + expect(localStorage.getItem(foreignKey)).toBe('not ours to parse') + }) + + it('records when an entry was written, so a later sweep can tell its age', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 42 } })) + + expect(dateNow() - writeTimeOf(setup!.storeKey)!).toBeLessThan(ONE_SECOND) + done() + }) + start(configurationWith()) + }) + + it('refreshes the write time of an entry whose values it refuses', (done) => { + // The entry a client is stuck on when a server breaks the only-goes-up contract is the one + // entry no successful write refreshes. Without this its settings would be swept out from + // under it while it was still asking for them. + writeEntryAged(setup!.storeKey, 3 * ONE_DAY, { version: 8, sessionSampleRate: 42 }) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 1 }, version: 7 })) + + expect(readRemoteConfig(setup)).toEqual({ version: 8, sessionSampleRate: 42 }) + expect(dateNow() - writeTimeOf(setup!.storeKey)!).toBeLessThan(ONE_SECOND) + done() + }) + start(configurationWith()) + }) + }) + describe('the storage key', () => { it('separates applications, environments and versions', () => { const keyOf = (partial: Partial) => diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 190e6df96f..97b8d7a57e 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -2,9 +2,11 @@ import { addEventListener, clearTimeout, createEndpointUrlBuilder, + dateNow, display, noop, setTimeout, + ONE_DAY, ONE_SECOND, } from '@flashcatcloud/browser-core' import type { DefaultPrivacyLevel, TimeoutId } from '@flashcatcloud/browser-core' @@ -57,6 +59,22 @@ const STORE_KEY_PREFIX = '_fc_rc_1_' const DRAW_STORE_KEY_PREFIX = '_fc_draw_1_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND +/** + * How long an entry may go unrefreshed before `sweepAbandonedEntries` treats it as belonging to a + * release nobody is running any more. + * + * An entry that is still being read is also being rewritten: the page reading it refetches at every + * session renewal and stores the answer. So the threshold only has to clear the longest a live + * entry can legitimately stay silent, which is the longest session (four hours, after which a + * renewal refetches) plus the longest endpoint outage we are willing to survive without dropping + * anyone — a failed fetch stores nothing. Two days leaves better than a day and a half of outage, + * and still bounds the leak at the entries of two days of releases. + * + * Erring long is deliberate. Deleting an entry too early costs the page reading it one session on + * its init values; keeping a dead one costs a few hundred bytes. + */ +const STORE_ENTRY_MAX_AGE = 2 * ONE_DAY + /** * A failed fetch is retried quickly, then patiently, then not at all until the next natural * trigger (a new session, or the next page load). The budget is deliberately tiny — two extra @@ -311,6 +329,10 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, onTrigger) + // Before the first request, so the room the entries of dead releases are holding is free by the + // time there is an answer to store. See `sweepAbandonedEntries`. + sweepAbandonedEntries(setup.storeKey) + onTrigger() return () => { @@ -422,8 +444,16 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // // What it is compared against is storage, not a version held in memory here, because the two // requests that can cross are two pages, and storage is the only thing they share. - const storedVersion = readRemoteConfig(setup).version + const stored = readRemoteConfig(setup) + const storedVersion = stored.version if (storedVersion !== undefined && response.version < storedVersion) { + // Refused, but the entry is plainly still in use — a request was just made for it and answered. + // Rewriting it unchanged is what says so: its age is the only thing the sweep reads, and this + // is the one path that reaches an entry without storing anything. A client left here by a + // server that broke the only-goes-up contract would otherwise have the settings it is still + // asking for swept out from under it. Reading a version out of the entry proves it is there, + // so nothing needs to be checked before writing it back. + writeEntry(setup, stored) return false } @@ -461,21 +491,110 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) values.custom = response.custom } + // Written even with nothing in it — that is what "remote configuration is off, use your own + // settings" looks like — so that the version is kept either way and the console can still see + // that this client is up to date with the change that turned it off. + return writeEntry(setup, values) && isNew +} + +/** + * What actually sits in storage: the values, plus when they were last written. + * + * `t` is not one of the values and is never handed on — `readStoredValues` drops it with everything + * else it does not recognise. It exists for `sweepAbandonedEntries` alone, which is why it is not + * spelled out on `RemoteConfigValues` where a reader would take it for something the server sends. + */ +interface StoredEntry extends RemoteConfigValues { + t: number +} + +/** + * The one place an entry is written, so that every entry carries the write time the sweep reads. + * + * Answers whether the values are now where the next draw will look for them. A failure — storage + * unavailable, or the origin out of room — leaves the previous entry exactly as it was, which is + * the same "keep what is already working" answer a failed request gets: the client goes on applying + * the settings it last stored, and goes on reporting their version. It is still reported as a + * failure, because nothing downstream may act on settings the next draw will not find. + */ +function writeEntry(setup: RemoteConfigSetup, values: RemoteConfigValues) { try { - // Written even with nothing in it — that is what "remote configuration is off, use your own - // settings" looks like — so that the version is kept either way and the console can still see - // that this client is up to date with the change that turned it off. - localStorage.setItem(setup.storeKey, JSON.stringify(values)) - return isNew + const entry: StoredEntry = { ...values, t: dateNow() } + localStorage.setItem(setup.storeKey, JSON.stringify(entry)) + return true } catch { - // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which - // is the same "keep what is already working" answer a failed request gets — the client goes on - // applying the settings it last stored, and goes on reporting their version. Reported as a - // failure all the same: nothing downstream may act on settings the next draw will not find. return false } } +/** + * Delete the entries of releases nobody is running any more. + * + * The store key carries the application version, because two releases live at the same time are + * entitled to different rates and one entry between them would have each overwrite the other's at + * every fetch. The cost of that is an entry per release, and nothing ever read or removed them + * again — on a site that deploys daily they accumulate for good, in a quota the host application + * shares. + * + * Run once per initialisation rather than at every write. Sweeping on write was the shape tried + * first and it is the wrong one: `localStorage` is synchronous, a session renewal is a hot path, + * and the walk would repeat for no new information. Once per page also puts it *before* the first + * write, which is what lets it free room on an origin that is already out of it — the very state + * the leak produces. + * + * This page's own entry is never a candidate: it holds the version floor that lets a late answer + * be refused, and it is about to be read by the request this initialisation is starting. + * + * An entry with no write time at all was left by a build older than this one. It is taken for + * abandoned rather than stamped and kept, which is the trade this makes deliberately: stamping + * would mean a write per orphan on the first load after the upgrade, and the accumulated orphans + * are exactly what this exists to clear. What it costs is bounded — while two builds are live on + * one origin, a page still on the old one may have its entry swept and spend a single session on + * its init values before writing it back. + */ +function sweepAbandonedEntries(keepKey: string) { + try { + const now = dateNow() + const abandoned: string[] = [] + + // Collected in full before anything is removed: removing during the walk shifts the indices + // `key()` reads, and whatever slid into the freed slot would be stepped over. + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i) + if (key === null || key === keepKey || key.indexOf(STORE_KEY_PREFIX) !== 0) { + continue + } + if (now - readWriteTime(key) > STORE_ENTRY_MAX_AGE) { + abandoned.push(key) + } + } + + abandoned.forEach((key) => localStorage.removeItem(key)) + } catch { + // Storage unavailable, or an entry that is not ours to parse. Housekeeping is never worth + // failing an initialisation over, and the next page load tries again. + } +} + +/** + * When the entry under `key` was last written, or 0 — older than any threshold — when it does not + * say. Anything in a browser profile can be edited by hand, so a time that is not a plain number is + * read as no time at all rather than trusted into the arithmetic above. + */ +function readWriteTime(key: string) { + try { + const stored = localStorage.getItem(key) + const parsed: unknown = stored ? JSON.parse(stored) : undefined + if (!parsed || typeof parsed !== 'object') { + return 0 + } + const { t } = parsed as Partial + return typeof t === 'number' && isFinite(t) ? t : 0 + } catch { + return 0 + } +} + export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): RemoteConfigSetup | undefined { if (!initConfiguration.remoteConfigurationEnabled) { return undefined @@ -529,19 +648,11 @@ function validFetchTimeout(timeout: number | undefined) { * as long as both are being served — so the version has to stay, and cannot be dropped to make the * limitation below go away. * - * KNOWN LIMITATION - that costs an entry per deploy. The first session after a release reads the - * local settings, and the entry the release before it used is never read again and never removed, - * so they accumulate in a quota the host application shares. - * - * Sweeping them on write is not the answer, and was tried: nothing here can tell an abandoned entry - * from the entry of a tab still open on yesterday's release, and deleting the latter drops that tab - * to its local settings for a whole session — after which the two tabs delete each other's entry at - * every renewal, which is a worse failure than the leak. A correct fix needs a way to know that no - * page is still reading an entry: an age written beside the values would do it, and is the shape to - * reach for if the accumulation ever bites. Two things to get right if it is ever built — the - * threshold has to clear the longest session AND the longest plausible endpoint outage, since only - * a stored response refreshes the age, and the stale-version early return above skips that write, - * so it must refresh the age even when it declines the values. + * That costs an entry per release — the first session after one reads the local settings, and the + * entry the release before it used is never read again — so the entries are swept by age rather + * than left to accumulate in a quota the host application shares. See `sweepAbandonedEntries` for + * why age is the only thing that can tell an abandoned entry from the entry of a tab still open on + * yesterday's release. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) From 6ff981f4efd20f40fc8ff3056d82124355367504 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 01:51:50 -0700 Subject: [PATCH 12/17] fix(rum): keep settings entries without a write time --- CHANGELOG.md | 7 ++-- .../configuration/remoteConfiguration.spec.ts | 9 ++--- .../configuration/remoteConfiguration.ts | 38 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c364446f..42b9a2ac7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,10 @@ - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by application version, because two releases served at the same time are entitled to different rates — but the entry a previous release used was never read or removed again, so on a site that - deploys often they accumulated in the storage quota the page shares. An entry that nothing has - refreshed for two days is now removed when the SDK starts. A page still reading its entry - rewrites it at every session renewal, so only the entries of releases nobody runs are swept. + deploys often they accumulated in the storage quota the page shares. New entries now record when + they were refreshed, and one left untouched for two days is removed when the SDK starts. Entries + written by older SDK builds are kept because they carry no refresh time, leaving a finite legacy + residue while preventing the cache from growing without bound. ## v0.2.1 diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 2ee8cf5824..c3e409ab8c 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -788,15 +788,14 @@ describe('remoteConfiguration', () => { expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() }) - it('removes an entry left by a build that did not record when it was written', () => { - // Everything stored before the write time existed. Taken for abandoned rather than kept: the - // accumulated orphans are the whole reason this exists, and a page still on the old build - // writes its entry back at its next renewal. + it('keeps an entry left by a build that did not record when it was written', () => { + // An old build still using this origin cannot add a write time when it refreshes the entry, + // so absence alone cannot distinguish a live release from an abandoned one. localStorage.setItem(otherReleaseKey, JSON.stringify({ version: 4, sessionSampleRate: 42 })) start(configurationWith()) - expect(localStorage.getItem(otherReleaseKey)).toBeNull() + expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() }) it("never removes this page's own entry, however old it looks", () => { diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 97b8d7a57e..2c0ee781f9 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -63,12 +63,12 @@ const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND * How long an entry may go unrefreshed before `sweepAbandonedEntries` treats it as belonging to a * release nobody is running any more. * - * An entry that is still being read is also being rewritten: the page reading it refetches at every - * session renewal and stores the answer. So the threshold only has to clear the longest a live - * entry can legitimately stay silent, which is the longest session (four hours, after which a - * renewal refetches) plus the longest endpoint outage we are willing to survive without dropping - * anyone — a failed fetch stores nothing. Two days leaves better than a day and a half of outage, - * and still bounds the leak at the entries of two days of releases. + * An entry written by this SDK that is still being read is also being rewritten: the page reading + * it refetches at every session renewal and stores the answer. So the threshold only has to clear + * the longest a live entry can legitimately stay silent, which is the longest session (four hours, + * after which a renewal refetches) plus the longest endpoint outage we are willing to survive + * without dropping anyone — a failed fetch stores nothing. Two days leaves better than a day and a + * half of outage, and still bounds the leak at the entries of two days of releases. * * Erring long is deliberate. Deleting an entry too early costs the page reading it one session on * its init values; keeping a dead one costs a few hundred bytes. @@ -545,12 +545,11 @@ function writeEntry(setup: RemoteConfigSetup, values: RemoteConfigValues) { * This page's own entry is never a candidate: it holds the version floor that lets a late answer * be refused, and it is about to be read by the request this initialisation is starting. * - * An entry with no write time at all was left by a build older than this one. It is taken for - * abandoned rather than stamped and kept, which is the trade this makes deliberately: stamping - * would mean a write per orphan on the first load after the upgrade, and the accumulated orphans - * are exactly what this exists to clear. What it costs is bounded — while two builds are live on - * one origin, a page still on the old one may have its entry swept and spend a single session on - * its init values before writing it back. + * An entry with no write time at all was left by a build older than this one. It is kept because an + * old build still running on the origin cannot add the write time when it refreshes the entry, so + * absence alone cannot distinguish a live release from an abandoned one. That leaves a finite set + * of entries from before the write time existed; every entry written from this build onward is + * timestamped, so the cache no longer grows without bound. */ function sweepAbandonedEntries(keepKey: string) { try { @@ -564,7 +563,8 @@ function sweepAbandonedEntries(keepKey: string) { if (key === null || key === keepKey || key.indexOf(STORE_KEY_PREFIX) !== 0) { continue } - if (now - readWriteTime(key) > STORE_ENTRY_MAX_AGE) { + const writeTime = readWriteTime(key) + if (writeTime !== undefined && now - writeTime > STORE_ENTRY_MAX_AGE) { abandoned.push(key) } } @@ -577,21 +577,21 @@ function sweepAbandonedEntries(keepKey: string) { } /** - * When the entry under `key` was last written, or 0 — older than any threshold — when it does not - * say. Anything in a browser profile can be edited by hand, so a time that is not a plain number is - * read as no time at all rather than trusted into the arithmetic above. + * When the entry under `key` was last written, or undefined when it does not say. Anything in a + * browser profile can be edited by hand, so a time that is not a finite number is read as no time + * at all rather than trusted into the arithmetic above. */ function readWriteTime(key: string) { try { const stored = localStorage.getItem(key) const parsed: unknown = stored ? JSON.parse(stored) : undefined if (!parsed || typeof parsed !== 'object') { - return 0 + return undefined } const { t } = parsed as Partial - return typeof t === 'number' && isFinite(t) ? t : 0 + return typeof t === 'number' && isFinite(t) ? t : undefined } catch { - return 0 + return undefined } } From 01ad2d78456ea9b9d842a2c38c84d6e9ac1fa226 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 02:18:41 -0700 Subject: [PATCH 13/17] v0.2.2 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- test/apps/react/yarn.lock | 36 ++++++++++++++++---------------- test/apps/vanilla/yarn.lock | 36 ++++++++++++++++---------------- yarn.lock | 8 +++---- 16 files changed, 57 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b9a2ac7f..5c63ede6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.2 - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by application version, because two releases served at the same time are entitled to different rates diff --git a/developer-extension/package.json b/developer-extension/package.json index 644f7f7c68..236a419626 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.1", + "version": "0.2.2", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index ff78f6529b..180e0391ed 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.1" + "version": "0.2.2" } diff --git a/packages/core/package.json b/packages/core/package.json index d15dea1f32..3db5ec37e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/flagging/package.json b/packages/flagging/package.json index 40f30188e9..511106b8d5 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.1" + "@flashcatcloud/browser-rum": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 6873a9bd30..10cca39af4 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.1" + "@flashcatcloud/browser-rum": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index a605341b7e..beb26291c4 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 35a9dde8af..3c779efb00 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index fa9f68b058..ef50527843 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/rum-slim/package.json b/packages/rum-slim/package.json index 82ebe14a55..57f2cb9eaf 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.1" + "@flashcatcloud/browser-logs": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index a9f961e247..9e408ebcbe 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.1" + "@flashcatcloud/browser-logs": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index aae687d99d..1b6fe55a17 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index f385bbcd42..e3d92ae11d 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.1", + "version": "0.2.2", "scripts": { "start": "ts-node ./src/main.ts" }, diff --git a/test/apps/react/yarn.lock b/test/apps/react/yarn.lock index 0cf90f7d4d..264338a0a7 100644 --- a/test/apps/react/yarn.lock +++ b/test/apps/react/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=4465e7&locator=react-app%40workspace%3A." - checksum: 10c0/46e0299a01d91d26b69488f075c3a773730c7b68b31a6acc15a627b078cd5b7d79c0f064ecc9a95f2f463545c4160a4983e4bac38b2a5228424747034fbdc4d1 + version: 0.2.2 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=react-app%40workspace%3A." + checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=9cfaab&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - checksum: 10c0/4ddf61c4fe3fd8d3d59d4b33e0a93490540cbb9acb28a4b6e65966b3de1018e4fb609978d702a9abf7cbe1b1848f7effefa01818567ff9d7a61b68533d62bfbb + "@flashcatcloud/browser-core": "npm:0.2.2" + checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 languageName: node linkType: hard "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=c6584f&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=070821&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: react: 18 || 19 react-router-dom: 6 || 7 @@ -39,22 +39,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/dfe0ff4d0ca4b50ce92b0c2cc190104afd0998a636382f05447bd8670c6ae9c199614898c345e3022f294859378f9357b08a8ad9319038b4523a153ad9fc1894 + checksum: 10c0/95d665251feef3cc0cd60a28d80599a6bb0f0dc249e25c7bed4572fbe594f9d45edb2062d8a1e6bebf519a720c4c69b7422d17d154f01abea1e37f1eb37eea6e languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=ab62a4&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-logs": 0.1.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/b866d94b34d3584c52e95e6bb244dec4186fd32a2f73cf6655349c6b2f50183bc130f4a872b3346b91cc8892a7ec3f74d3bad8b92cd39608327d6eff3fc8e2ed + checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 languageName: node linkType: hard diff --git a/test/apps/vanilla/yarn.lock b/test/apps/vanilla/yarn.lock index 471d88bfa9..df607db722 100644 --- a/test/apps/vanilla/yarn.lock +++ b/test/apps/vanilla/yarn.lock @@ -6,47 +6,47 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=4465e7&locator=app%40workspace%3A." - checksum: 10c0/46e0299a01d91d26b69488f075c3a773730c7b68b31a6acc15a627b078cd5b7d79c0f064ecc9a95f2f463545c4160a4983e4bac38b2a5228424747034fbdc4d1 + version: 0.2.2 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=app%40workspace%3A." + checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae languageName: node linkType: hard "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=9cf51a&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=e6bcc1&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-rum": 0.1.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true - checksum: 10c0/2ed31be0c03c45f1edf422e8136bfa4b804fcbdda924d8eb11996f38f2c06ebe7c5f34fad26bec8c48905463fc90b48a5ad492e147dd78d3fde1f5f0bace4424 + checksum: 10c0/fb9e48075e01feef767f84cc948939964dc2b24fb2d469dfc2dd31a6a56678974ee059a26c75e87adff212a5843883a19a63df41f599280d965c2beb3c777012 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=9cfaab&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - checksum: 10c0/4ddf61c4fe3fd8d3d59d4b33e0a93490540cbb9acb28a4b6e65966b3de1018e4fb609978d702a9abf7cbe1b1848f7effefa01818567ff9d7a61b68533d62bfbb + "@flashcatcloud/browser-core": "npm:0.2.2" + checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=ab62a4&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-logs": 0.1.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/b866d94b34d3584c52e95e6bb244dec4186fd32a2f73cf6655349c6b2f50183bc130f4a872b3346b91cc8892a7ec3f74d3bad8b92cd39608327d6eff3fc8e2ed + checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index 89c4bb495b..ed7a6e3e36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From 06bb967f7a9e407dd614e56ee60ae63f593d9ec5 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 07:27:00 -0700 Subject: [PATCH 14/17] feat(rum): apply a rate that leaves zero to the running session A session sample rate published above 0 now ends the session of a visitor whose session was drawn AT 0, so collection starts at their next interaction instead of waiting for that session to rotate -- up to four hours. This is the case where waiting shows an operator who has just switched collection on nothing at all, and nothing at all is indistinguishable from a broken integration. It joins the two changes that already did not wait: a stricter privacy level, and a rate of 0. Written against the rate the session was DRAWN at rather than against whether it is being collected, which is what keeps it honest. Re-drawing every session that is not collected would spare the winners and re-roll the losers, so a fleet drawn at 20 and moved to 50 would come out at 60. A rate of 0 is the one value with no winners to spare -- nothing was collected and no coin was flipped -- so re-drawing everyone lands exactly on the new rate. A rate rising from one real value to another therefore still waits. Answering that question needed a record a sampled-out session never had. Such a session is given no id, so its draw was not recorded at all and the rate it was drawn at fell back to init -- which reads a session that lost a draw at 30 as one drawn at 0 and re-draws it, the bias above. Its draw is now recorded in the same single entry as a collected session's, under an id no session can hold, so the two cannot read each other's. That sentinel shares one id across every sampled-out session, so the id check that makes a stale record inert for a collected session does nothing here. What replaces it is that the page which draws now owns the slot: reportDraw hands over every draw rather than only the ones worth keeping, so a draw that lands on the init values clears the record instead of leaving the previous session's behind to answer for it. Resolving a rate runs the site's beforeSampling callback, so it is asked only where the answer settles whether the session ends, not once per announcement for every visitor. --- CHANGELOG.md | 25 +++ .../src/domain/configuration/configuration.ts | 24 ++- .../src/domain/rumSessionManager.spec.ts | 171 ++++++++++++++-- .../rum-core/src/domain/rumSessionManager.ts | 182 +++++++++++++----- 4 files changed, 321 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c63ede6e4..696358c579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,31 @@ --- +## Unreleased + +- ✨ A session sample rate published from the console that rises above 0 now ends the running + session of a visitor whose session was drawn at 0, so collection starts at their next interaction + instead of waiting for that session to end on its own — up to four hours. This is the case where + waiting shows an operator who has just switched collection on nothing at all, which is + indistinguishable from a broken integration. It joins the two changes that already did not wait: + a stricter Session Replay privacy level, and a rate of 0. Nothing here happens without + `remoteConfigurationEnabled: true`. +- 📝 Only a session drawn AT 0 is re-drawn, not every session that is not being collected. Those + are different populations: a visitor who lost a draw at 30 had a coin flipped for them, and + re-rolling the losers while the winners keep their sessions would put the real rate above the + published one. While a rate of 0 is in force nothing is collected and no coin is flipped, so + re-drawing everyone lands exactly on the new rate. A rate rising from one real value to another + therefore still waits for the next session, as before. +- 📝 The rate a sampled-out session was drawn at is now recorded alongside the one a collected + session was drawn at, in the same single `localStorage` entry this SDK already keeps for the + draw. No new entry, no extra request. Without it a page that did not perform the draw — the + second page of a visit, or another tab — could not tell the two populations above apart. +- 📝 What you will see on the day you lift a rate off 0: visitors who were invisible start + appearing within seconds of loading a page rather than at their next session, so collected volume + climbs the same day rather than the next. That is the change taking effect, not a defect. + +--- + ## v0.2.2 - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 9ea4ca9ea7..f449070d4f 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -93,14 +93,22 @@ export interface RumInitConfiguration extends InitConfiguration { * values passed here, so they can be changed without releasing a new version of this site. * * A change applies to sessions started after it arrives, and a session already under way is never - * re-decided in place. Two changes do not wait for that session to end on its own, because their - * effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a session - * sample rate of 0. Both apply only while the visitor is being collected — one who is not records - * nothing and sends nothing, so neither has anything to act on there. Either ends the current - * session, and the visitor's next action starts a new one under the new settings; the old session - * is collected to its end as it was begun, so no recording is left masked in one half and plain - * in the other. Every other change waits for the next session, a loosening privacy level and a - * rate rising to 100 included — for "collect this visitor now" there is `setForcedSession()`. + * re-decided in place. Three changes do not wait for that session to end on its own, because + * their effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a + * session sample rate of 0, both while the visitor is being collected — one who is not records + * nothing and sends nothing, so neither has anything to act on there — and a rate above 0 for a + * visitor whose session was drawn AT 0, who was never in a draw at all and now could be. Any of + * the three ends the current session, and the visitor's next action starts a new one under the + * new settings; the old session is collected to its end as it was begun, so no recording is left + * masked in one half and plain in the other. + * + * Every other change waits for the next session, a loosening privacy level included, and so does + * a rate rising from one real value to another: only a second draw could say whether a session + * drawn at 30 should have been kept at 80, and drawing twice turns a rate p into p². Re-drawing + * only the visitors who are not collected would spare the winners and re-roll the losers, which + * lifts the real rate above the published one. A rate of 0 is the one value with no winners to + * spare, which is why leaving it is decidable and leaving 30 is not. For "collect this one + * visitor now" at any rate, there is `setForcedSession()`. * * How soon "does not wait" is depends on when this client next hears of the change, and it hears * only at page load and at each new session. A visitor who keeps loading pages hears within diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 40878e2db0..186514a200 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -853,7 +853,51 @@ describe('rum session manager', () => { return getSessionState(SESSION_STORE_KEY).isExpired === '1' } - describe('the two changes it can decide on its own', () => { + describe('the three changes it can decide on its own', () => { + it('ends a session drawn at zero when the rate rises above it', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Nothing was collected and no coin was flipped, so re-drawing this visitor lands exactly + // on the new rate — and until it happens an operator who has just switched collection on + // sees nothing at all, which is indistinguishable from broken. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn at zero even when the new rate is a partial one', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn on an init rate of zero when the first settings deliver a rate', () => { + // Nothing in storage yet, so this session was drawn on the init values — and a draw landing + // exactly on them records nothing, which is why zero can only be read back off init here. + // This is the application that never collects until the console says so. + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('collects the session that follows a rate lifted off zero', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + it('ends a session being collected when the rate goes to zero', () => { storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -946,25 +990,17 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('leaves a session that is not being collected alone when the rate goes to a hundred', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) + it('leaves a session that lost a draw at a real rate alone when the rate rises', () => { + // The regression this exists to catch: re-drawing every session that is not collected, + // while leaving the collected ones alone, spares the winners and re-rolls the losers — a + // fleet drawn at 30 and moved to 80 would come out well above 80. Only a session drawn at + // zero has no winner beside it to spare. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) startWith({ sessionSampleRate: 0 }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - // The one rate whose outcome could be asserted and deliberately is not: `setForcedSession` - // already covers "collect this visitor now", raising volume unannounced is the one - // direction that surprises, and nothing about it is urgent. - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(isSessionEnded()).toBeFalse() - }) - - it('leaves a session that is not collected alone when the rate merely rises', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) - - deliver({ version: 2, sessionSampleRate: 30 }) + deliver({ version: 2, sessionSampleRate: 80 }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(isSessionEnded()).toBeFalse() @@ -984,9 +1020,9 @@ describe('rum session manager', () => { }) it('does not end one sampled-out session after another as settings keep arriving', () => { - // A session that is not collected is given no id, so no record of its draw is kept and the - // level it was drawn under cannot be read back. Ending it would not change that, so acting - // on the comparison would end every session this visitor is ever given. + // Nothing is recorded for this visitor, so a stricter level has nothing to catch however + // many times it is announced. The rate stays at zero throughout, so the one rule that does + // act on a sampled-out session finds nothing to act on either. storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) @@ -1074,6 +1110,79 @@ describe('rum session manager', () => { }) describe('what it compares', () => { + it('does not answer for a sampled-out session with the record of the one it replaced', () => { + // A page that draws owns the record slot. Having drawn on the init values it has nothing to + // record, and leaving the previous session's record there would let it answer for this one: + // every sampled-out session is recorded under the same id, so unlike a collected session it + // cannot tell that the record describes somebody else. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // The settings entry is gone — swept as belonging to a release nobody runs any more — so + // the draw that follows uses the init rate and has nothing to record. It loses too, so it + // is a sampled-out session that did not write the record it would be read under. + localStorage.removeItem(STORE_KEY) + expireCookie() + const secondPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + secondPage.stop() + stopSessionManager() + + // A third page restores that session instead of drawing one, so the record is the only + // thing it can read the draw off — and the only record left would be the first session's. + startWith({ sessionSampleRate: 50 }) + expireSessionSpy.calls.reset() + + // Read off the first session's record this one looks drawn at zero and is re-drawn; read + // off init, which is what it was actually drawn at, it lost a draw at fifty and stays. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('reads the rate a sampled-out session was drawn at back through storage', () => { + // The case that decides whether any of this reaches a real visitor: they were drawn at zero + // on the page before, and the page acting on the change never performed that draw. A + // sampled-out session is given no id, so its draw is recorded under one no session can + // hold — without that record this page falls back to the init rate and answers wrongly. + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // A second page load restores the same session without drawing anything of its own. Init + // says 50 here on purpose: falling back to it would read this session as one that lost a + // draw and leave it alone, which is the answer the record exists to correct. + startWith({ sessionSampleRate: 50 }) + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('does not consult beforeSampling when no rate could decide anything', () => { + // Resolving the rate runs the site's own code, and an announcement is not a draw. It is + // asked only where the answer is what settles whether the session ends — never once per + // announcement for every visitor. + const beforeSampling = jasmine.createSpy('beforeSampling').and.returnValue(undefined) + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) + startWith({ sessionSampleRate: 0, beforeSampling }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + beforeSampling.calls.reset() + + // This visitor lost a draw at thirty, so no rate the console publishes says anything about + // the session they are on, and there is nothing to ask. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(beforeSampling).not.toHaveBeenCalled() + }) + it('never draws again to reach its decision', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -1141,6 +1250,28 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('stops re-drawing once the session that follows has lost a draw at the new rate', () => { + // The loop this could have become: the replacement is sampled out too, and if it were read + // as another session drawn at zero every further announcement would end it again. It was + // drawn at the new rate, and that is what its record says. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('stops tightening the privacy level once the session is drawn under it', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 9e742043ee..90f6196876 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -99,6 +99,35 @@ export const enum SessionReplayState { FORCED, } +/** + * FLASHCAT FORK - the id the draw of a session that lost its lottery is recorded under. + * + * A session that is not collected is given no id — see `sessionStore` — so it has nothing to key a + * record on, and until this existed its draw was simply not recorded. That left the one question + * this SDK has to answer before it may re-draw such a visitor unanswerable: was this session drawn + * at a rate of 0, or did it lose a draw at some other rate? Getting that wrong in the second + * direction re-rolls losers while sparing winners, which quietly lifts a fleet's real sampling + * rate towards 100% — see `endSessionIfSettingsAreDecisive`. + * + * Not a UUID, and not a value `generateUUID` can produce, so a record written here can never be + * mistaken for a real session's. The two are told apart by the id alone, which is what lets both + * share the single record slot: a collected session looks its own id up and a sampled-out one + * looks this up, and neither can read the other's. + * + * What it gives up, and why that is affordable: every sampled-out session matches this same id, so + * the id check that makes a stale record inert for a collected session does nothing here. What + * keeps a stale one from being read instead is that the page which draws owns the slot — it writes + * its draw or clears the slot, in the same stack that created the session — so the record always + * describes the most recent draw, and the most recent draw is what created the session being read. + * The gap left is the one this design already has for collected sessions and states two comments + * down: a tab polling storage between the session store's write and the record's would read the + * previous draw. A collected session falls back to init there; a sampled-out one reads the + * previous sampled-out draw's rate instead, which differs from its own only if the console moved + * the rate between two consecutive sessions of one visitor, and costs that visitor one extra + * re-draw when it does. + */ +const NOT_TRACKED_DRAW_ID = 'not-tracked' + export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, @@ -182,15 +211,32 @@ export function startRumSessionManager( const drawn = pendingDraw pendingDraw = undefined const sessionEntity = sessionManager.findSession() - if (!sessionEntity?.id) { + if (!sessionEntity) { return } + // A session that lost its draw has no id to be recorded under, so it is recorded under an id no + // session can hold. It has to be recorded at all for the same reason a collected one does — the + // rate it was drawn at is not something a later page can work out, and here it decides whether + // a console change away from 0 may re-draw this visitor at once. Which of the two is read back + // follows from the session itself, so no record can be read for a session it does not describe. + const drawId = sessionEntity.id || NOT_TRACKED_DRAW_ID if (drawn) { - writeDrawRecord(configuration, { id: sessionEntity.id, ...drawn }) - drawnHistory.add(drawn, startTime) + // The page that draws owns the slot, and says so either way. A record is only worth keeping + // when it says something the init values do not — but leaving the previous one in place + // instead would let it outlive the session it described, and a sampled-out session cannot + // spot that the way a collected one does: it matches on an id every sampled-out session + // shares. So a draw that has nothing to record clears the slot rather than passing over it. + // The cost is one `removeItem` per session drawn on a site that enabled none of this, which + // is a handful per visit. + if (isWorthRecording(configuration, drawn)) { + writeDrawRecord(configuration, { id: drawId, ...drawn }) + drawnHistory.add(drawn, startTime) + } else { + forgetDrawRecord(configuration) + } return } - const stored = readDrawRecord(configuration, sessionEntity.id) + const stored = readDrawRecord(configuration, drawId) if (stored) { drawnHistory.add(stored, startTime) } @@ -213,27 +259,37 @@ export function startRumSessionManager( // pages fetches the change within seconds and then carries on under the old decision for the rest // of their visit. // - // Two changes are not made to wait, and what makes exactly those two special is that their + // Three changes are not made to wait, and what makes exactly those three special is that their // outcome for the running session can be asserted without drawing again: // // - a stricter default privacy level: every further second recorded is a second of plaintext // uploaded, and masking cannot reach back for it. This is the one whose cost is not // recoverable, and the reason the rest of this exists; - // - a session sample rate of 0: nothing is meant to be collected any more, and this is the - // emergency stop the console offers — one that took four hours would not be one. + // - a session sample rate of 0 for a session being collected: nothing is meant to be collected + // any more, and this is the emergency stop the console offers — one that took four hours + // would not be one; + // - a rate above 0 for a session that was drawn AT 0: this visitor was never in a draw at all, + // and now could be. Without it an application whose rate only ever comes from the console + // shows an operator who has just switched collection on precisely nothing, for as long as + // the sessions already running take to rotate — and nothing at all is indistinguishable from + // broken. // - // Both are about a session that is being collected, which is why that is the first thing checked. - // A visitor who is not being collected records nothing and uploads nothing, so neither rule has - // anything to act on for them. + // The first two are about a session that is being collected, and the third only ever about one + // that is not, which is why each rule checks that for itself. // // No rate other than 0 says anything about whether THIS session should have been kept — only a - // second draw could, and drawing twice silently turns a rate p into p². A rate of 100 could be - // asserted about a session that is not collected, and deliberately is not acted on: `setForcedSession` - // already exists for "collect this visitor now", it is the one direction that raises volume - // unannounced, and nothing about it is urgent. So everything else waits for the next session, a - // loosening privacy level included. Loosening waits on purpose: the delay is what leaves an - // operator room to undo a mistake, and what it costs meanwhile is more of the data already being - // collected. + // second draw could, and drawing twice silently turns a rate p into p². That is also why the + // third rule is written against the rate the session was DRAWN at rather than against whether it + // is being collected: re-drawing every session that is not collected, while leaving the collected + // ones alone, spares the winners and re-rolls the losers, so a fleet drawn at 20 and moved to 50 + // would come out at 60. A rate of 0 is the one value with no winners to spare — nothing was + // collected, no coin was flipped — so re-drawing everyone lands exactly on the new rate. And it + // costs nothing to end such a session: it has no id, no events and no history, so it does not + // exist in the data and ending it leaves no seam. + // + // Everything else waits for the next session, a loosening privacy level included. Loosening waits + // on purpose: the delay is what leaves an operator room to undo a mistake, and what it costs + // meanwhile is more of the data already being collected. // // The action is to end the session and let the next activity start a new one — never to flip the // running one, which would leave a replay masked in its first half and plain in its second, or @@ -250,24 +306,37 @@ export function startRumSessionManager( // Any other tab of the same visitor that does load a page ends the session they share. function endSessionIfSettingsAreDecisive() { const session = sessionManager.findSession() - if (!session || !isTypeTracked(session.trackingType)) { - // Nothing here that ending would change. Whatever starts this visitor's next session draws - // on the settings just stored, which is the ordinary path and already gives them effect. - // - // It also could not be decided if we wanted to: a session that is not collected is given no - // id, so no record is kept of what it was drawn under. The comparison below would fall - // through to the init value on every announcement and keep answering "tighter", ending one - // empty session after another for as long as the visitor stayed. + if (!session) { return } const remote = readRemoteConfig(configuration.remoteConfig) + // What this session was created under, which is not the previously stored settings: settings + // are stored while a session runs, and the session was drawn under whatever was stored before + // that. No record means the draw used the init values — `reportDraw` records every draw that + // did not, so a draw with nothing recorded is a draw that used them. + const drawn = drawnHistory.find() + + if (!isTypeTracked(session.trackingType)) { + // Nothing forced can reach this comparison as a zero: a forced draw is recorded at 100 and is + // collected besides, so the record already answers the question the tracked branch has to ask + // `forcedSession` about below. + const drawnSampleRate = drawn?.sessionSampleRate ?? configuration.sessionSampleRate + if (drawnSampleRate !== 0) { + return + } + // Asked only now, and only here, because resolving runs the site's `beforeSampling`: this + // announcement is not a draw, and the callback should be run no more often than a decision + // actually turns on its answer. + if (resolveSampleRates(configuration, remote).sessionSampleRate > 0) { + sessionManager.expire() + } + return + } - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under whatever - // was stored before that. No record means the draw used the init value, and so does the - // recorder — see `startRecording`, which falls back the same way. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + // What this session is masking pages with right now — the recorder falls back to the init value + // the same way when there is no record, see `startRecording`. + const drawnPrivacyLevel = drawn?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { sessionManager.expire() @@ -283,8 +352,7 @@ export function startRumSessionManager( return } - const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if (sessionSampleRate === 0) { + if (resolveSampleRates(configuration, remote).sessionSampleRate === 0) { sessionManager.expire() } } @@ -454,9 +522,11 @@ function computeSessionState( configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean, - // FLASHCAT FORK - called when a draw actually happens (never for a restored session) and lands - // on something other than the init values, with the rates the draw used and the remote version - // they came from. + // FLASHCAT FORK - called whenever a draw actually happens and never for a restored session, with + // the rates the draw used and the remote version they came from. Reporting every draw, including + // one that landed on the init values, is what lets the caller tell "this page drew" from "this + // page adopted a session somebody else drew" — see `trackDraw`, where only the first may write to + // the record slot. onDraw?: (drawn: DrawnConfiguration) => void ) { let trackingType: RumTrackingType @@ -549,12 +619,9 @@ const PRIVACY_LEVEL_STRICTNESS: { [level in DefaultPrivacyLevel]: number } = { * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses * what the console and the application settled on. * - * What decides whether a draw is worth recording is the draw itself, not which feature produced it: - * a draw that used exactly what init passed is already described by the events, so recording it - * would buy nothing and cost a storage write on every site that turned none of this on. Everything - * else is recorded — including a `beforeSampling` override or a forced session on a site with - * remote configuration switched off, where the rates used and the rates init passed are precisely - * the values that differ. + * Whether the draw is worth keeping is `isWorthRecording`'s question, asked one layer up, because + * the answer there decides between writing the record and clearing it — and only a caller that + * hears about every draw can clear one. */ function reportDraw( configuration: RumConfiguration, @@ -566,23 +633,32 @@ function reportDraw( if (!onDraw) { return } - const drawn: DrawnConfiguration = { + onDraw({ version: remote.version, sessionSampleRate, sessionReplaySampleRate, traceSampleRate: remote.traceSampleRate ?? initTraceRule(configuration), defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - } - if ( - drawn.version === undefined && - drawn.sessionSampleRate === configuration.sessionSampleRate && - drawn.sessionReplaySampleRate === configuration.sessionReplaySampleRate && - drawn.traceSampleRate === initTraceRule(configuration) && - drawn.defaultPrivacyLevel === configuration.defaultPrivacyLevel - ) { - return - } - onDraw(drawn) + }) +} + +/** + * FLASHCAT FORK - whether a draw says anything the init values do not. + * + * One that does not is already described by the events, so keeping it would buy nothing and cost a + * storage write on every site that turned none of this on. Asked about the draw rather than about + * which feature produced it: a `beforeSampling` override or a forced session on a site with remote + * configuration switched off is precisely the case where the rates used and the rates init passed + * are the values that differ. + */ +function isWorthRecording(configuration: RumConfiguration, drawn: DrawnConfiguration) { + return ( + drawn.version !== undefined || + drawn.sessionSampleRate !== configuration.sessionSampleRate || + drawn.sessionReplaySampleRate !== configuration.sessionReplaySampleRate || + drawn.traceSampleRate !== initTraceRule(configuration) || + drawn.defaultPrivacyLevel !== configuration.defaultPrivacyLevel + ) } /** From 175bf28f1669c7ae1e7e8af3d0bd4c4079d038da Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 00:21:37 -0700 Subject: [PATCH 15/17] fix(rum): decide on a sampled-out session from the draw record in storage A rate leaving 0 ends the running session only if that session was drawn at 0, and the rate it was drawn at was read off the in-memory copy taken when the session was adopted. That copy can outlive the session: the session store tells sessions apart by id and tracking type, and two sampled-out sessions have neither an id nor a different type, so a tab whose storage poll misses the expired state between them never sees another tab end the first and draw the second. It keeps the first session's rate and, on the next delivered settings, may end a session that already lost a draw at the current rate. Read the rate off storage at the moment of the decision instead. The page that draws writes its record in the same stack that creates the session, so storage always describes the current draw, and it is the only thing the two tabs share. The tracked branch keeps the in-memory copy: a collected session carries an id, so its replacement is seen. --- CHANGELOG.md | 5 ++- .../src/domain/rumSessionManager.spec.ts | 37 ++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 42 +++++++++++-------- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 696358c579..170b09cac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,10 @@ - 📝 The rate a sampled-out session was drawn at is now recorded alongside the one a collected session was drawn at, in the same single `localStorage` entry this SDK already keeps for the draw. No new entry, no extra request. Without it a page that did not perform the draw — the - second page of a visit, or another tab — could not tell the two populations above apart. + second page of a visit, or another tab — could not tell the two populations above apart. The + decision reads that record straight off storage rather than off what the page last saw of the + draw: two sampled-out sessions look alike to the session store, so a tab can miss another tab + ending one and drawing the next, and storage is the one place the current draw is always found. - 📝 What you will see on the day you lift a rate off 0: visitors who were invisible start appearing within seconds of loading a page rather than at their next session, so collected volume climbs the same day rather than the next. That is the change taking effect, not a defect. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 186514a200..8cf789409e 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1165,6 +1165,43 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) + it('reads the rate off storage rather than off the draw this page last saw', () => { + // Two sampled-out sessions look alike to the session store — no id, the same tracking type — + // so a tab that misses the expired state between them never learns the session was + // replaced: nothing expires and nothing renews here, and what this page last read of the + // draw stays as it was. Storage is the one thing the tab that drew the replacement shares + // with this one, so it is what has to be read when the decision is made. + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Another tab hears a rate of 30, ends the session drawn at zero and draws the next one, + // which loses — all between two of this page's storage polls. + storeRemote({ version: 2, sessionSampleRate: 30 }) + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ + id: 'not-tracked', + version: 2, + sessionSampleRate: 30, + sessionReplaySampleRate: 50, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + ) + clock.tick(STORAGE_POLL_DELAY) + expect(expireSessionSpy).not.toHaveBeenCalled() + + // This page's own request answers with settings newer still. Read off the draw it last saw + // the session looks drawn at zero and is ended; read off storage it lost a draw at thirty + // and is left alone. + deliver({ version: 3, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('does not consult beforeSampling when no rate could decide anything', () => { // Resolving the rate runs the site's own code, and an announcement is not a draw. It is // asked only where the answer is what settles whether the session ends — never once per diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 90f6196876..7617bfb0e4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -119,12 +119,11 @@ export const enum SessionReplayState { * keeps a stale one from being read instead is that the page which draws owns the slot — it writes * its draw or clears the slot, in the same stack that created the session — so the record always * describes the most recent draw, and the most recent draw is what created the session being read. - * The gap left is the one this design already has for collected sessions and states two comments - * down: a tab polling storage between the session store's write and the record's would read the - * previous draw. A collected session falls back to init there; a sampled-out one reads the - * previous sampled-out draw's rate instead, which differs from its own only if the console moved - * the rate between two consecutive sessions of one visitor, and costs that visitor one extra - * re-draw when it does. + * The one thing the record decides for a sampled-out session — whether a rate leaving 0 may end + * it — is read off storage at the moment of that decision rather than off the copy `trackDraw` + * took when the session was adopted. See `endSessionIfSettingsAreDecisive` for why the copy is not + * enough: the session store cannot tell one sampled-out session from the next, so a page can keep + * the copy of a session another tab has already replaced. */ const NOT_TRACKED_DRAW_ID = 'not-tracked' @@ -201,8 +200,8 @@ export function startRumSessionManager( // synchronous stack: a tab whose storage poll fell exactly between the two would find no record // and keep its own settings for that session. Writing it earlier is not possible from here — the // id it belongs to is generated inside the store, as that session is persisted. The record is - // read only here, when a session is adopted, so such a tab keeps its own settings for the whole - // remaining life of that session rather than until its next poll. + // read into the history only here, when a session is adopted, so such a tab keeps its own + // settings for the whole remaining life of that session rather than until its next poll. // // Storage is also per origin while the session need not be: with `trackSessionAcrossSubdomains` // a session arrives on the next subdomain with no record waiting, and is reported and traced @@ -311,17 +310,22 @@ export function startRumSessionManager( } const remote = readRemoteConfig(configuration.remoteConfig) - // What this session was created under, which is not the previously stored settings: settings - // are stored while a session runs, and the session was drawn under whatever was stored before - // that. No record means the draw used the init values — `reportDraw` records every draw that - // did not, so a draw with nothing recorded is a draw that used them. - const drawn = drawnHistory.find() if (!isTypeTracked(session.trackingType)) { + // Read off storage rather than off `drawnHistory`, because the two can disagree here and only + // storage is right. Two sampled-out sessions look alike to the session store — no id, the + // same tracking type — so a page whose storage poll misses the expired state between them + // never learns that another tab ended the first and drew the second: nothing expires and + // nothing renews on this page, and the history keeps the draw of a session that is gone. The + // page that drew the replacement wrote its rate to storage in the same stack, so that is the + // one place this session's own rate can be found. A collected session cannot be confused this + // way, since its id changes with it. + // // Nothing forced can reach this comparison as a zero: a forced draw is recorded at 100 and is // collected besides, so the record already answers the question the tracked branch has to ask - // `forcedSession` about below. - const drawnSampleRate = drawn?.sessionSampleRate ?? configuration.sessionSampleRate + // `forcedSession` about below. No record means the draw used the init values, see `trackDraw`. + const drawnSampleRate = + readDrawRecord(configuration, NOT_TRACKED_DRAW_ID)?.sessionSampleRate ?? configuration.sessionSampleRate if (drawnSampleRate !== 0) { return } @@ -334,9 +338,11 @@ export function startRumSessionManager( return } - // What this session is masking pages with right now — the recorder falls back to the init value - // the same way when there is no record, see `startRecording`. - const drawnPrivacyLevel = drawn?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { sessionManager.expire() From 224beeff34cbb5781ea7636b7359ab0b2fe3c0e9 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 01:44:56 -0700 Subject: [PATCH 16/17] v0.2.3 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 4 ++-- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- test/apps/react/yarn.lock | 36 ++++++++++++++++---------------- test/apps/vanilla/yarn.lock | 36 ++++++++++++++++---------------- yarn.lock | 8 +++---- 16 files changed, 58 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 170b09cac8..53f046eba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.3 - ✨ A session sample rate published from the console that rises above 0 now ends the running session of a visitor whose session was drawn at 0, so collection starts at their next interaction diff --git a/developer-extension/package.json b/developer-extension/package.json index 236a419626..21084ec20a 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.2", + "version": "0.2.3", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index 180e0391ed..8eb0851193 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.2" -} + "version": "0.2.3" +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 3db5ec37e4..681fcd12f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/flagging/package.json b/packages/flagging/package.json index 511106b8d5..28650c2fd4 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.2" + "@flashcatcloud/browser-rum": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 10cca39af4..b12b307d92 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.2" + "@flashcatcloud/browser-rum": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index beb26291c4..6f633a85a2 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 3c779efb00..6de09fe87e 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index ef50527843..e573607070 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/rum-slim/package.json b/packages/rum-slim/package.json index 57f2cb9eaf..e7b30dbb9f 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.2" + "@flashcatcloud/browser-logs": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index 9e408ebcbe..4b7154fe74 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.2" + "@flashcatcloud/browser-logs": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index 1b6fe55a17..540a1fc2b2 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index e3d92ae11d..f8034b3907 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.2", + "version": "0.2.3", "scripts": { "start": "ts-node ./src/main.ts" }, diff --git a/test/apps/react/yarn.lock b/test/apps/react/yarn.lock index 264338a0a7..54ebdf9fdf 100644 --- a/test/apps/react/yarn.lock +++ b/test/apps/react/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=react-app%40workspace%3A." - checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae + version: 0.2.3 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=react-app%40workspace%3A." + checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 + "@flashcatcloud/browser-core": "npm:0.2.3" + checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b languageName: node linkType: hard "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=070821&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=7e444d&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: react: 18 || 19 react-router-dom: 6 || 7 @@ -39,22 +39,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/95d665251feef3cc0cd60a28d80599a6bb0f0dc249e25c7bed4572fbe594f9d45edb2062d8a1e6bebf519a720c4c69b7422d17d154f01abea1e37f1eb37eea6e + checksum: 10c0/2db122bbdf63bfe0e8c900cbca0f0f5b460db811a0837b2447aaef1768c2499e11565b67a28716f8faba038dd37e584618cd068a4c59ec5b9c8780c3b5c54115 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 + checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 languageName: node linkType: hard diff --git a/test/apps/vanilla/yarn.lock b/test/apps/vanilla/yarn.lock index df607db722..e278764101 100644 --- a/test/apps/vanilla/yarn.lock +++ b/test/apps/vanilla/yarn.lock @@ -6,47 +6,47 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=app%40workspace%3A." - checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae + version: 0.2.3 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=app%40workspace%3A." + checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 languageName: node linkType: hard "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=e6bcc1&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=f16bce&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true - checksum: 10c0/fb9e48075e01feef767f84cc948939964dc2b24fb2d469dfc2dd31a6a56678974ee059a26c75e87adff212a5843883a19a63df41f599280d965c2beb3c777012 + checksum: 10c0/997fa5864dd29469fea4a042dae3eddbc9647aee8535074288328e9eced7d8274ef78ee7b2ff311821cf15f34d2bd3347bd9d7dacb77a75c194045c3eb36a3f1 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 + "@flashcatcloud/browser-core": "npm:0.2.3" + checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 + checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index ed7a6e3e36..d350539f33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From 057815bd391fca27878bfed10436766b404fe00b Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 01:45:58 -0700 Subject: [PATCH 17/17] chore: restore the trailing newline lerna dropped from lerna.json --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index 8eb0851193..17fc086141 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", "version": "0.2.3" -} \ No newline at end of file +}