Skip to content

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

Open
Fiona2016 wants to merge 16 commits into
publishfrom
feat/remote-sampling-configuration
Open

feat(rum): let the console set the sampling configuration#30
Fiona2016 wants to merge 16 commits into
publishfrom
feat/remote-sampling-configuration

Conversation

@Fiona2016

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.

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.
  • 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.
  • 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. With immediate
    activation the running session ends and a new one starts under the new values,
    rather than the running session 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.
  • 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.

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

  • 2724 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.
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