Skip to content

feat(rum): let the console set the sampling configuration - #30

Merged
Fiona2016 merged 43 commits into
publishfrom
feat/remote-sampling-configuration
Sep 1, 2026
Merged

feat(rum): let the console set the sampling configuration#30
Fiona2016 merged 43 commits into
publishfrom
feat/remote-sampling-configuration

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Lets an application owner change how much traffic RUM keeps, and how Session
Replay masks a page, without the site shipping a new release.

Off by default: without remoteConfigurationEnabled: true the SDK makes no extra
request and behaves exactly as before. The one deliberate break is upstream's
remoteConfigurationId, which this branch removes: it fetched a different
endpoint under a different contract, and a site still passing it now gets the
settings it passed to init.

How it works

  • Fetching follows the rhythm of the sessions that read it: once at start-up and
    once whenever a new session begins. There is no polling — a change can only
    matter at the next draw, so asking more often than sessions are drawn would be
    requests for nothing.
  • The draw reads straight from storage rather than from a value held in memory,
    which is what lets a value fetched by one page load apply to the very first
    session of the next one instead of every visit starting on the local settings.
    The decision a draw made is written back the same way, so a page that did not
    draw — another tab on the same session, or the page load that restored it —
    reads it rather than falling back to its own settings.
  • Any failure — network error, timeout, non-200, unparseable body — leaves the
    stored settings exactly as they were, and is retried twice (5s then 60s, ±20%
    jitter) before waiting for the next natural trigger.
  • Settings are published under a number that only ever goes up, rollbacks included: going back to
    earlier settings publishes them again under a new, higher number. A response numbered below what
    is already stored is therefore an older answer arriving late — another tab's request that crossed
    this one, or a copy an intermediary kept — and is discarded rather than applied.
  • Conditional revalidation is the HTTP stack's job: the server pairs an ETag with
    Cache-Control: private, no-cache, so the browser cache revalidates on its own
    and a 304 is answered from cache without any If-None-Match handling here.
  • A change applies to sessions created after it arrives; a session already under
    way keeps the values it was drawn with, rather than flipping in place. A session
    that was not being collected has no id and no history, so flipping it would
    invent a session that appears to begin mid-use. The server's activation field
    is accepted and ignored, like ttl: everything here is next-session.
  • Events report the rates the session was actually drawn under plus the
    configuration version, so server-side extrapolation lines up with the draw that
    kept the session rather than with whatever has arrived since. That holds for a
    draw beforeSampling moved or setForcedSession() pinned, remote configuration
    on or off, and for an event assembled after its own session has been renewed.

What the application can do

  • beforeSampling gets the last word at the draw, with the rates that would
    apply and the delivered custom values. A thrown error or an out-of-range rate
    leaves the incoming value in place — its failure modes must never reach session
    creation.
  • setForcedSession() collects the current visitor regardless of the rates.
  • getRemoteConfig() returns the console's custom values, delivered verbatim and
    never interpreted.

Verification

  • 2913 unit tests green
  • typecheck, lint and format clean

Both sampling rates were fixed when `init()` ran, so changing either one
meant releasing a new version of the site. That is days or weeks at
exactly the moments the knob is worth having: an incident, a launch, a
bill that jumped overnight.

With `remoteConfiguration: true` the SDK takes `sessionSampleRate` and
`sessionReplaySampleRate` from the application's settings instead, polling
`/api/v2/rum/config` for them. Left off — the default — nothing is
requested and the SDK behaves exactly as before.

The rates are read at the one moment a session's fate is decided, so a
change never disturbs a visitor already on the site: it applies from the
next session onwards, in either direction. They are read from storage
rather than from memory, so rates fetched during one page load already
carry the first session of the next one.

Failure is always "keep collecting with what you have": initialisation
never waits on the request, an error or timeout leaves the stored rates
untouched, and a rate the server does not send stays with the value passed
to `init()` — a rate is never invented, least of all a zero, which would
switch collection off nobody asked to switch off.

`remoteConfigurationId` is removed. It addressed a configuration file this
SDK's backend does not serve, so no working integration can depend on it.

Also generalises the endpoint URL builder to take a path, so this request
follows the same `site` and `proxy` rules as every other one instead of
growing a second copy that could quietly bypass a customer's proxy.
A change to the sampling rates only reached a visitor on their next
session. That is the right default — it keeps every session a complete
record of itself — but it is the wrong answer during an incident, where
"show me what is happening now" and "stop this flood now" are the whole
point of having the knob.

The configuration response now carries an activation, chosen per
application in the console. `next_session` is unchanged and remains the
default. `immediate` ends the running session as soon as rates that
actually change this client arrive, so a new one starts under them.

Ending and restarting is not the same as flipping the running session's
decision in place, and the difference is why it is done this way: a
session that was not being collected has no id and no history, so
flipping it would invent a session that appears to begin mid-visit, and a
collected session flipped off would simply stop, looking like it ended
early. Restarting reuses the expiry path the SDK already has, so the
recorder flushes and starts again from a fresh full snapshot exactly as it
does when a session times out.

The session is only ended when the rates this client would draw with
really changed — remote value or, per knob, the value passed to init.
Without that, a console resending an unchanged configuration would cut
every visitor's session in two on every poll.

Fetching moved from preStartRum into startRum so it sits next to the
session manager it now has to reach, which also means it no longer runs
before tracking consent is granted. The URL, storage key and timeout are
resolved once into a single `remoteSampling` field on the configuration,
so "did the site opt in" is one check rather than three.
A page the visitor left and returned to had usually missed its refresh.
Browsers throttle timers hard in hidden tabs, and a page restored from the
back-forward cache may not have run one for hours, so someone could come
back to a tab and carry on under settings that were changed while they
were away.

Coming back is now its own reason to ask, subject to the same ttl, so
switching between tabs does not turn into a request each time.

Deliberately not a method the site has to call: the sites that would never
get fresh settings are exactly the ones that never read far enough to find
such a method.
The console had no honest way to tell whether a saved change had reached
anyone. Events cannot answer it: an event only exists for a session that
was kept, so at a low sample rate they describe the sampled few, and the
size of that blind spot is set by the very rate being changed.

The version each response carried is now stored alongside the rates and
sent back on the next request — the one request every client makes,
whether or not its session was kept.

The stored entry is now written even when it holds no rates, which is what
'remote configuration is off, use your own settings' looks like, so the
version survives that case too and the console can still see the client is
up to date with the change that turned them off.
Asking again when the page came back was unconditional. The poll spreads
requests across the ttl; coming back does the opposite, bunching them at the
moments people return to their tabs, which is the shape the endpoint copes
with worst — and the ttl throttle bounds the rate, not the shape.

It now happens only when the configuration says so, which is off by default.

The tests for it check the decision rather than counting requests: the poll
interval and the age at which settings go stale are the same duration by
construction, so any clock tick that makes them stale also fires the poll,
and a request count cannot tell the two apart. The previous test passed for
that reason rather than for the one it claimed.
setForcedSession() is the escape hatch for "collect this visitor now":
the application knows who needs debugging (its own allow-list, a support
flow), the SDK only provides the switch. A visitor that was not being
collected gets their empty session ended, and the next activity starts a
collected session with replay regardless of the sample rates; a session
already collected keeps running and gets replay recording forced on.
The forced state lasts for the page lifetime, so the application decides
on each page load whether to call again. Called before init, the call is
buffered and applied once the SDK starts.
The console can now publish a small bag of application-defined JSON
values alongside the sampling settings; the SDK stores it with them and
hands it to the host application verbatim through getRemoteConfig(),
never interpreting it. What a value means is entirely up to the
application's own code - a debug allow-list to pair with
setForcedSession(), a feature toggle. The bag is cached like the rates,
so the very first code to run on a page reads what the previous page
load fetched, including before the SDK starts; when the kill switch
turns remote configuration off, the bag goes with it.
beforeSampling is called synchronously each time a new session is about
to be drawn, with the rates that would apply (console-delivered,
falling back to init) and the console-delivered custom values; whatever
rate it returns is the one the draw uses. This is what turns delivered
data into sampling decisions without a wasted first draw or a session
restart: the console ships an allow-list or a cohort rule, the
application's own code interprets it right where the session's fate is
decided. Returning 100 or 0 makes the decision deterministic; a thrown
error or an out-of-range value leaves the incoming rate in place, so
the callback can never break session creation; a session already under
way is never re-decided. Precedence: init < delivered < beforeSampling
< setForcedSession.
…events

Events used to carry the init sampling rates even when the remote
settings or beforeSampling decided the draw, skewing server-side
extrapolation. Each draw now records the rates it actually used and the
remote settings version they came from; the session context reports
them on every event as _dd.configuration, with rc_version naming the
settings version so an audit can recover the exact configuration from
the version history.

The record is married to the session id on renewal and kept in
localStorage next to the settings cache, so a session restored on a
later page load still knows the decision it was created under; an id
mismatch makes a stale record inert. Sessions drawn without remote
configuration report nothing new — for them the init values are the
drawn values.

Also reformats remoteConfiguration.spec.ts, committed unformatted
earlier on this branch.
The server-sent ttl still wins on every response; this only paces the
retry after a fetch that never answered.
The rates only matter at the next draw, so the SDK now asks once at
start-up and once per session renewal, and stays quiet in between —
the rhythm the industry ships (fetch-at-init, no polling) and the one
that matches next-session activation exactly. The server's ttl field is
accepted and ignored, reserved for a future polling mode.

A failed fetch retries after 5s then 60s, both spread by ±20% so an
endpoint recovery is not greeted by the whole fleet at once, then gives
up until the next natural trigger — two extra requests per outage per
client, bounded. Conditional requests stay the HTTP stack's job: the
server pairs no-cache with an ETag, so the browser cache revalidates on
its own.

The storage key now carries a storage format version (_fc_rc_1_), so an
SDK upgrade keeps the cache and only a real format change orphans it.
The immediate-activation branch leaves with the poll it rode on: the
console no longer offers it, and the escape hatch is the public
stopSession().
Two more settings an operator can change from the console without the customer
shipping a release, and a rename of everything internal that still called this
channel "sampling" — it no longer carries only sampling. The init option is
unchanged (`remoteConfiguration`), as is the endpoint and the storage key, so
nothing a customer sees moves.

Both new values are latched at the draw, in the record that already remembers
what a session was drawn under and already survives page loads. That is not
decoration:

- the trace rate is a hash of the session id, so a rate that moved mid-session
  would flip a session between traced and untraced while it is still running;
- the privacy level is read on every node the recorders serialise, and one
  recorder captures it when it starts, so applying a change to a recording in
  progress leaves a single replay partly masked and partly not — and an upload
  cannot be masked afterwards.

`rule_psr` follows the drawn trace rate too. The backend extrapolates from that
field, so reporting the init value while drawing on a delivered one would put a
wrong number on every traced resource. That is what the sessionManager argument
threaded through resource collection is for.

An unrecognised privacy level is dropped rather than stored: an unknown value
reaching the recorders falls through to recording everything, which is the one
outcome nobody asks for by accident. A draw record written before these two
existed falls back to init, so an SDK upgrade mid-session changes neither.

Events are untouched — `_dd.configuration` names its fields one by one, so it
still carries the two session rates and rc_version and nothing new.

2724 unit tests pass (2717 before, 7 added). Both wirings were reverted in
place to confirm the new tests fail without them.
Both native SDKs already name this switch `remoteConfigurationEnabled`, and a
boolean reads better with the suffix than as a bare noun. Renamed before any
release so no integration has to change.
Both draw branches built the same five-field record behind the same guard, and
three of those fields were written out identically twice. They differ only in
the rates — forcing pins them, an ordinary draw uses what the console and the
application settled on — so that is all each branch says now.
A 200 was taken as proof that the body came from the configuration endpoint. A
captive portal, a misrouted proxy or a gateway error page can all answer 200
with something else, and the parsed result was stored either way — so a blank
record replaced a working one and the whole fleet fell back to its init settings
for as long as that lasted. A response is now stored only if it is recognisably
a configuration.

The server also stamps a schema version on every response, and a value this
build does not recognise means the payload changed in a way it could misread:
the response is discarded and the settings already in force are kept. This has
to ship in the first release that reads remote configuration at all — rejection
can only be performed by code already on the client, so a version introduced
later would be ignored by exactly the clients it needs to protect. A response
without the field is treated as compatible, since only a server predating the
field itself omits it.

Requests now carry sdk_version alongside sdk. Settings can then be targeted at
the clients running a particular build, which is not something that can be added
retroactively: the clients such a rule would have to match are already deployed.
The record of the draw that creates a session was held in a single
in-memory value and matched by id, so a page that did not perform that draw
had nothing to match: a second tab renewing onto a shared session, or an
event assembled after its own session was renewed, fell back to the init
values. One session was then traced at one rate in one tab and another rate
in the other, and events reported rates the draw never used.

The decision now lives in a time-indexed history beside the session contexts
it belongs to, and is adopted from storage whenever this page did not draw.
Its storage key drops the application version, so a deploy no longer orphans
the record of a session that is still running.

Recording a draw no longer depends on remote configuration being on. What
decides is whether the draw landed anywhere other than the init values, so a
`beforeSampling` override or `setForcedSession()` is reported under the rates
it actually used. A site that enabled none of this still writes nothing.

`rule_psr` is read at the time the request started rather than at the time
its event is assembled.

Also: `applied_version` is sent for a stored version of `0`, and rides inside
the forwarded request so it survives a `proxy`; a configuration fetch still
in flight when the SDK is stopped no longer schedules a retry.
Every history in the page garbage-collects itself on one shared timer, and
that timer stays registered for as long as a single history is alive. The
one the session manager creates had no stop, so it kept the timer registered
for the rest of the page — and for the rest of a test run, where a suite
that winds a fake clock forward by hours then has every minute of them
replayed. The unit suite takes 9s instead of 52s, and no longer risks a
runner's silence timeout on a slower machine.

It is stopped where the stub's watch of the host session already is.
…on the way in

A response is validated before it is stored, but what comes back out of storage
was handed on as-is. Storage is not ours alone: it outlives an SDK downgrade,
it is shared with everything else on the origin, and anyone can edit it in
devtools. A stored rate that is not a number therefore reached the arithmetic
that assembles every event, where the failure surfaces far from its cause
rather than as the absent value it should have read as.

Both readers now apply the same checks the fetch path uses. An unusable value
reads as "nothing was delivered", so the settings passed to init stay in force,
and a record whose rates are not rates is refused whole rather than latched
onto the session that will be read against it for as long as it lives.
The session manager carried its own copy of the range check while already
importing the identical one from the configuration module beside it. Two
definitions of the same rule is one more than can be kept in agreement.
The session store can compute a state and then discard the whole attempt: a
lock another tab corrupted makes it start over from what that tab left
behind. The draw of a discarded attempt stayed behind in `pendingDraw`, and
the attempt that replaces it may find a session that tab already drew, keep
it, and draw nothing — so the renewal that followed married a dead draw to a
session it had no part in creating, and wrote it to the record every other
tab reads. Every attempt now starts from nothing drawn.

Reproducing this needs the store's lock-corruption path, which is only
enabled on Chromium, so no test pins it.

Along the way: one name for "a session manager this page started and
therefore stops", which both of them now are, so the two branches that start
one differ only in which; and a test for the draw record's key, the one thing
in this feature nothing held to excluding the application version.
Settings are published under a number that only ever goes up — going back to
earlier settings publishes them again under a new, higher number — so a
response numbered below what is already stored can only be an older answer
arriving late: another tab's request that crossed this one, or a copy an
intermediary kept. It used to be applied, putting the client back on settings
the console had already replaced and reporting a version the console believes
nobody is running any more.

The comparison is against storage rather than a version held in memory,
because the two requests that can cross are two pages, and storage is the
only thing they share. A rollback still lands: it arrives as a higher number
carrying the earlier settings.
…a WebView

Two boundaries a reader of these options cannot infer from their names.

The settings cache lives in `localStorage`, which the SDK does not otherwise
touch by default: sessions are kept in a cookie unless `sessionPersistence`
says otherwise. Turning remote configuration on therefore adds a storage
surface the site did not have before, which a privacy review has to know
about. Where that storage is unavailable the feature simply stays off and the
values passed to init carry the page. Private browsing is not one of those
cases — storage works there and is cleared when the window closes — so only
the first session of each private visit starts on the init values.

The cookie is not offered as an alternative, for three reasons worth writing
down before someone asks again: the session store holds flat strings matched
against `[a-z0-9-]`, which fits neither a fractional rate nor the custom bag;
a cookie rides on every same-origin request, while this is read once per
session draw and never needed by the server, which sent it and already learns
the applied version from the request parameter; and a cookie would not rescue
the unavailable cases anyway, since partitioning and a block on site data
take cookies and `localStorage` together.

`setForcedSession()` gets the other one: under an event bridge the host
application owns the session, so only the recording half of it applies.
The key is built by joining the parts that decide the answer — site,
application, environment, application version — with `_`, a character
`encodeURIComponent` leaves alone. So the parts can run together: environment
`prod` with version `1_0` and environment `prod_1` with version `0` produce
the same key, share one cache entry, and read each other's rates. An
environment name carrying an underscore is ordinary enough that this is not
theoretical.

`|` is escaped to `%7C`, so it can only ever appear in the key as the
separator.
The SDK leaves its own traffic out of what it collects by looking for
`ddsource` and `ddtags` on the URL, and the settings request carried neither.
So it was indistinguishable from an XHR the page had made: a resource event
was filed for it, and the in-flight request counted towards the page activity
that decides when a view finished loading.

The first request of a page load happened to escape, because remote
configuration starts before the request collection that would have seen it.
Every later one did not — one per session renewal, plus each retry — so the
longer a visitor stayed, the more of the customer's own data was the SDK
talking about itself.

Both parameters survive a `proxy`, which encodes the whole query into
`ddforward`, since the match is on the URL as a substring. The tests assert
`isIntakeUrl` rather than the parameters themselves: what has to hold is that
the SDK recognises the request as its own, not how it does so.
Only `version` being a number stood between an answer and the cache, and
that is not enough to tell settings from any other JSON a 200 can carry.
A misrouted proxy or an appliance's own status page answering
`{"version": 20260831, "status": "ok"}` passed, and the damage was not
one wasted request: the write emptied the stored rates, and the number it
left behind sat above everything the console could ever publish, so every
genuine answer after it was refused as stale. Nothing recovered from that
on its own.

The whole envelope is checked now — the version is a plausible publish
counter, the kill switch is a boolean, and `rum` is an object when it is
there at all. The application's own `custom` bag stays outside that
judgement and is dropped on its own if it is malformed, so a mistake in
it cannot switch the platform's knobs back off.

The version is checked the same way on the way out of storage, which is
what keeps a single bad write from being permanent: anything this SDK
could not have put there reads as no version, and the console's answer
applies again.

Also stop passing `remoteConfigurationFetchTimeout` to `xhr.timeout`
unexamined. It takes an unsigned long, so a string lands as 0 and a
negative number wraps to weeks — both meaning the request never gives up,
and nothing clears the in-flight guard until one finishes, so a single
unusable value silently ended every later refresh on the page. An
unusable value now falls back to the default and says so, rather than
refusing init: this is a knob on a background request, and losing the
page's events over it would cost far more than it could save.

Records why the entries earlier deploys leave behind are not swept, since
the obvious sweep is worse than the leak: 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.
`rule_psr` says which rule the tracer drew under, and the backend
extrapolates from it, so a site that never configured trace sampling has
always sent no rule at all — `rulePsr` is undefined unless `init` was
given a rate, and there is a test pinning exactly that.

The drawn trace rate could not express it. It fell back to
`configuration.traceSampleRate`, which is 100 whether or not anybody
asked for one, so as soon as anything recorded a draw — remote settings,
`beforeSampling`, a forced session — every traced resource on such a site
started reporting `rule_psr: 1`, and the backend extrapolated from a rule
nobody wrote. The existing test did not catch it because its scenario
records no draw.

The drawn rate is now `number | undefined`, and undefined means no rule
reached this draw. `rulePsr` is what decides, since it is the built
configuration's own record of whether a rate was ever configured;
`traceSampleRate` cannot answer that question and reading it was the bug.
The tracer is unaffected — it already falls back to the built
configuration's rate, which is what it used before any of this existed —
so which requests carry trace headers does not change, only what the
event reports about them.

The expectations that read `traceSampleRate: 100` for configurations that
passed no rate described the old behaviour and now read undefined. Two
tests hold the line from both sides: a recorded draw with no rule still
sends no `rule_psr`, and a console-delivered rate of 0 still sends 0,
because turning tracing off is a decision the backend has to be told
about.

Adds the regression test the throwing `beforeSampling` case was missing.
It ran with init and the console agreeing at 100, so a `catch` that reset
the rates to the init values would have passed it; it now draws with the
two disagreeing, which is the only arrangement that can tell them apart.
…t the schema

`rc_version` is ours, on top of the shared RUM event schema, and the
whole `_dd` object was cast to make room for it — which also stopped the
fields that DO belong to the shared schema from being checked against it.
A rename upstream would have compiled and quietly emitted a dead field.

Declaring the addition on a local type keeps the rest checked, and leaves
no cast at all.
…ettings do not reach

`BeforeSamplingCallback` and `BeforeSamplingContext` could not be named
by a TypeScript site, so a shared helper around the callback had no type
to declare. They are exported now, alongside `RemoteConfigValues`.

Three limits were found while reviewing the branch and are written down
rather than papered over, because each needs a decision this change is
not the place to make:

- The settings and the record of a draw live in `localStorage`, which
  belongs to one origin, while a session need not: with
  `trackSessionAcrossSubdomains` the same session arrives on the next
  subdomain with no record waiting and is reported, traced and masked by
  the values that subdomain passed to init. Each subdomain also fetches
  and keeps its own copy.
- `setForcedSession()` sets a flag in the page that called it, but ends a
  session every tab shares. If the visitor acts in another tab first,
  that tab draws the replacement under the ordinary rates and the call
  quietly does nothing.
- The settings request is left out of what the SDK collects by carrying
  `ddsource` and `ddtags`, which survive a `proxy` given as a string. A
  `proxy` given as a function builds its own URL and may drop them. That
  is the same exposure the intake requests themselves already have under
  such a proxy, so it is noted rather than given a second, divergent
  mechanism.

Adds the changelog entry, including the one deliberate break: a
TypeScript site still passing `remoteConfigurationId` no longer compiles.
The privacy level a recording runs under is the most consequential line
of this branch — it decides whether a replay carries what a visitor
typed — and no test touched it. Removing the latch, or flipping which
side of the `??` wins, left the suite green. It is now asserted from both
directions, because either one alone would also pass if the drawn value
were ignored and the init value happened to agree.

`beforeSampling` runs inside session creation, where nothing can report a
failure, so a value that is not a function is refused at init instead.
That branch had no test either.

Notes what the session manager mock does differently from the real one:
it collects the visitor on the spot and keeps the session id, where the
real `setForcedSession()` has to end the session and draw again at the
next interaction, under a new id. A consumer test written against the
mock should not conclude otherwise.
The comment left it open whether the key needs the release a site is
running, and treated dropping it as a way to stop the entries piling up.
It is not one: settings can be targeted at a release, so two releases of
a site that are live at once are entitled to different rates, and one
entry between them would have each overwrite the other's on every fetch —
for as long as both are being served, not just across a deploy.

So the entry per deploy stays, and the note now says what a real fix
would need instead: a way to know no page is still reading an entry, for
which an age written beside the values and swept well past the session
timeout is the shape to reach for.
A dimension the settings can be varied on is added in three places: the
SDK reports it, the server accepts it as a match key, and the storage key
separates on it. Forgetting the third is silent — two clients entitled to
different answers share one entry and overwrite each other's on every
fetch — and nothing here would have noticed.

Derived rather than listed, so it needs no upkeep: if changing a field
changes the request, the server can vary its answer on it and the key has
to change too. A field the request does not carry is free to be left out.
The converse is deliberately not required — a key finer than the server's
targeting costs an extra entry, never a wrong one.

Checked by adding `service` to the request without keying by it, which is
the shape the mistake would take: the test names the field and says why.
The envelope check accepted a body on `version` and `enabled` alone,
which is also the shape of an ordinary health or feature-flag payload —
`{"version": 1725072000, "enabled": true, ...}`. Taking one of those for
settings blanks the rates and leaves behind a number no later publish can
climb over, with no way back. The schema stamp is what actually tells a
configuration from any other JSON, so it is now required rather than
treated as compatible when absent. That allowance only ever protected a
server older than the field, and this endpoint has carried it since it
existed.

The test meant to cover the absent case never did: it built the body
through a helper whose destructuring default turns an explicit
`undefined` back into 1, so it was stamping a schema version while
claiming to omit one. Replaced with a body written out in full, which
fails if the requirement is relaxed again.

Also fixes a regression from the previous commit: `rum: null` was being
refused outright, though the field's own contract says absent means
empty, and serializers write `null` for an empty optional struct. Left as
it was, a server-side serialization change would have frozen a whole
fleet on the settings it already held — the same failure this line of
work exists to prevent.

And tightens the fetch timeout bounds to `xhr.timeout`'s own. It takes an
unsigned long, which truncates and then wraps modulo 2^32, so `0.5` from
someone thinking in seconds truncates to 0 and 2^32 wraps back to it —
both meaning no timeout at all, which is exactly what leaves the
in-flight guard set and silently ends every later refresh on the page.
… mock

Three claims did not match the code:

- The `remoteConfigurationEnabled` doc said the SDK does not otherwise
  touch `localStorage`, so a reader would conclude that leaving the
  option off means no access at all. It does not: the record of the
  sampling draw is read on every site at start-up and at each new
  session, and written whenever a draw lands off the init values. The
  option adds a second entry and the request that fills it. Stated
  precisely, since this is the sentence a privacy review reads.
- `setForcedSession()` said a call defeated by another tab "has no
  effect". True of that replacement session, not of the call: the page
  stays forced, so a session it later draws itself is collected.
- The session manager mock's warning missed a case. A session collected
  WITHOUT replay keeps its tracking type and only gains forced replay, so
  it reports FORCED where the mock reports SAMPLED — which is what
  `sampled_for_replay` on the events is derived from.

Drops the `RemoteConfigValues` export, which no public signature
references and which is absent from the packages users install, so it
committed the published surface to an internal storage shape for
nothing. `BeforeSamplingCallback` and `BeforeSamplingContext` stay: they
are needed to type the init option.

Adds the fetch timeout option to the changelog, and a note that this
release reads and writes `localStorage` on every site.
…h it

Building the url runs the application's own code when `proxy` is a
function, and `open` refuses a url that function may return. Both throw
synchronously, and nothing caught them.

Where that lands is the problem. At start-up this call sits in `startRum`
ahead of everything that collects, so a throw skipped the batch, the
views, the errors and the resources — the whole page's RUM, silently,
because `init` is monitored. On every session renewal it sits in a
lifecycle notification, and those have no per-subscriber isolation, so
the same throw skipped every subscriber registered after it. A settings
request is not worth either.

It now ends the attempt the way a network failure does, which also keeps
the in-flight guard from sticking — left set, it would have dropped every
later refresh on the page. Constructing the XMLHttpRequest is guarded the
same way, for a page that has replaced the global with something unusable.
The record of a session's sampling draw is read on every site, and it
carries a privacy level and a trace rate. Neither can be moved by
anything a site that opted out has available: `beforeSampling` and
`setForcedSession()` shape the rates and reach neither. So on such a site
a stored value differing from `init` was never written by this SDK — and
honouring it meant any same-origin script could take a site that asked
for `mask`, and enabled nothing, into recording a replay unmasked, with
one invisible storage write. Before this feature that downgrade needed
the SDK re-initialised, which is neither invisible nor undisruptive.

Both values are now read back only where they could have been delivered.
Opted in, the console may still relax masking — that is the feature, and
the option's documentation now says so plainly, since it is the
consequence a privacy review has to weigh.

An unusable `beforeSampling` also stops refusing `init`. It is one
callback consulted at the draw; refusing took the site's entire
collection down over it, which no other bad input on this feature does.
It is reported once and ignored.

Documents two behaviours found while reviewing this for compatibility:
`beforeSampling` runs inside the session lock, so it may be called more
than once for one session and a slow callback delays the site's other
tabs; and under a `proxy` function that drops the query parameters the
settings request counts towards view loading time, not merely towards
resource events.
…d risky

The previous guard left the three listener registrations between its two
halves. Registering a listener calls a method the page's replacement for
`XMLHttpRequest` may not have — the same threat the constructor was
guarded against — and a throw there reached the same two stacks: the one
inside `startRum`, where everything that collects comes after, and the
lifecycle notification, whose remaining subscribers would be skipped.

The exchange is one try now, and the callback answers at most once, so a
replacement that both dispatches an event and throws cannot be counted as
two attempts and leave a retry timer nobody owns.

A throw is also said out loud, unlike a network failure. A request that
could not be sent means the page or the application broke it, and there
was nothing to tell that apart from an endpoint merely being down.

Also gates the stored settings version the same way the privacy level and
trace rate already are. By the same reasoning — only settings can have
written it — a site that opted out was putting a version onto every event
that no delivered configuration ever stood behind, and that number is
exactly what an auditor uses to look those settings up.
…orce

The settings request travels to `/api/v2/rum/config`, which is not one of
the intake paths. A proxy that forwards whatever arrives is unaffected;
one that checks the forwarded path against a list of known intake paths
rejects every settings request, and the only symptom is that the console
appears to have no effect — the SDK carries on with the values passed to
`init`, exactly as designed, which is what makes it hard to recognise.

`setForcedSession()` also promised Session Replay unconditionally. The
slim build has no recorder, so there the visitor is collected without one.
The record of a sampling draw holds the id of the session it describes.
Withdrawing consent is the one moment the SDK promises that id stops
existing — the session store is rewritten without it — but nothing
removed this copy, and `localStorage` does not expire on its own. So the
id of the last session a visitor had stayed on their device after they
asked to stop being tracked, indefinitely, where a consent audit finds it.

Only on withdrawal, and deliberately not when a session merely expires.
Sessions expire and renew constantly, and the tab that notices an expiry
is not always the tab that drew what replaced it: removing the record
there lets a page still polling delete what another page has just written
for the new session, putting every tab back on its own settings. There is
a test for that hazard, and it fails if the removal is moved.

A withdrawal has no such successor — nothing is meant to be adopted after
it — which is what makes it the safe place to do this.
Only `load`, `error` and `timeout` were listened for. A request aborted
by the page — `window.stop()`, a navigation, a page restored from the
back/forward cache with its request already dead — fires none of them,
and the timeout cannot save it because it does not tick while a page is
frozen. The in-flight guard then stays set for the life of the page, and
every later refresh is dropped: settings frozen, no retry, and nothing to
tell it apart from a console change that simply has not propagated.

`loadend` is the event that always arrives. The answer is delivered at
most once, so on an ordinary response it is inert.

Also reads the stored settings version back through `isVersion`, like
every other version in this feature. It was the one place still asking
only for a number, and it feeds `rc_version` — the field the events carry
so an auditor can look those settings up.
A session is about to stop being trusted when it cannot prove it is still
within bounds: a stored state holding an id but no creation date is read
as expired rather than assumed young. These fixtures stand in for a
session that already exists, and they carry no stamps, so every one of
them would be read as expired the moment that lands — eight of the specs
here fail on the merge, all of them on a session that is suddenly not
there.

Stamped the way a real session is stamped, they satisfy both rules: the
current one, which lets an absent stamp pass, and the stricter one, which
requires it. Written to match the change that introduces the rule
character for character, so the two land on the same lines without
conflicting.
Brings in the v0.1.1 release line, whose session expiry fix lands on the same
files this branch rewrites.

The only conflict was CHANGELOG.md, where both sides opened a new section at the
top: this branch's Unreleased notes and the released v0.1.1 entry. Kept both,
Unreleased above v0.1.1, and dropped the horizontal rule this branch had put
between its section and the one below — no other version boundary in the file
carries one.

packages/rum-core/src/domain/rumSessionManager.spec.ts merged on its own: the
fixtures here were already stamped to satisfy both the old rule and the stricter
one the expiry fix introduces.
@Fiona2016
Fiona2016 merged commit ebcab6d into publish Sep 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant