Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
3bf3ee3
feat(rum): let sampling rates be set remotely instead of only at init
Fiona2016 Aug 15, 2026
ec9873b
feat(rum): let a sampling change land on the running session too
Fiona2016 Aug 15, 2026
c02d18f
feat(rum): ask for the sampling settings again when the page comes back
Fiona2016 Aug 16, 2026
d6b972f
feat(rum): report which settings version the client is running
Fiona2016 Aug 16, 2026
427cb53
fix(rum): only refresh on reactivation when the server allows it
Fiona2016 Aug 17, 2026
369cf84
feat(rum): let the host application force a session to be collected
Fiona2016 Aug 19, 2026
d384b4c
feat(rum): deliver the console's custom values to the host application
Fiona2016 Aug 19, 2026
adfef26
feat(rum): give the application the last word on sampling, at the draw
Fiona2016 Aug 19, 2026
7956159
feat(rum): report the configuration a session was drawn under on its …
Fiona2016 Aug 20, 2026
fe0c46e
feat(rum): align the fallback config ttl with the server's ten minutes
Fiona2016 Aug 20, 2026
17e6490
feat(rum): fetch remote configuration per session instead of polling
Fiona2016 Aug 20, 2026
2b73383
feat(rum): deliver the trace sample rate and the replay privacy level
Fiona2016 Aug 21, 2026
270a2f8
refactor(rum): rename remoteConfiguration to remoteConfigurationEnabled
Fiona2016 Aug 24, 2026
40b58b7
refactor(rum): report a draw from one place
Fiona2016 Aug 25, 2026
55dda5d
Merge remote-tracking branch 'origin/publish' into feat/remote-sampli…
Fiona2016 Aug 26, 2026
1760aa9
feat(rum): refuse a configuration payload this build cannot read
Fiona2016 Aug 26, 2026
bbfea52
fix(rum): keep a session's sampling decision with the session
Fiona2016 Aug 27, 2026
2ec52a3
fix(rum): stop the draw history with the session manager
Fiona2016 Aug 27, 2026
676c97f
fix(rum): check stored remote settings on the way out as strictly as …
Fiona2016 Aug 27, 2026
6f25d03
refactor(rum): one definition of what counts as a rate
Fiona2016 Aug 27, 2026
71a32e3
style(rum): drop the blank line the removed helper left behind
Fiona2016 Aug 27, 2026
dcaa0fc
fix(rum): let no draw outlive the attempt that made it
Fiona2016 Aug 28, 2026
581c61e
fix(rum): refuse settings older than the ones already stored
Fiona2016 Aug 28, 2026
a3b9037
docs(rum): say where the settings are kept, and what forcing does in …
Fiona2016 Aug 31, 2026
3abeca9
fix(rum): keep two identities from spelling the same settings key
Fiona2016 Aug 31, 2026
874d1e3
fix(rum): keep the settings request out of the page's own data
Fiona2016 Aug 31, 2026
cd5d3bf
fix(rum): judge a settings response before it can replace what works
Fiona2016 Aug 31, 2026
ade28e2
fix(rum): keep "no trace rule" apart from a trace rate of 100
Fiona2016 Aug 31, 2026
d29b244
refactor(rum): name the fork's own event field instead of casting pas…
Fiona2016 Aug 31, 2026
5b7a782
docs(rum): export the sampling callback types, and say where remote s…
Fiona2016 Aug 31, 2026
f9a156c
test(rum): cover the two guards nothing was exercising
Fiona2016 Aug 31, 2026
127bce7
docs(rum): say why the settings key carries the application version
Fiona2016 Aug 31, 2026
eeb1da0
test(rum): fail if a targeting dimension is reported but not keyed by
Fiona2016 Aug 31, 2026
7f1fb08
fix(rum): let only a stamped configuration response replace what works
Fiona2016 Sep 1, 2026
0a13e41
docs(rum): correct what this SDK says about storage, forcing, and its…
Fiona2016 Sep 1, 2026
9ce20bb
fix(rum): never let a settings request take the page's collection wit…
Fiona2016 Sep 1, 2026
caecc3b
fix(rum): keep a site that opted out on its own masking
Fiona2016 Sep 1, 2026
575c83a
fix(rum): guard the whole settings exchange, not the parts that looke…
Fiona2016 Sep 1, 2026
bbf831b
docs(rum): name the path a proxy has to allow, and what slim cannot f…
Fiona2016 Sep 1, 2026
c128605
fix(rum): let the session id go when the visitor withdraws consent
Fiona2016 Sep 1, 2026
805bd7f
fix(rum): answer a settings request that was aborted out from under it
Fiona2016 Sep 1, 2026
ce80dc1
test(rum): give the session fixtures the stamps a real session carries
Fiona2016 Sep 1, 2026
0622bf6
Merge branch 'publish' into feat/remote-sampling-configuration
Fiona2016 Sep 1, 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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@

---

## Unreleased

- 💥 **Breaking**: `remoteConfigurationId` is gone from `RumInitConfiguration`. It fetched a
different endpoint under a different contract, and is replaced by `remoteConfigurationEnabled`.
A site still passing it in JavaScript keeps working and gets the settings it passed to `init`;
a TypeScript project passing it no longer compiles and should drop the option.
- 📝 Behind a `proxy`, the settings request travels to `/api/v2/rum/config`. A proxy that checks the
forwarded path against a list of known intake paths must be told about this one, or the settings
never arrive — the SDK keeps collecting with the values passed to `init`, so the only symptom is
that the console appears to have no effect.
- 💥 **Breaking for TypeScript code that implements our interfaces**: `RumPublicApi` gains
`setForcedSession` and `getRemoteConfig`, and `RumSessionManager` gains `setForcedSession`. Code
that only calls these interfaces is unaffected; code that implements or hand-mocks them needs the
new members. The two new methods are also absent from the legacy ES5 bundle, so feature-detect
them if the same code runs against both.
- ✨ `remoteConfigurationEnabled` lets the sampling rates, the trace sample rate and the Session
Replay privacy level be set from the console instead of only at `init`. Off by default: without
it the SDK makes no extra request. A change applies to sessions created after it arrives, never
to one already under way. `remoteConfigurationFetchTimeout` (default 3000 ms) bounds how long
that request may take; an unusable value falls back to the default rather than refusing `init`.
- 📝 Withdrawing tracking consent now also removes the record of the sampling draw, which holds the
session id. The session store was already rewritten without that id on withdrawal; this keeps the
copy in `localStorage` from outliving it.
- 📝 This release reads `localStorage` on every site, not only those that opt into remote
configuration: at start-up and at each new session it looks for the record of the sampling draw
that session was created under, so that another tab on the same session, and the page load that
restores it, report and trace it the same way. It only WRITES that record when a draw lands
somewhere other than the values passed to `init` — which needs remote configuration,
`beforeSampling`, or `setForcedSession()`; a site using none of them never writes. Sessions
themselves are unaffected and stay in a cookie unless `sessionPersistence` says otherwise. Called
out for privacy reviews.
- ✨ `beforeSampling` gives the application the last word on the rates at the moment a session is
drawn, with the console's custom values in hand.
- ✨ `setForcedSession()` collects the current visitor regardless of the rates, and
`getRemoteConfig()` returns the console's custom values verbatim.

## v0.1.1

Sessions on pages that stay open no longer run without end. A stored session whose state could not
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/domain/configuration/endpointBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function createEndpointBuilder(
trackType: TrackType,
configurationTags: string[]
) {
const buildUrlWithParameters = createEndpointUrlWithParametersBuilder(initConfiguration, trackType)
const buildUrlWithParameters = createEndpointUrlBuilder(initConfiguration, trackType, `/api/v2/${trackType}`)

return {
build(api: ApiType, payload: Payload) {
Expand All @@ -41,12 +41,17 @@ export function createEndpointBuilder(
* Create a function used to build a full endpoint url from provided parameters. The goal of this
* function is to pre-compute some parts of the URL to avoid re-computing everything on every
* request, as only parameters are changing.
*
* FLASHCAT FORK - `path` is a parameter rather than derived from `trackType`, so endpoints that do
* not sit at `/api/v2/<trackType>` can be built here too. That keeps every request the SDK makes on
* one implementation of the proxy and site rules: an endpoint that built its own URL would quietly
* bypass a customer's `proxy` and go straight to the intake host.
*/
function createEndpointUrlWithParametersBuilder(
export function createEndpointUrlBuilder(
initConfiguration: InitConfiguration,
trackType: TrackType
trackType: TrackType,
path: string
): (parameters: string) => string {
const path = `/api/v2/${trackType}`
const proxy = initConfiguration.proxy
if (typeof proxy === 'string') {
const normalizedProxyUrl = normalizeUrl(proxy)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/domain/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export {
serializeConfiguration,
} from './configuration'
export type { EndpointBuilder, TrackType } from './endpointBuilder'
export { createEndpointBuilder, buildEndpointHost } from './endpointBuilder'
export { createEndpointBuilder, createEndpointUrlBuilder, buildEndpointHost } from './endpointBuilder'
export * from './intakeSites'
export { computeTransportConfiguration, isIntakeUrl } from './transportConfiguration'
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
serializeConfiguration,
isSampleRate,
buildEndpointHost,
createEndpointUrlBuilder,
INTAKE_SITE_STAGING,
INTAKE_SITE_US1,
INTAKE_SITE_US1_FED,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/emulate/mockXhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ export class MockXhr extends MockEventTarget {
public status: number | undefined = undefined
public readyState: number = XMLHttpRequest.UNSENT
public onreadystatechange: () => void = noop
// Recorded so tests can assert on where a request was addressed, not only on what came back.
public url: string | undefined = undefined

private hasEnded = false

/* eslint-disable @typescript-eslint/no-unused-vars */
open(method: string | undefined | null, url: string | URL | undefined | null) {
this.url = url?.toString()
this.hasEnded = false
}

Expand Down
60 changes: 24 additions & 36 deletions packages/rum-core/src/boot/preStartRum.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
import type { Clock } from '@flashcatcloud/browser-core/test'
import {
callbackAddsInstrumentation,
interceptRequests,
mockClock,
mockEventBridge,
mockSyntheticsWorkerValues,
Expand Down Expand Up @@ -449,33 +448,31 @@ describe('preStartRum', () => {
})

describe('remote configuration', () => {
let interceptor: ReturnType<typeof interceptRequests>
it('starts collecting straight away, whatever the sampling settings do', () => {
// Fetching them belongs to startRum, next to the session manager. What matters here is
// that opting in never delays or blocks initialisation.
const strategy = createPreStartStrategy(
{},
createTrackingConsentState(),
createCustomVitalsState(),
doStartRumSpy
)
strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true }, PUBLIC_API)

beforeEach(() => {
interceptor = interceptRequests()
expect(doStartRumSpy).toHaveBeenCalled()
expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined()
})

it('should start with the remote configuration when a remoteConfigurationId is provided', (done) => {
interceptor.withMockXhr((xhr) => {
xhr.complete(200, '{"rum":{"sessionSampleRate":50}}')

expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50)
done()
})

it('resolves no remote sampling setup at all when the site did not opt in', () => {
const strategy = createPreStartStrategy(
{},
createTrackingConsentState(),
createCustomVitalsState(),
doStartRumSpy
)
strategy.init(
{
...DEFAULT_INIT_CONFIGURATION,
remoteConfigurationId: '123',
},
PUBLIC_API
)
strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API)

expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeUndefined()
})
})

Expand Down Expand Up @@ -568,10 +565,8 @@ describe('preStartRum', () => {
describe('initConfiguration', () => {
let strategy: Strategy
let initConfiguration: RumInitConfiguration
let interceptor: ReturnType<typeof interceptRequests>

beforeEach(() => {
interceptor = interceptRequests()
strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy)
initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' }
})
Expand Down Expand Up @@ -606,27 +601,20 @@ describe('preStartRum', () => {
expect(strategy.initConfiguration).toEqual(initConfiguration)
})

it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => {
interceptor.withMockXhr((xhr) => {
xhr.complete(200, '{"rum":{"sessionSampleRate":50}}')

expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50)
done()
})

it('reports exactly what the site passed, with nothing merged in from the console', () => {
// Remote settings only ever move the sampling rates, and only inside the session manager.
// If they were merged into the init configuration instead, anything in it — the client
// token, the site — could be rewritten from the far end of a request.
const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true }
const strategy = createPreStartStrategy(
{},
createTrackingConsentState(),
createCustomVitalsState(),
doStartRumSpy
)
strategy.init(
{
...DEFAULT_INIT_CONFIGURATION,
remoteConfigurationId: '123',
},
PUBLIC_API
)
strategy.init(initConfiguration, PUBLIC_API)

expect(strategy.initConfiguration).toEqual(initConfiguration)
})
})

Expand Down
22 changes: 16 additions & 6 deletions packages/rum-core/src/boot/preStartRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import {
validateAndBuildRumConfiguration,
type RumConfiguration,
type RumInitConfiguration,
readRemoteConfig,
buildRemoteConfigSetup,
} from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection'
import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection'
import { fetchAndApplyRemoteConfiguration, serializeRumConfiguration } from '../domain/configuration'
import { serializeRumConfiguration } from '../domain/configuration'
import { callPluginsMethod } from '../domain/plugins'
import { buildGlobalContextManager } from '../domain/contexts/globalContext'
import { buildUserContextManager } from '../domain/contexts/userContext'
Expand Down Expand Up @@ -175,11 +177,7 @@ export function createPreStartStrategy(

callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi })

if (initConfiguration.remoteConfigurationId) {
fetchAndApplyRemoteConfiguration(initConfiguration, doInit)
} else {
doInit(initConfiguration)
}
doInit(initConfiguration)
},

get initConfiguration() {
Expand All @@ -190,6 +188,18 @@ export function createPreStartStrategy(

stopSession: noop,

setForcedSession() {
bufferApiCalls.add((startRumResult) => startRumResult.setForcedSession())
},

getRemoteConfig() {
// Before the SDK starts, the last stored bag still answers — that is what lets application
// code read it right after init() without waiting for the first fetch.
return cachedInitConfiguration
? readRemoteConfig(buildRemoteConfigSetup(cachedInitConfiguration)).custom
: undefined
},

addTiming(name, time = timeStampNow()) {
bufferApiCalls.add((startRumResult) => startRumResult.addTiming(name, time))
},
Expand Down
2 changes: 2 additions & 0 deletions packages/rum-core/src/boot/rumPublicApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const noopStartRum = (): ReturnType<StartRum> => ({
viewHistory: {} as any,
session: {} as any,
stopSession: () => undefined,
setForcedSession: () => undefined,
getRemoteConfig: () => undefined,
startDurationVital: () => ({}) as DurationVitalReference,
stopDurationVital: () => undefined,
addDurationVital: () => undefined,
Expand Down
40 changes: 40 additions & 0 deletions packages/rum-core/src/boot/rumPublicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,38 @@ export interface RumPublicApi extends PublicApi {
*/
stopSession: () => void

/**
* Force the session to be collected, with Session Replay where this build records it at all,
* regardless of the configured sample rates. Call it when your own code decides a visitor needs
* debugging (an allow-list, a support flow). On the slim build there is no recorder, so the
* visitor is collected without a replay. If the current session was not being collected, it ends and a collected one starts at
* the next user interaction; a session already collected keeps running and gets replay recording.
* The forced state lasts for the page lifetime — decide on each page load whether to call again.
*
* The forced state belongs to the page that called, but the session belongs to every tab. So if
* the visitor has this site open in another tab and acts there first, that tab draws the
* replacement session under the ordinary rates and the visitor is not collected after all —
* silently, since nothing failed. The call is not lost: this page stays forced, so a later
* session it draws itself is collected. But nothing forces one to arrive soon. Call it from the
* page the visitor is actually using, or have them close the others.
*
* Inside a WebView the host application owns the session, so only the recording half applies:
* replay starts, but the session's own sampling decision belongs to the mobile SDK and is left
* to it. Force the session there through the host application instead.
*/
setForcedSession: () => void

/**
* Read the custom values published for this application in the console. The SDK delivers them
* verbatim and never interprets them — what a value means is entirely up to your own code (a
* debug allow-list to pair with `setForcedSession()`, a feature toggle). Values are cached
* locally, so the bag published while a previous page was open answers immediately on the next.
* Returns undefined when nothing has been published, when remote configuration is off, and
* inside a WebView, where the host application owns these settings and nothing is fetched. The
* content is readable by anyone holding the public client token — it is public information.
*/
getRemoteConfig: () => Record<string, unknown> | undefined

/**
* Add a feature flag evaluation,
* stored in `@feature_flags.<feature_flag_key>`
Expand Down Expand Up @@ -397,6 +429,8 @@ export interface Strategy {
initConfiguration: RumInitConfiguration | undefined
getInternalContext: StartRumResult['getInternalContext']
stopSession: StartRumResult['stopSession']
setForcedSession: StartRumResult['setForcedSession']
getRemoteConfig: StartRumResult['getRemoteConfig']
addTiming: StartRumResult['addTiming']
startView: StartRumResult['startView']
setViewName: StartRumResult['setViewName']
Expand Down Expand Up @@ -625,6 +659,12 @@ export function makeRumPublicApi(
addTelemetryUsage({ feature: 'stop-session' })
}),

setForcedSession: monitor(() => {
strategy.setForcedSession()
}),

getRemoteConfig: monitor(() => strategy.getRemoteConfig()),

addFeatureFlagEvaluation: monitor((key, value) => {
strategy.addFeatureFlagEvaluation(sanitize(key)!, sanitize(value))
addTelemetryUsage({ feature: 'add-feature-flag-evaluation' })
Expand Down
Loading
Loading