-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(browser): Defer sending session envelope until browser is idle #21844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
7e33cfe
fix(browser): Defer browser session envelope until browser is idle
Lms24 df51d46
add unit tests
Lms24 d183039
fix(browser): Prevent duplicate session send on early navigation
Lms24 5d52db4
test(browser): Fix session test lint error and debug message order
Lms24 dadb1bb
test(nextjs): Stub removeEventListener in fake-window test harnesses
Lms24 d60003d
unflakre debug log integration test
Lms24 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
10 changes: 6 additions & 4 deletions
10
dev-packages/browser-integration-tests/suites/sessions/user/subject.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| Sentry.setUser({ | ||
| id: '1337', | ||
| email: 'user@name.com', | ||
| username: 'user1337', | ||
| document.getElementById('set-user').addEventListener('click', () => { | ||
| Sentry.setUser({ | ||
| id: '1337', | ||
| email: 'user@name.com', | ||
| username: 'user1337', | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,5 +5,6 @@ | |
| </head> | ||
| <body> | ||
| <a id="navigate" href="#foo">Navigate</a> | ||
| <button id="set-user">Set user</button> | ||
| </body> | ||
| </html> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
packages/browser/test/integrations/browsersession.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
|
|
||
| import type * as BrowserUtils from '@sentry/browser-utils'; | ||
| import type { Scope, User } from '@sentry/core/browser'; | ||
| import * as SentryCore from '@sentry/core/browser'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { browserSessionIntegration } from '../../src/integrations/browsersession'; | ||
|
|
||
| const scopeHolder = vi.hoisted(() => ({ current: undefined as unknown as FakeIsolationScope })); | ||
|
|
||
| const historyHandlers = vi.hoisted(() => ({ current: [] as Array<(data: { from?: string; to?: string }) => void> })); | ||
|
|
||
| vi.mock('@sentry/core/browser', async importActual => { | ||
| const actual = (await importActual()) as typeof SentryCore; | ||
| return { | ||
| ...actual, | ||
| startSession: vi.fn(), | ||
| captureSession: vi.fn(), | ||
| getIsolationScope: () => scopeHolder.current, | ||
| }; | ||
| }); | ||
|
|
||
| // Capture the registered history handler so navigation can be driven deterministically, | ||
| // while keeping the real `whenIdleOrHidden` (the tests drive its timers/events directly). | ||
| vi.mock('@sentry/browser-utils', async importActual => { | ||
| const actual = (await importActual()) as typeof BrowserUtils; | ||
| return { | ||
| ...actual, | ||
| addHistoryInstrumentationHandler: (handler: (data: { from?: string; to?: string }) => void) => { | ||
| historyHandlers.current.push(handler); | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| function navigate(from: string, to: string): void { | ||
| historyHandlers.current.forEach(handler => handler({ from, to })); | ||
| } | ||
|
|
||
| interface FakeIsolationScope { | ||
| getUser: () => User | undefined; | ||
| addScopeListener: (cb: (scope: Scope) => void) => void; | ||
| setUser: (user: User | undefined) => void; | ||
| } | ||
|
|
||
| /** | ||
| * Minimal isolation-scope stand-in so we can drive the integration's scope listener | ||
| * deterministically (and in isolation from the global scope) across tests. | ||
| */ | ||
| function createFakeIsolationScope(initialUser?: User): FakeIsolationScope { | ||
| let user = initialUser; | ||
| let listener: ((scope: Scope) => void) | undefined; | ||
| return { | ||
| getUser: () => user, | ||
| addScopeListener: cb => { | ||
| listener = cb; | ||
| }, | ||
| setUser: nextUser => { | ||
| user = nextUser; | ||
| listener?.({ getUser: () => user } as Scope); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function setVisibilityState(state: DocumentVisibilityState): void { | ||
| Object.defineProperty(document, 'visibilityState', { value: state, configurable: true }); | ||
| } | ||
|
|
||
| function setupBrowserSession(options?: Parameters<typeof browserSessionIntegration>[0]): void { | ||
| const integration = browserSessionIntegration(options); | ||
| integration.setupOnce?.(); | ||
| } | ||
|
|
||
| describe('browserSessionIntegration', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.useFakeTimers(); | ||
| // `requestIdleCallback` is unavailable in jsdom, so `whenIdleOrHidden` falls back to | ||
| // `setTimeout` which we drive via fake timers to simulate the browser going idle. | ||
| delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback; | ||
| setVisibilityState('visible'); | ||
| scopeHolder.current = createFakeIsolationScope(); | ||
| historyHandlers.current = []; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('starts the session synchronously but defers the initial capture until the browser is idle', () => { | ||
| setupBrowserSession({ lifecycle: 'page' }); | ||
|
|
||
| expect(SentryCore.startSession).toHaveBeenCalledTimes(1); | ||
| expect(SentryCore.startSession).toHaveBeenCalledWith({ ignoreDuration: true }); | ||
|
|
||
| // The send must not happen synchronously during init. | ||
| expect(SentryCore.captureSession).not.toHaveBeenCalled(); | ||
|
|
||
| vi.runAllTimers(); | ||
|
|
||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('captures the session immediately when the page is already hidden', () => { | ||
| setVisibilityState('hidden'); | ||
|
|
||
| setupBrowserSession({ lifecycle: 'page' }); | ||
|
|
||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('flushes the deferred session when the page is hidden before the browser goes idle', () => { | ||
| setupBrowserSession({ lifecycle: 'page' }); | ||
| expect(SentryCore.captureSession).not.toHaveBeenCalled(); | ||
|
|
||
| window.dispatchEvent(new Event('pagehide')); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
|
|
||
| // The idle fallback must not send the session a second time. | ||
| vi.runAllTimers(); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('does not send a separate envelope for user data set before the initial capture', () => { | ||
| setupBrowserSession({ lifecycle: 'page' }); | ||
|
|
||
| // User set during page load (before idle): folded into the deferred initial session, | ||
| // so it must not trigger its own send. | ||
| scopeHolder.current.setUser({ id: '1337' }); | ||
| expect(SentryCore.captureSession).not.toHaveBeenCalled(); | ||
|
|
||
| vi.runAllTimers(); | ||
|
|
||
| // Only the (single) deferred initial session is sent. | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('captures an update when user data changes after the initial capture', () => { | ||
| setupBrowserSession({ lifecycle: 'page' }); | ||
| vi.runAllTimers(); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
|
|
||
| // User set after the initial session was sent: emits a dedicated update envelope. | ||
| scopeHolder.current.setUser({ id: '1337' }); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('does not re-send the navigation session when navigation happens before the deferred initial capture', () => { | ||
| // Default lifecycle is 'route', which also registers the navigation handler. | ||
| setupBrowserSession(); | ||
|
|
||
| // The initial capture is deferred, so nothing is sent synchronously. | ||
| expect(SentryCore.captureSession).not.toHaveBeenCalled(); | ||
|
|
||
| // User navigates before the browser goes idle: a new session is started and sent. | ||
| navigate('/initial', '/next'); | ||
| expect(SentryCore.startSession).toHaveBeenCalledTimes(2); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
|
|
||
| // The deferred idle callback now fires. Since the navigation already sent the current | ||
| // session, the deferred capture must not re-send it. | ||
| vi.runAllTimers(); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('still captures a session on navigation that happens after the initial capture', () => { | ||
| setupBrowserSession(); | ||
|
|
||
| vi.runAllTimers(); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
|
|
||
| navigate('/initial', '/next'); | ||
| expect(SentryCore.startSession).toHaveBeenCalledTimes(2); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('does not capture again when the user reference changes but id and ip stay the same', () => { | ||
| setupBrowserSession({ lifecycle: 'page' }); | ||
| vi.runAllTimers(); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); | ||
|
|
||
| scopeHolder.current.setUser({ id: '1337', email: 'a@example.com' }); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); | ||
|
|
||
| // Same id and ip_address (only unrelated fields change) -> no extra capture. | ||
| scopeHolder.current.setUser({ id: '1337', email: 'b@example.com' }); | ||
| expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.