Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f84fd00
refactor(rum): resolve the rates a draw would use in one place
Fiona2016 Aug 31, 2026
2acc5d1
feat(rum): end the session when new settings decide its fate
Fiona2016 Aug 31, 2026
f6d9b5c
refactor(rum): keep the fork's lifecycle event out of upstream's numb…
Fiona2016 Aug 31, 2026
c370e92
fix(rum): stop ending the sessions of visitors who are not collected
Fiona2016 Sep 1, 2026
738ef96
test(rum): cover the decision paths nothing was holding
Fiona2016 Sep 1, 2026
20fbc0b
docs(changelog): say what a decisive publish does to a running visit
Fiona2016 Sep 1, 2026
9c76b20
fix(rum): let a rate of a hundred through to a forced page that colle…
Fiona2016 Sep 1, 2026
f02d659
Merge pull request #40 from flashcatcloud/feat/end-session-on-new-set…
Fiona2016 Sep 1, 2026
9be78e7
refactor(rum): stop acting on a rate that rises to a hundred
Fiona2016 Sep 2, 2026
7f2c44a
Merge pull request #41 from flashcatcloud/refactor/drop-the-hundred-rule
Fiona2016 Sep 2, 2026
2ff548f
v0.2.1
Fiona2016 Sep 2, 2026
ff4ce8a
chore: refresh the lockfile for the 0.2.1 workspace versions
Fiona2016 Sep 2, 2026
0f866c1
fix(rum): sweep the settings entries of releases nobody runs
Fiona2016 Sep 2, 2026
6ff981f
fix(rum): keep settings entries without a write time
Fiona2016 Sep 4, 2026
5b31603
Merge pull request #43 from flashcatcloud/feat/remote-config-cache-sweep
Fiona2016 Sep 4, 2026
01ad2d7
v0.2.2
Fiona2016 Sep 4, 2026
06bb967
feat(rum): apply a rate that leaves zero to the running session
Fiona2016 Sep 4, 2026
175bf28
fix(rum): decide on a sampled-out session from the draw record in sto…
Fiona2016 Sep 7, 2026
fb94a28
Merge pull request #44 from flashcatcloud/feat/remote-config-open-gate
Fiona2016 Sep 7, 2026
224beef
v0.2.3
Fiona2016 Sep 7, 2026
057815b
chore: restore the trailing newline lerna dropped from lerna.json
Fiona2016 Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,80 @@

---

## 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
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. 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.

---

## 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
— 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. 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

- ✨ 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 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.

## v0.2.0

- 💥 **Breaking**: `remoteConfigurationId` is gone from `RumInitConfiguration`. It fetched a
Expand Down
2 changes: 1 addition & 1 deletion developer-extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-sdk-developer-extension",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"scripts": {
"build": "rm -rf dist && webpack --mode production",
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"npmClient": "yarn",
"version": "0.2.0"
"version": "0.2.3"
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-core",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
4 changes: 2 additions & 2 deletions packages/flagging/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-flagging",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"private": true,
"main": "cjs/entries/main.js",
Expand All @@ -18,7 +18,7 @@
"@flashcatcloud/browser-core": "workspace:*"
},
"peerDependencies": {
"@flashcatcloud/browser-rum": "0.2.0"
"@flashcatcloud/browser-rum": "0.2.3"
},
"peerDependenciesMeta": {
"@flashcatcloud/browser-rum": {
Expand Down
4 changes: 2 additions & 2 deletions packages/logs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-logs",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"main": "cjs/entries/main.js",
"module": "esm/entries/main.js",
Expand All @@ -17,7 +17,7 @@
"@flashcatcloud/browser-core": "workspace:*"
},
"peerDependencies": {
"@flashcatcloud/browser-rum": "0.2.0"
"@flashcatcloud/browser-rum": "0.2.3"
},
"peerDependenciesMeta": {
"@flashcatcloud/browser-rum": {
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-rum-core",
"version": "0.2.0",
"version": "0.2.3",
"license": "Apache-2.0",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
34 changes: 31 additions & 3 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
* then ignored rather than refusing `init`.
Expand All @@ -86,9 +92,31 @@ 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 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
* 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.
*
* 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -196,6 +196,83 @@ 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 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')

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 }

Expand Down Expand Up @@ -672,6 +749,102 @@ 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<string, unknown> = { 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('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)).not.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<RumInitConfiguration>) =>
Expand Down
Loading
Loading