Skip to content

feat(rum): end the session when new settings decide its fate - #40

Merged
Fiona2016 merged 7 commits into
publishfrom
feat/end-session-on-new-settings
Sep 1, 2026
Merged

feat(rum): end the session when new settings decide its fate#40
Fiona2016 merged 7 commits into
publishfrom
feat/end-session-on-new-settings

Conversation

@Fiona2016

Copy link
Copy Markdown
Collaborator

Replaces #31, which was stacked on feat/remote-sampling-configuration and targeted that branch.
That branch has since landed on publish, so the same three commits are replayed here on top of
it, with the conflicts against what publish gained in the meantime resolved and four further
commits on top.

Why

Settings published from the console apply to sessions created after they arrive, and to nothing
else. A session ends after fifteen minutes without activity, or four hours outright, so for a
visitor who never goes idle — a support console, a trading screen, a wallboard — the change can be
hours away. Those are exactly the visitors who produce the most data, so a change made to stop a
flood reaches the flood last.

What it does

Three changes no longer wait, and they are exactly the three whose effect on the running session
can be told without drawing again:

Change Current session Action
session sample rate → 0 being collected end it
session sample rate → 100 not being collected end it
defaultPrivacyLevel gets stricter being collected end it
any other rate either leave it
only the trace rate or the replay rate changed either leave it

Ending is the whole point, rather than flipping the running session: the old session is collected
to its end as it was begun, so no replay is masked in one half and plain in the other, and no
session is invented that begins in the middle of a visit. The visitor's next action starts a new
session, which draws on the settings that just landed.

0 and 100 are the only rates that can decide anything, because they are the only ones whose
outcome needs no lottery. For any rate in between there is no answer to "should THIS session have
been kept" — only a second draw could produce one, and drawing twice turns a rate p into
with nothing to show for it. A privacy level that gets looser deliberately waits too: the delay
is what leaves room to undo a mistake, and what it costs meanwhile is more of the data already
being collected.

The custom bag is not in the table because it has no meaning of its own here. It reaches the
decision through beforeSampling: a callback that answers 0 or 100 for the values just published
ends the session exactly as a published rate would, which is the point of asking it away from a
draw.

Each rule carries its own precondition

Two of the three are about what is being recorded, so both are gated on the session actually being
collected, and getting that wrong is not a small mistake:

  • A stricter privacy level only ends a session that is being collected. A sampled-out visitor
    records nothing, so a stricter level has no plaintext to catch there — and no draw record either,
    because a session that is not collected is given no id and so no record is kept of what it was
    drawn under. The comparison would fall through to the init value on every announcement and keep
    answering "tighter", ending one empty session after another for as long as the visitor stayed.
  • The exemption for a forced session applies while it is collected. Ending a forced session on
    a rate would ordinarily produce the same session again, which is why the exemption exists. But a
    forced page can adopt a session drawn by a tab that never forced anything, and there a rate of
    100 has something to change — it is precisely the draw the page asked for.

How it is wired

store() reports whether the response advanced what is in storage, and the fetcher emits
REMOTE_CONFIGURATION_STORED only when it did. A stale response, a failed write and a response
repeating the version already held all stay silent — that last one is the ordinary answer, since
every new session refetches and most find nothing new, and a subscriber woken by those would end a
session per renewal.

The comparison lives in the session manager, which already holds the record of what each session
was drawn under and the ability to end one. Remote configuration needs no reference to the session
manager.

What it compares is what the running session was drawn under against what a draw would use now.
That is not the previously stored settings: settings are stored while a session runs, and the
session was drawn on whatever was stored before that. Ending the session is precisely what makes
that difference disappear, which is why this needs no "only interrupt once per version"
bookkeeping to be idempotent — the same response arriving again, in another tab, on a retry or
after a reload, finds nothing left to act on.

Known limitation

Settings are still fetched only at start-up and on session renewal. A visitor who never reloads
therefore hears of a publish at their next session boundary, at most four hours away under the
session cap — this halves the worst case rather than removing it. Any tab they open, or any reload,
fetches at once and ends the session every tab shares. The changelog states this plainly rather
than leaving an operator to discover it.

Notes for integrators

beforeSampling is now also called away from a draw, to resolve which rate newly delivered
settings would actually apply. The documentation now asks for a callback that is free of side
effects and answers the same way for the same input. Both were already implied by it running inside
session creation; neither was written down.

Release notes

CHANGELOG.md gains an ## Unreleased section. It leads with the symptoms that will read like a
regression on publish day — session counts up, average session length down, a replay ending
mid-visit, collected volume climbing the same day on a rate of 100 — because without that, the
first report will be that an SDK upgrade doubled someone's session count.

Testing

format, lint, typecheck, build, check-packages, test:compat:tsc and test:compat:ssr
are all clean, and the unit suite is green at 2985.

Twenty-nine new unit tests cover each rule and each non-rule. Every one was checked by mutating the
implementation and confirming the test fails: neutralising the decision, widening it to fire on any
change, removing either guard, dropping the fallback to the init privacy level, resolving the rate
without beforeSampling, bailing out when the response carries no rate, and announcing a response
that repeats the stored version — each is caught by the test written for it.

The rate a session is drawn on is the console's value falling back to
init, with the application's beforeSampling given the last word. That
resolution was written inline in the only branch that draws, which is
fine as long as a draw is the only thing that needs to know the answer.

Move it into a function that resolves and never draws, so the same
question can be asked without spending a lottery ticket to find out.
No behaviour changes.
Settings published from the console applied to sessions created after
they arrived, and to nothing else. For a visitor who never goes idle
that is hours: a session ends after fifteen minutes without activity or
four hours outright, so the change everyone is waiting on reaches the
people generating the most data last.

Three changes cannot wait, and they are exactly the three whose effect
on the running session can be told without drawing again:

  - a session sample rate of 0 while the visitor is being collected;
  - a rate of 100 while they are not;
  - a stricter defaultPrivacyLevel, where every further second recorded
    is a second of plaintext uploaded that masking cannot reach back for.

Each of them ends the current session; the visitor's next action starts
a new one under the new settings. Ending rather than flipping is the
point: the old session is collected to its end as it was begun, so no
replay is masked in one half and plain in the other, and no session is
invented that starts in the middle of a visit.

No other rate says anything about whether THIS session should have been
kept. Only a second draw could, and drawing twice quietly turns a rate p
into p², so every other change waits for the next session — a loosening
privacy level included, where being slow is what leaves room to undo a
mistake.

It needs no bookkeeping to stay idempotent: what it compares is what the
session was drawn under against what a draw would use now, and ending
the session is exactly what makes that difference disappear. The same
response arriving again, in another tab or after a reload, finds nothing
left to act on.

beforeSampling is now called outside a draw as well, to resolve the rate
that would actually apply, so the documentation asks for a callback free
of side effects and stable for the same input.
…ering

A const enum's values are inlined at build time and every entry after an
insertion shifts, so an entry wedged into the middle of a list that is
otherwise upstream's is both a renumbering and a conflict on the next
upstream merge. Move it to the end.

Also drop a guard that restated its caller's precondition: the event is
only ever emitted by the fetcher, which does not exist unless the site
opted in, and reading the settings already answers with nothing when it
did not.
A session that is not being collected is given no id, so no record of its
draw is kept and the privacy level it was drawn under cannot be read back.
The comparison fell through to the init value on every announcement and kept
answering "tighter", so once an operator tightened `defaultPrivacyLevel` from
the console, every sampled-out visitor was put on a loop: end the session,
renew on the next click, refetch, end it again. It bought no privacy either
-- a visitor who is not collected records nothing, so a stricter level has no
plaintext to catch there.

The rule now carries its own precondition and applies only while the session
is being collected, which is also the only state in which a recording exists.

Its fuel was the announcement firing on settings that had not changed:
`store()` answered "stored" for a response repeating the version already
held, which is the ordinary answer, since every new session refetches and
most find nothing new. It now answers whether the stored version actually
advanced.

Three tests, each checked against the unfixed source first: a sampled-out
session is left alone when the level tightens, it is still left alone as
further settings arrive, and a response repeating the stored version is not
announced.
Three paths the implementation documents had no test standing on them, each
found by mutating the source and watching the suite stay green:

- A session drawn before any settings arrived. A draw that lands exactly on
  the init values records nothing, so the level such a session runs under can
  only be read back off init -- the fallback every existing privacy test
  stepped around by storing settings before starting. Deleting that fallback
  passed the whole suite.
- A response that carries no rate at all, with `beforeSampling` turning the
  delivered custom values into the decision. This is the "called away from a
  draw" contract, and both resolving the rate without the callback and
  bailing out when the console sends no rate passed the whole suite.
- The console's kill switch, which stores a version and nothing else and so
  puts the rates back to the ones init passed. That is a change like any
  other, and where init never collected it is the decisive one.

Also renames the opt-out test to what it actually pins down. Its store key is
one no implementation could derive, so it cannot witness the store being left
alone; what it does witness is the decision surviving an undefined
`remoteConfig`.
…cts nothing

The exemption that keeps a rate from ending a forced session was written for
the case where ending it changes nothing: the page collects this visitor
whatever the console says, so the replacement session would be the same
session again. That reasoning runs out when the session is not collected.
A page can adopt one drawn by a tab that never forced anything, and there a
rate of 100 has something to change -- it is exactly the draw the page asked
for. The guard now carries the precondition its own reasoning rests on.

Also corrects two claims in the changelog entry that the code does not make
good on: custom values do not always wait for the next session, since
`beforeSampling` can turn them into a decisive rate -- the flagship pattern
for this feature, and something the suite already pins down -- and the
session after a split carries a new recording only if its draw keeps one.
@Fiona2016
Fiona2016 merged commit f02d659 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