diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c63ede6e4..170b09cac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,34 @@ --- +## Unreleased + +- ✨ A session sample rate published from the console that rises above 0 now ends the running + session of a visitor whose session was drawn at 0, so collection starts at their next interaction + instead of waiting for that session to end on its own — up to four hours. This is the case where + waiting shows an operator who has just switched collection on nothing at all, which is + indistinguishable from a broken integration. It joins the two changes that already did not wait: + a stricter Session Replay privacy level, and a rate of 0. Nothing here happens without + `remoteConfigurationEnabled: true`. +- šŸ“ Only a session drawn AT 0 is re-drawn, not every session that is not being collected. Those + are different populations: a visitor who lost a draw at 30 had a coin flipped for them, and + re-rolling the losers while the winners keep their sessions would put the real rate above the + published one. While a rate of 0 is in force nothing is collected and no coin is flipped, so + re-drawing everyone lands exactly on the new rate. A rate rising from one real value to another + therefore still waits for the next session, as before. +- šŸ“ The rate a sampled-out session was drawn at is now recorded alongside the one a collected + session was drawn at, in the same single `localStorage` entry this SDK already keeps for the + draw. No new entry, no extra request. Without it a page that did not perform the draw — the + second page of a visit, or another tab — could not tell the two populations above apart. The + decision reads that record straight off storage rather than off what the page last saw of the + draw: two sampled-out sessions look alike to the session store, so a tab can miss another tab + ending one and drawing the next, and storage is the one place the current draw is always found. +- šŸ“ What you will see on the day you lift a rate off 0: visitors who were invisible start + appearing within seconds of loading a page rather than at their next session, so collected volume + climbs the same day rather than the next. That is the change taking effect, not a defect. + +--- + ## v0.2.2 - šŸ› The settings cache no longer grows by one entry per release of your site. Entries are keyed by diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 9ea4ca9ea7..f449070d4f 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -93,14 +93,22 @@ export interface RumInitConfiguration extends InitConfiguration { * values passed here, so they can be changed without releasing a new version of this site. * * A change applies to sessions started after it arrives, and a session already under way is never - * re-decided in place. Two changes do not wait for that session to end on its own, because their - * effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a session - * sample rate of 0. Both apply only while the visitor is being collected — one who is not records - * nothing and sends nothing, so neither has anything to act on there. Either ends the current - * session, and the visitor's next action starts a new one under the new settings; the old session - * is collected to its end as it was begun, so no recording is left masked in one half and plain - * in the other. Every other change waits for the next session, a loosening privacy level and a - * rate rising to 100 included — for "collect this visitor now" there is `setForcedSession()`. + * re-decided in place. Three changes do not wait for that session to end on its own, because + * their effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a + * session sample rate of 0, both while the visitor is being collected — one who is not records + * nothing and sends nothing, so neither has anything to act on there — and a rate above 0 for a + * visitor whose session was drawn AT 0, who was never in a draw at all and now could be. Any of + * the three ends the current session, and the visitor's next action starts a new one under the + * new settings; the old session is collected to its end as it was begun, so no recording is left + * masked in one half and plain in the other. + * + * Every other change waits for the next session, a loosening privacy level included, and so does + * a rate rising from one real value to another: only a second draw could say whether a session + * drawn at 30 should have been kept at 80, and drawing twice turns a rate p into p². Re-drawing + * only the visitors who are not collected would spare the winners and re-roll the losers, which + * lifts the real rate above the published one. A rate of 0 is the one value with no winners to + * spare, which is why leaving it is decidable and leaving 30 is not. For "collect this one + * visitor now" at any rate, there is `setForcedSession()`. * * How soon "does not wait" is depends on when this client next hears of the change, and it hears * only at page load and at each new session. A visitor who keeps loading pages hears within diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 40878e2db0..8cf789409e 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -853,7 +853,51 @@ describe('rum session manager', () => { return getSessionState(SESSION_STORE_KEY).isExpired === '1' } - describe('the two changes it can decide on its own', () => { + describe('the three changes it can decide on its own', () => { + it('ends a session drawn at zero when the rate rises above it', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Nothing was collected and no coin was flipped, so re-drawing this visitor lands exactly + // on the new rate — and until it happens an operator who has just switched collection on + // sees nothing at all, which is indistinguishable from broken. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn at zero even when the new rate is a partial one', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn on an init rate of zero when the first settings deliver a rate', () => { + // Nothing in storage yet, so this session was drawn on the init values — and a draw landing + // exactly on them records nothing, which is why zero can only be read back off init here. + // This is the application that never collects until the console says so. + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('collects the session that follows a rate lifted off zero', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + it('ends a session being collected when the rate goes to zero', () => { storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -946,25 +990,17 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('leaves a session that is not being collected alone when the rate goes to a hundred', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) + it('leaves a session that lost a draw at a real rate alone when the rate rises', () => { + // The regression this exists to catch: re-drawing every session that is not collected, + // while leaving the collected ones alone, spares the winners and re-rolls the losers — a + // fleet drawn at 30 and moved to 80 would come out well above 80. Only a session drawn at + // zero has no winner beside it to spare. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) startWith({ sessionSampleRate: 0 }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - // The one rate whose outcome could be asserted and deliberately is not: `setForcedSession` - // already covers "collect this visitor now", raising volume unannounced is the one - // direction that surprises, and nothing about it is urgent. - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(isSessionEnded()).toBeFalse() - }) - - it('leaves a session that is not collected alone when the rate merely rises', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) - - deliver({ version: 2, sessionSampleRate: 30 }) + deliver({ version: 2, sessionSampleRate: 80 }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(isSessionEnded()).toBeFalse() @@ -984,9 +1020,9 @@ describe('rum session manager', () => { }) it('does not end one sampled-out session after another as settings keep arriving', () => { - // A session that is not collected is given no id, so no record of its draw is kept and the - // level it was drawn under cannot be read back. Ending it would not change that, so acting - // on the comparison would end every session this visitor is ever given. + // Nothing is recorded for this visitor, so a stricter level has nothing to catch however + // many times it is announced. The rate stays at zero throughout, so the one rule that does + // act on a sampled-out session finds nothing to act on either. storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) @@ -1074,6 +1110,116 @@ describe('rum session manager', () => { }) describe('what it compares', () => { + it('does not answer for a sampled-out session with the record of the one it replaced', () => { + // A page that draws owns the record slot. Having drawn on the init values it has nothing to + // record, and leaving the previous session's record there would let it answer for this one: + // every sampled-out session is recorded under the same id, so unlike a collected session it + // cannot tell that the record describes somebody else. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // The settings entry is gone — swept as belonging to a release nobody runs any more — so + // the draw that follows uses the init rate and has nothing to record. It loses too, so it + // is a sampled-out session that did not write the record it would be read under. + localStorage.removeItem(STORE_KEY) + expireCookie() + const secondPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + secondPage.stop() + stopSessionManager() + + // A third page restores that session instead of drawing one, so the record is the only + // thing it can read the draw off — and the only record left would be the first session's. + startWith({ sessionSampleRate: 50 }) + expireSessionSpy.calls.reset() + + // Read off the first session's record this one looks drawn at zero and is re-drawn; read + // off init, which is what it was actually drawn at, it lost a draw at fifty and stays. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('reads the rate a sampled-out session was drawn at back through storage', () => { + // The case that decides whether any of this reaches a real visitor: they were drawn at zero + // on the page before, and the page acting on the change never performed that draw. A + // sampled-out session is given no id, so its draw is recorded under one no session can + // hold — without that record this page falls back to the init rate and answers wrongly. + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // A second page load restores the same session without drawing anything of its own. Init + // says 50 here on purpose: falling back to it would read this session as one that lost a + // draw and leave it alone, which is the answer the record exists to correct. + startWith({ sessionSampleRate: 50 }) + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('reads the rate off storage rather than off the draw this page last saw', () => { + // Two sampled-out sessions look alike to the session store — no id, the same tracking type — + // so a tab that misses the expired state between them never learns the session was + // replaced: nothing expires and nothing renews here, and what this page last read of the + // draw stays as it was. Storage is the one thing the tab that drew the replacement shares + // with this one, so it is what has to be read when the decision is made. + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Another tab hears a rate of 30, ends the session drawn at zero and draws the next one, + // which loses — all between two of this page's storage polls. + storeRemote({ version: 2, sessionSampleRate: 30 }) + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ + id: 'not-tracked', + version: 2, + sessionSampleRate: 30, + sessionReplaySampleRate: 50, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + ) + clock.tick(STORAGE_POLL_DELAY) + expect(expireSessionSpy).not.toHaveBeenCalled() + + // This page's own request answers with settings newer still. Read off the draw it last saw + // the session looks drawn at zero and is ended; read off storage it lost a draw at thirty + // and is left alone. + deliver({ version: 3, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('does not consult beforeSampling when no rate could decide anything', () => { + // Resolving the rate runs the site's own code, and an announcement is not a draw. It is + // asked only where the answer is what settles whether the session ends — never once per + // announcement for every visitor. + const beforeSampling = jasmine.createSpy('beforeSampling').and.returnValue(undefined) + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) + startWith({ sessionSampleRate: 0, beforeSampling }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + beforeSampling.calls.reset() + + // This visitor lost a draw at thirty, so no rate the console publishes says anything about + // the session they are on, and there is nothing to ask. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(beforeSampling).not.toHaveBeenCalled() + }) + it('never draws again to reach its decision', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -1141,6 +1287,28 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('stops re-drawing once the session that follows has lost a draw at the new rate', () => { + // The loop this could have become: the replacement is sampled out too, and if it were read + // as another session drawn at zero every further announcement would end it again. It was + // drawn at the new rate, and that is what its record says. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('stops tightening the privacy level once the session is drawn under it', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 9e742043ee..7617bfb0e4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -99,6 +99,34 @@ export const enum SessionReplayState { FORCED, } +/** + * FLASHCAT FORK - the id the draw of a session that lost its lottery is recorded under. + * + * A session that is not collected is given no id — see `sessionStore` — so it has nothing to key a + * record on, and until this existed its draw was simply not recorded. That left the one question + * this SDK has to answer before it may re-draw such a visitor unanswerable: was this session drawn + * at a rate of 0, or did it lose a draw at some other rate? Getting that wrong in the second + * direction re-rolls losers while sparing winners, which quietly lifts a fleet's real sampling + * rate towards 100% — see `endSessionIfSettingsAreDecisive`. + * + * Not a UUID, and not a value `generateUUID` can produce, so a record written here can never be + * mistaken for a real session's. The two are told apart by the id alone, which is what lets both + * share the single record slot: a collected session looks its own id up and a sampled-out one + * looks this up, and neither can read the other's. + * + * What it gives up, and why that is affordable: every sampled-out session matches this same id, so + * the id check that makes a stale record inert for a collected session does nothing here. What + * keeps a stale one from being read instead is that the page which draws owns the slot — it writes + * its draw or clears the slot, in the same stack that created the session — so the record always + * describes the most recent draw, and the most recent draw is what created the session being read. + * The one thing the record decides for a sampled-out session — whether a rate leaving 0 may end + * it — is read off storage at the moment of that decision rather than off the copy `trackDraw` + * took when the session was adopted. See `endSessionIfSettingsAreDecisive` for why the copy is not + * enough: the session store cannot tell one sampled-out session from the next, so a page can keep + * the copy of a session another tab has already replaced. + */ +const NOT_TRACKED_DRAW_ID = 'not-tracked' + export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, @@ -172,8 +200,8 @@ export function startRumSessionManager( // synchronous stack: a tab whose storage poll fell exactly between the two would find no record // and keep its own settings for that session. Writing it earlier is not possible from here — the // id it belongs to is generated inside the store, as that session is persisted. The record is - // read only here, when a session is adopted, so such a tab keeps its own settings for the whole - // remaining life of that session rather than until its next poll. + // read into the history only here, when a session is adopted, so such a tab keeps its own + // settings for the whole remaining life of that session rather than until its next poll. // // Storage is also per origin while the session need not be: with `trackSessionAcrossSubdomains` // a session arrives on the next subdomain with no record waiting, and is reported and traced @@ -182,15 +210,32 @@ export function startRumSessionManager( const drawn = pendingDraw pendingDraw = undefined const sessionEntity = sessionManager.findSession() - if (!sessionEntity?.id) { + if (!sessionEntity) { return } + // A session that lost its draw has no id to be recorded under, so it is recorded under an id no + // session can hold. It has to be recorded at all for the same reason a collected one does — the + // rate it was drawn at is not something a later page can work out, and here it decides whether + // a console change away from 0 may re-draw this visitor at once. Which of the two is read back + // follows from the session itself, so no record can be read for a session it does not describe. + const drawId = sessionEntity.id || NOT_TRACKED_DRAW_ID if (drawn) { - writeDrawRecord(configuration, { id: sessionEntity.id, ...drawn }) - drawnHistory.add(drawn, startTime) + // The page that draws owns the slot, and says so either way. A record is only worth keeping + // when it says something the init values do not — but leaving the previous one in place + // instead would let it outlive the session it described, and a sampled-out session cannot + // spot that the way a collected one does: it matches on an id every sampled-out session + // shares. So a draw that has nothing to record clears the slot rather than passing over it. + // The cost is one `removeItem` per session drawn on a site that enabled none of this, which + // is a handful per visit. + if (isWorthRecording(configuration, drawn)) { + writeDrawRecord(configuration, { id: drawId, ...drawn }) + drawnHistory.add(drawn, startTime) + } else { + forgetDrawRecord(configuration) + } return } - const stored = readDrawRecord(configuration, sessionEntity.id) + const stored = readDrawRecord(configuration, drawId) if (stored) { drawnHistory.add(stored, startTime) } @@ -213,27 +258,37 @@ export function startRumSessionManager( // pages fetches the change within seconds and then carries on under the old decision for the rest // of their visit. // - // Two changes are not made to wait, and what makes exactly those two special is that their + // Three changes are not made to wait, and what makes exactly those three special is that their // outcome for the running session can be asserted without drawing again: // // - a stricter default privacy level: every further second recorded is a second of plaintext // uploaded, and masking cannot reach back for it. This is the one whose cost is not // recoverable, and the reason the rest of this exists; - // - a session sample rate of 0: nothing is meant to be collected any more, and this is the - // emergency stop the console offers — one that took four hours would not be one. + // - a session sample rate of 0 for a session being collected: nothing is meant to be collected + // any more, and this is the emergency stop the console offers — one that took four hours + // would not be one; + // - a rate above 0 for a session that was drawn AT 0: this visitor was never in a draw at all, + // and now could be. Without it an application whose rate only ever comes from the console + // shows an operator who has just switched collection on precisely nothing, for as long as + // the sessions already running take to rotate — and nothing at all is indistinguishable from + // broken. // - // Both are about a session that is being collected, which is why that is the first thing checked. - // A visitor who is not being collected records nothing and uploads nothing, so neither rule has - // anything to act on for them. + // The first two are about a session that is being collected, and the third only ever about one + // that is not, which is why each rule checks that for itself. // // No rate other than 0 says anything about whether THIS session should have been kept — only a - // second draw could, and drawing twice silently turns a rate p into p². A rate of 100 could be - // asserted about a session that is not collected, and deliberately is not acted on: `setForcedSession` - // already exists for "collect this visitor now", it is the one direction that raises volume - // unannounced, and nothing about it is urgent. So everything else waits for the next session, a - // loosening privacy level included. Loosening waits on purpose: the delay is what leaves an - // operator room to undo a mistake, and what it costs meanwhile is more of the data already being - // collected. + // second draw could, and drawing twice silently turns a rate p into p². That is also why the + // third rule is written against the rate the session was DRAWN at rather than against whether it + // is being collected: re-drawing every session that is not collected, while leaving the collected + // ones alone, spares the winners and re-rolls the losers, so a fleet drawn at 20 and moved to 50 + // would come out at 60. A rate of 0 is the one value with no winners to spare — nothing was + // collected, no coin was flipped — so re-drawing everyone lands exactly on the new rate. And it + // costs nothing to end such a session: it has no id, no events and no history, so it does not + // exist in the data and ending it leaves no seam. + // + // Everything else waits for the next session, a loosening privacy level included. Loosening waits + // on purpose: the delay is what leaves an operator room to undo a mistake, and what it costs + // meanwhile is more of the data already being collected. // // The action is to end the session and let the next activity start a new one — never to flip the // running one, which would leave a replay masked in its first half and plain in its second, or @@ -250,19 +305,39 @@ export function startRumSessionManager( // Any other tab of the same visitor that does load a page ends the session they share. function endSessionIfSettingsAreDecisive() { const session = sessionManager.findSession() - if (!session || !isTypeTracked(session.trackingType)) { - // Nothing here that ending would change. Whatever starts this visitor's next session draws - // on the settings just stored, which is the ordinary path and already gives them effect. - // - // It also could not be decided if we wanted to: a session that is not collected is given no - // id, so no record is kept of what it was drawn under. The comparison below 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. + if (!session) { return } const remote = readRemoteConfig(configuration.remoteConfig) + if (!isTypeTracked(session.trackingType)) { + // Read off storage rather than off `drawnHistory`, because the two can disagree here and only + // storage is right. Two sampled-out sessions look alike to the session store — no id, the + // same tracking type — so a page whose storage poll misses the expired state between them + // never learns that another tab ended the first and drew the second: nothing expires and + // nothing renews on this page, and the history keeps the draw of a session that is gone. The + // page that drew the replacement wrote its rate to storage in the same stack, so that is the + // one place this session's own rate can be found. A collected session cannot be confused this + // way, since its id changes with it. + // + // Nothing forced can reach this comparison as a zero: a forced draw is recorded at 100 and is + // collected besides, so the record already answers the question the tracked branch has to ask + // `forcedSession` about below. No record means the draw used the init values, see `trackDraw`. + const drawnSampleRate = + readDrawRecord(configuration, NOT_TRACKED_DRAW_ID)?.sessionSampleRate ?? configuration.sessionSampleRate + if (drawnSampleRate !== 0) { + return + } + // Asked only now, and only here, because resolving runs the site's `beforeSampling`: this + // announcement is not a draw, and the callback should be run no more often than a decision + // actually turns on its answer. + if (resolveSampleRates(configuration, remote).sessionSampleRate > 0) { + sessionManager.expire() + } + return + } + // What this session is masking pages with right now, which is not the previously stored // settings: settings are stored while a session runs, and the session was drawn under whatever // was stored before that. No record means the draw used the init value, and so does the @@ -283,8 +358,7 @@ export function startRumSessionManager( return } - const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if (sessionSampleRate === 0) { + if (resolveSampleRates(configuration, remote).sessionSampleRate === 0) { sessionManager.expire() } } @@ -454,9 +528,11 @@ function computeSessionState( configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean, - // FLASHCAT FORK - called when a draw actually happens (never for a restored session) and lands - // on something other than the init values, with the rates the draw used and the remote version - // they came from. + // FLASHCAT FORK - called whenever a draw actually happens and never for a restored session, with + // the rates the draw used and the remote version they came from. Reporting every draw, including + // one that landed on the init values, is what lets the caller tell "this page drew" from "this + // page adopted a session somebody else drew" — see `trackDraw`, where only the first may write to + // the record slot. onDraw?: (drawn: DrawnConfiguration) => void ) { let trackingType: RumTrackingType @@ -549,12 +625,9 @@ const PRIVACY_LEVEL_STRICTNESS: { [level in DefaultPrivacyLevel]: number } = { * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses * what the console and the application settled on. * - * What decides whether a draw is worth recording is the draw itself, not which feature produced it: - * a draw that used exactly what init passed is already described by the events, so recording it - * would buy nothing and cost a storage write on every site that turned none of this on. Everything - * else is recorded — including a `beforeSampling` override or a forced session on a site with - * remote configuration switched off, where the rates used and the rates init passed are precisely - * the values that differ. + * Whether the draw is worth keeping is `isWorthRecording`'s question, asked one layer up, because + * the answer there decides between writing the record and clearing it — and only a caller that + * hears about every draw can clear one. */ function reportDraw( configuration: RumConfiguration, @@ -566,23 +639,32 @@ function reportDraw( if (!onDraw) { return } - const drawn: DrawnConfiguration = { + onDraw({ version: remote.version, sessionSampleRate, sessionReplaySampleRate, traceSampleRate: remote.traceSampleRate ?? initTraceRule(configuration), defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - } - if ( - drawn.version === undefined && - drawn.sessionSampleRate === configuration.sessionSampleRate && - drawn.sessionReplaySampleRate === configuration.sessionReplaySampleRate && - drawn.traceSampleRate === initTraceRule(configuration) && - drawn.defaultPrivacyLevel === configuration.defaultPrivacyLevel - ) { - return - } - onDraw(drawn) + }) +} + +/** + * FLASHCAT FORK - whether a draw says anything the init values do not. + * + * One that does not is already described by the events, so keeping it would buy nothing and cost a + * storage write on every site that turned none of this on. Asked about the draw rather than about + * which feature produced it: a `beforeSampling` override or a forced session on a site with remote + * configuration switched off is precisely the case where the rates used and the rates init passed + * are the values that differ. + */ +function isWorthRecording(configuration: RumConfiguration, drawn: DrawnConfiguration) { + return ( + drawn.version !== undefined || + drawn.sessionSampleRate !== configuration.sessionSampleRate || + drawn.sessionReplaySampleRate !== configuration.sessionReplaySampleRate || + drawn.traceSampleRate !== initTraceRule(configuration) || + drawn.defaultPrivacyLevel !== configuration.defaultPrivacyLevel + ) } /**