Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
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
27 changes: 27 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,25 @@ export interface RumPublicApi extends PublicApi {
*/
stopSession: () => void

/**
* Force the session to be collected, with Session Replay, regardless of the configured sample
* rates. Call it when your own code decides a visitor needs debugging (an allow-list, a support
* flow). 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.
*/
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 or remote configuration is off. 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 +416,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 +646,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
18 changes: 17 additions & 1 deletion packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { startRumEventBridge } from '../transport/startRumEventBridge'
import { startUrlContexts } from '../domain/contexts/urlContexts'
import { createLocationChangeObservable } from '../browser/locationChangeObservable'
import type { RumConfiguration } from '../domain/configuration'
import { startRemoteConfiguration, readRemoteConfig } from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext'
import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry'
Expand Down Expand Up @@ -125,6 +126,13 @@ export function startRum(
}

if (!canUseEventBridge()) {
// FLASHCAT FORK - keep the console's sampling rates fresh, at the rhythm the sessions read
// them: once now and once per session renewal. It is skipped under an event bridge, where the
// host application owns the sampling decision. Nothing waits on the first response: the rates
// already in storage, or the ones passed to init, carry this page either way, so an endpoint
// having a bad minute never costs a visit.
cleanupTasks.push(startRemoteConfiguration(configuration, lifeCycle))

const batch = startRumBatch(
configuration,
lifeCycle,
Expand Down Expand Up @@ -198,7 +206,7 @@ export function startRum(

cleanupTasks.push(stopViewCollection)

const { stop: stopResourceCollection } = startResourceCollection(lifeCycle, configuration, pageStateHistory)
const { stop: stopResourceCollection } = startResourceCollection(lifeCycle, configuration, pageStateHistory, session)
cleanupTasks.push(stopResourceCollection)

if (configuration.trackLongTasks) {
Expand Down Expand Up @@ -240,6 +248,14 @@ export function startRum(
viewHistory,
session,
stopSession: () => session.expire(),
getRemoteConfig: () => readRemoteConfig(configuration.remoteConfig).custom,
setForcedSession: () => {
session.setForcedSession()
// A session that was collected without replay needs the recorder actually started on top of
// the session-state flip; the forced-replay start path already handles every other case as a
// no-op.
recorderApi.start({ force: true })
},
getInternalContext: internalContext.get,
startDurationVital: vitalCollection.startDurationVital,
stopDurationVital: vitalCollection.stopDurationVital,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ describe('serializeRumConfiguration', () => {
...EXHAUSTIVE_INIT_CONFIGURATION,
applicationId: 'applicationId',
beforeSend: () => true,
beforeSampling: () => undefined,
excludedActivityUrls: ['toto.com'],
workerUrl: './worker.js',
compressIntakeRequests: true,
Expand All @@ -561,7 +562,8 @@ describe('serializeRumConfiguration', () => {
trackWebVitals: true,
trackResources: true,
trackLongTasks: true,
remoteConfigurationId: '123',
remoteConfigurationEnabled: true,
remoteConfigurationFetchTimeout: 3000,
plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }],
trackFeatureFlagsForEvents: ['vital'],
profilingSampleRate: 0,
Expand All @@ -577,12 +579,14 @@ describe('serializeRumConfiguration', () => {
: Key extends
| 'applicationId'
| 'subdomain'
| 'remoteConfigurationId'
| 'remoteConfigurationEnabled'
| 'remoteConfigurationFetchTimeout'
| 'profilingSampleRate'
| 'propagateTraceBaggage'
| 'trackWebVitals'
// FLASHCAT FORK: not reported to telemetry
| 'sessionReplayDirectUpload'
| 'beforeSampling'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
Loading
Loading