fix(session): expire a session that cannot prove it is still within bounds - #33
Conversation
Mirrors upstream datadog/browser-sdk c2bba2d ("Remove old cookie migration", 2026-02-18), same three files, same 121 deletions. The migration read the legacy `_dd` / `_dd_r` / `_dd_l` cookies and folded them into today's single `_dd_s`. Nothing has written those cookies since 2020-04-07 (upstream DataDog#342), and this SDK line never did: `_dd_s` is already the store key in v0.0.1. tryOldCookiesMigration is not exported from any package entry, so nothing outside the cookie strategy can reach it. It also mattered for a second reason. It was the only place that set `session.id` without stamping `created`, and since `created` is only written when a new id is generated, a session that came through it could never acquire one -- which is exactly the state the next commit stops trusting.
…ounds
A session was kept alive whenever its stamps were missing, because both
comparisons short-circuited to `true` on `undefined`:
(created === undefined || dateNow() - Number(created) < SESSION_TIME_OUT_DELAY) &&
(expire === undefined || dateNow() < Number(expire))
So a stored state holding an id but no `created` cleared the timeout, and one
without `expire` cleared the inactivity deadline. `created` is only written
in the step that generates a new id, so such a state could never acquire one
and the session went on indefinitely -- surviving idle gaps of hours on a
page that was never closed, well past both bounds.
Both bounds now collapse into one effective deadline, min(expire, created +
SESSION_TIME_OUT_DELAY), and a state that cannot produce one is expired
rather than assumed young. This also caps a deadline written by a clock that
was running ahead, which would otherwise hold a session open until that
error had elapsed for real.
Taken from upstream DataDog/browser-sdk 5257b52 ("fix session lifetime
bugs for long-lived pages and multi-tab scenarios", DataDog#4531). Their
SessionManager rewrite makes the commit itself unmergeable here, so only the
rule is carried over, with one deliberate difference: upstream stamps every
started session, while here only tracked sessions get an id and a `created`,
so a session with no id is still judged on `expire` alone.
expandOrRenewSessionState now stamps the deadline before returning.
processSessionStoreOperations decides whether the session is expired
immediately after it runs and only stamped it afterwards, so a session
renewed there would be judged with no deadline at all and no session could
ever establish itself.
Specs that asserted the old contract are updated: a stored session with no
stamps used to be treated as live, and fixtures that stood in for an existing
session now carry the stamps a real one has.
Review follow-up. The first pass closed the escape for tracked sessions and left it open for the other half. `created` is now stamped on every started session, not only on the one that mints an id. A not-tracked session carried no creation date, so the cap had nothing to count from while the visibility timer pushed its deadline forward once a minute: on a page that stays open it never expired. The practical effect is that a user sampled out of a long-lived page never got to re-run the sampling draw, because every renewal read the stored `rum=0` back. The id-less fallback in getExpireDate stays. Its job is not this case but mixed builds: a bundle that predates the stamp keeps writing state without one, and expiring those on sight would have old and new builds take turns ending each other's sessions. The legacy bundle carried its own copy of the same fail-open check, and its own header promises "same cookie name, same serialisation, same expiration rules". After the first pass that promise was false: the modern bundle stopped trusting a state with no creation date while the legacy one would adopt it and refresh its deadline forever, so any page loading the legacy build could resurrect exactly the sessions this change ends. Its rule now mirrors the modern one, and it stamps what it adopts. Tests: a not-tracked session must be stamped; the legacy build must not adopt a session with no creation date, and must stamp a sampled-out one it adopts. Each was checked against the unfixed source first — all three fail without the change. One existing test is repaired rather than moved: "should renew an existing timed out session" set a fixture with no `expire`, so after the first pass it was passing for the mundane reason of having no deadline at all, and the cap it names was never exercised.
Review round: two gaps closedThe first pass shut the escape for tracked sessions and left it open for the other half. 1. Not-tracked sessions had no cap. The id-less fallback in 2. The legacy bundle carried its own copy of the same fail-open check. Its header promises "same cookie name, same serialisation, same expiration rules" — after the first pass that promise was false: the modern bundle stopped trusting a state with no creation date while the legacy one would adopt it and refresh its deadline forever. Any page loading the legacy build could resurrect exactly the sessions this PR ends. Its rule now mirrors the modern one, and it stamps what it adopts. 3. One existing test was repaired. Three regression tests added. Each was run against the unfixed source first; all three fail without the change.
|
Problem
isActiveSessiongave a session the benefit of the doubt whenever its stamps were missing, because both comparisons short-circuit totrueonundefined:A stored state holding an id but no
createdtherefore cleared the hard timeout, and one withoutexpirecleared the inactivity deadline.createdwas only written in the step that generates a new id, so such a state could never acquire one: the session stayed usable indefinitely.This only shows up on pages that are left open.
trackVisibilityextends the session once a minute for as long as the page is visible, with no user interaction needed, so the inactivity deadline never fires there and the hard cap is the only bound left. When that bound is short-circuited, nothing ends the session at all.Fix
Both bounds collapse into a single effective deadline,
min(expire, created + SESSION_TIME_OUT_DELAY), and a state that cannot produce one is expired rather than assumed young. This also caps a deadline written by a clock that was running ahead, which would otherwise hold a session open until that error had elapsed for real.Taken from upstream DataDog#4531 ("fix session lifetime bugs for long-lived pages and multi-tab scenarios"). Their SessionManager rewrite makes the commit itself unmergeable here, so only the rule is carried over.
createdis stamped on every started session, tracked or not. A not-tracked session used to carry no creation date, so the cap had nothing to count from while the visibility timer pushed its deadline forward once a minute — the same escape, left open for the sampled-out half. The practical effect was that a user sampled out of a long-lived page never got to re-run the sampling draw, because every renewal read the storedrum=0back.The id-less fallback in
getExpireDateremains, for mixed builds rather than for sampling: a bundle predating the stamp keeps writing state without one, and expiring those on sight would have old and new builds take turns ending each other's sessions.expandOrRenewSessionStatenow stamps the deadline before returning.processSessionStoreOperationsdecides whether the session is expired immediately after it runs and only stamped it afterwards, so a session renewed there would have been judged with no deadline at all and no session could ever have established itself. Upstream does the same inside its ownexpandOrRenew.The legacy bundle is brought in line
packages/rum-legacycarried its own copy of the same fail-open check, and its header promises "same cookie name, same serialisation, same expiration rules". Without this, that promise would have become false: the modern bundle stops trusting a state with no creation date while the legacy one would adopt it and refresh its deadline forever, so any page loading the legacy build could resurrect exactly the sessions this change ends. Its rule now mirrors the modern one, and it stamps what it adopts.Also removed: the pre-2020 cookie format migration
Mirrors upstream DataDog#4198, same three files. The migration read the legacy
_dd/_dd_r/_dd_lcookies and folded them into today's single_dd_s. Nothing has written those cookies since 2020 (upstream DataDog#342), this SDK line never did (_dd_sis already the store key in v0.0.1), andtryOldCookiesMigrationis not exported from any package entry.It also mattered for the fix: it was the only place that set
session.idwithout stampingcreated, so it was the one code path that could produce the state this PR stops trusting.Behaviour change worth knowing
A page that stays visible now reaches the 4h cap and its session expires there. Renewal is driven by real user interaction (
trackActivitylistens for click / touchstart / keydown / scroll); the visibility timer can only extend a session that is still alive. So an unattended always-on screen expires at 4h and does not start a new session until someone interacts with it. This matches upstream's behaviour after the same fix.Worth calling out in the release notes: for a page that had been holding one session open indefinitely, the symptom of the fix — data stopping four hours in — reads like a regression unless it is explained.
Test changes
Specs that asserted the old contract are updated: a stored session carrying no stamps used to be treated as live, and fixtures that stood in for an existing session now carry the stamps a real one has. Upstream flipped the same assertions in their change.
One existing test is repaired rather than moved.
should renew an existing timed out sessionset a fixture with noexpire, so it would have passed for the mundane reason of having no deadline at all, and the cap it names was never exercised.Three regression tests added: a not-tracked session must be stamped; the legacy build must not adopt a session with no creation date, and must stamp a sampled-out one it adopts. Each was run against the unfixed source first — all three fail without the change.
Verification
yarn test:unit— 2867 passing, 0 failingyarn formatandyarn lintcleangetExpireDatereturning nothing when a required stamp is absent, and the legacy bundle's handling of all of the above