Skip to content

Commit 8253604

Browse files
🐛 fix logs not being sent long after session expiration (#4839)
1 parent 1d72c68 commit 8253604

7 files changed

Lines changed: 101 additions & 19 deletions

File tree

packages/browser-core/src/domain/session/sessionManager.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,18 @@ describe('startSessionManager', () => {
799799
expect(sessionManager.findTrackedSession()).toBeUndefined()
800800
})
801801

802+
it('should return the session older than TRACKED_SESSION_MAX_AGE when a larger maxAge is provided', async () => {
803+
const sessionManager = await startSessionManagerWithDefaults()
804+
805+
// Let the session expire from inactivity, then age well past TRACKED_SESSION_MAX_AGE. The
806+
// in-memory session context entry is kept (bounded by maxEntries, not elapsed time), so it
807+
// can still be returned with `returnInactive: true` regardless of how old it is.
808+
clock.tick(TRACKED_SESSION_MAX_AGE + ONE_SECOND)
809+
810+
expect(sessionManager.findTrackedSession(undefined, { returnInactive: true })).toBeUndefined()
811+
expect(sessionManager.findTrackedSession(undefined, { returnInactive: true, maxAge: Infinity })).toBeDefined()
812+
})
813+
802814
describe('deterministic sampling', () => {
803815
it('should track a session whose ID has a low hash, even with a low sessionSampleRate', async () => {
804816
setupFakeStrategy({

packages/browser-core/src/domain/session/sessionManager.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { display } from '../../tools/display'
2323
import { isSampled } from '../sampler'
2424
import { TelemetryMetrics, addTelemetryMetrics } from '../telemetry'
2525
import { monitorError } from '../../tools/monitor'
26-
import { SESSION_TIME_OUT_DELAY } from './sessionConstants'
2726
import type { SessionState } from './sessionState'
2827
import {
2928
expandOnly,
@@ -38,7 +37,10 @@ import { getSessionStoreStrategy, selectSessionStoreStrategyType } from './sessi
3837

3938
export interface SessionManager {
4039
findSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined
41-
findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined
40+
findTrackedSession: (
41+
startTime?: RelativeTime,
42+
options?: { returnInactive?: boolean; maxAge?: number }
43+
) => SessionContext | undefined
4244
renewObservable: Observable<void>
4345
expireObservable: Observable<void>
4446
expire: () => void
@@ -53,7 +55,12 @@ export interface SessionContext {
5355
}
5456

5557
export const VISIBILITY_CHECK_DELAY = ONE_MINUTE
56-
const SESSION_CONTEXT_TIMEOUT_DELAY = SESSION_TIME_OUT_DELAY
58+
59+
// Arbitrary value to cap memory consumption for very long-lived pages with many session
60+
// renewals. Entries are *not* evicted based on elapsed time: an idle session's sole (closed)
61+
// entry must survive indefinitely so browser-logs can keep sending logs after it expires, with
62+
// or without a session attached (see browser-logs/src/domain/contexts/sessionContext.ts).
63+
export const MAX_SESSION_CONTEXT_HISTORY_ENTRIES = 1000
5764

5865
// Maximum duration for which we can send data related to a session.
5966
//
@@ -84,7 +91,7 @@ export async function startSessionManager(
8491
const strategy = mockable(getSessionStoreStrategy)(sessionStoreStrategyType, configuration)
8592

8693
const sessionContextHistory = createValueHistory<SessionContext>({
87-
expireDelay: SESSION_CONTEXT_TIMEOUT_DELAY,
94+
maxEntries: MAX_SESSION_CONTEXT_HISTORY_ENTRIES,
8895
})
8996
stopCallbacks.push(() => sessionContextHistory.stop())
9097

@@ -216,14 +223,14 @@ export async function startSessionManager(
216223
function buildSessionManager(): SessionManager {
217224
return {
218225
findSession: (startTime, options) => sessionContextHistory.find(startTime, options),
219-
findTrackedSession: (startTime, options) => {
220-
const session = sessionContextHistory.find(startTime, options)
226+
findTrackedSession: (startTime, { returnInactive = false, maxAge = TRACKED_SESSION_MAX_AGE } = {}) => {
227+
const session = sessionContextHistory.find(startTime, { returnInactive })
221228

222229
if (!session || session.id === 'invalid' || !isSampled(session.id, configuration.sessionSampleRate)) {
223230
return
224231
}
225232

226-
if (dateNow() - session.createdAt > TRACKED_SESSION_MAX_AGE) {
233+
if (dateNow() - session.createdAt > maxAge) {
227234
return
228235
}
229236

packages/browser-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type { SessionManager, SessionContext } from './domain/session/sessionMan
5151
export { startSessionManager, startSessionManagerStub, stopSessionManager } from './domain/session/sessionManager'
5252
export {
5353
SESSION_TIME_OUT_DELAY, // Exposed for tests
54+
SESSION_EXPIRATION_DELAY,
5455
SESSION_NOT_TRACKED,
5556
SessionPersistence,
5657
} from './domain/session/sessionConstants'

packages/browser-core/src/tools/valueHistory.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,18 @@ describe('valueHistory', () => {
220220
valueHistory1.stop()
221221
valueHistory2.stop()
222222
})
223+
224+
it('should not clear closed entries based on elapsed time when expireDelay is not set', () => {
225+
const maxEntriesOnlyHistory = createValueHistory<string>({ maxEntries: MAX_ENTRIES })
226+
const originalTime = performance.now() as RelativeTime
227+
maxEntriesOnlyHistory.add('foo', originalTime).close(addDuration(originalTime, 10 as Duration))
228+
229+
clock.tick(EXPIRE_DELAY + CLEAR_OLD_VALUES_INTERVAL)
230+
231+
expect(maxEntriesOnlyHistory.find(originalTime, { returnInactive: true })).toBeDefined()
232+
233+
maxEntriesOnlyHistory.stop()
234+
})
223235
})
224236

225237
it('should limit the number of entries', () => {

packages/browser-core/src/tools/valueHistory.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,27 +40,35 @@ function cleanupHistories() {
4040
cleanupTasks.forEach((task) => task())
4141
}
4242

43-
export function createValueHistory<Value>({
44-
expireDelay,
45-
maxEntries,
46-
}: {
47-
expireDelay: number
48-
maxEntries?: number
49-
}): ValueHistory<Value> {
50-
let entries: Array<ValueHistoryEntry<Value>> = []
43+
type ValueHistoryArgs =
44+
| {
45+
expireDelay?: number
46+
maxEntries: number
47+
}
48+
| {
49+
expireDelay: number
50+
maxEntries?: number
51+
}
5152

52-
if (!cleanupHistoriesInterval) {
53-
cleanupHistoriesInterval = setInterval(() => cleanupHistories(), CLEAR_OLD_VALUES_INTERVAL)
54-
}
53+
export function createValueHistory<Value>({ expireDelay, maxEntries }: ValueHistoryArgs): ValueHistory<Value> {
54+
let entries: Array<ValueHistoryEntry<Value>> = []
5555

5656
const clearExpiredValues = () => {
57+
if (!expireDelay) {
58+
return
59+
}
5760
const oldTimeThreshold = relativeNow() - expireDelay
5861
while (entries.length > 0 && entries[entries.length - 1].endTime < oldTimeThreshold) {
5962
entries.pop()
6063
}
6164
}
6265

63-
cleanupTasks.add(clearExpiredValues)
66+
if (expireDelay) {
67+
if (!cleanupHistoriesInterval) {
68+
cleanupHistoriesInterval = setInterval(() => cleanupHistories(), CLEAR_OLD_VALUES_INTERVAL)
69+
}
70+
cleanupTasks.add(clearExpiredValues)
71+
}
6472

6573
/**
6674
* Add a value to the history associated with a start time. Returns a reference to this newly

packages/browser-logs/src/domain/contexts/sessionContext.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ export function startSessionContext(
99
sessionManager: SessionManager
1010
) {
1111
hook.register(({ startTime }) => {
12+
// Used to attach a (fresh, safe-to-reference) session id: subject to the default
13+
// TRACKED_SESSION_MAX_AGE, so it becomes undefined once the session is too old to reference.
1214
const session = sessionManager.findTrackedSession(startTime)
1315

16+
// Used for the discard decision: unlike `session` above, this ignores session age (logs
17+
// should keep being sent indefinitely, with or without a session, once a session was
18+
// legitimately tracked here) but still respects sampling.
1419
const isSessionTracked = sessionManager.findTrackedSession(startTime, {
1520
returnInactive: true,
21+
maxAge: Infinity,
1622
})
1723

1824
if (!isSessionTracked) {

test/e2e/scenario/logs.scenario.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { DEFAULT_REQUEST_ERROR_RESPONSE_LENGTH_LIMIT } from '@datadog/browser-logs/src/domain/configuration'
2+
import { ONE_HOUR, ONE_MINUTE } from '@datadog/js-core/time'
3+
import { SESSION_EXPIRATION_DELAY } from '@datadog/browser-core'
24
import { test, expect } from '@playwright/test'
35
import { createTest, createWorker } from '../lib/framework'
46
import { APPLICATION_ID } from '../lib/helpers/configuration'
@@ -354,4 +356,38 @@ test.describe('logs', () => {
354356
expect(intakeRegistry.logsEvents).toHaveLength(1)
355357
expect(intakeRegistry.logsEvents[0].foo).toBe('bar')
356358
})
359+
360+
test.describe('session expiration', () => {
361+
createTest('logs should keep being sent forever, even long after the session has expired')
362+
.withLogs()
363+
.withMockClock()
364+
.run(async ({ intakeRegistry, flushEvents, page }) => {
365+
// Logs should keep being sent indefinitely, with or without a session attached, even
366+
// long after the session has expired.
367+
368+
// Let the session expire from inactivity (no click/scroll/keydown/touch).
369+
await page.clock.fastForward(SESSION_EXPIRATION_DELAY + ONE_MINUTE)
370+
371+
await page.evaluate(() => {
372+
window.DD_LOGS!.logger.log('shortly after session expiration')
373+
})
374+
375+
// Fast-forward well past the point where the session is expired.
376+
await page.clock.fastForward(24 * ONE_HOUR)
377+
378+
await page.evaluate(() => {
379+
window.DD_LOGS!.logger.log('long after session expiration')
380+
})
381+
382+
await flushEvents()
383+
384+
expect(intakeRegistry.logsEvents).toHaveLength(2)
385+
386+
expect(intakeRegistry.logsEvents[0].message).toBe('shortly after session expiration')
387+
expect(intakeRegistry.logsEvents[0].session_id).toBeUndefined()
388+
389+
expect(intakeRegistry.logsEvents[1].message).toBe('long after session expiration')
390+
expect(intakeRegistry.logsEvents[1].session_id).toBeUndefined()
391+
})
392+
})
357393
})

0 commit comments

Comments
 (0)