From 57220f45a2e35f48881d01467d08aebc4c79c35f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 1 Jun 2023 09:15:56 -0400 Subject: [PATCH 01/13] feat(replay): Do not restart replays on "keydown" (#8226) This is causing some low-value replays. If you search sentry for replays with duration < 5 seconds, you will see that most of them are re-newed sessions from idle that only have a "keydown" breadcrumb. I suspect users are alt-tabbing out of an idle Sentry page. Removing for now until we have a better way to detect when a session is truly resumed via keydown event. --- .../replay/src/coreHandlers/handleKeyboardEvent.ts | 5 ++++- packages/replay/src/replay.ts | 12 ++++++++++++ packages/replay/src/types.ts | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/replay/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts index f7943d34fa4f..0f7560f39584 100644 --- a/packages/replay/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay/src/coreHandlers/handleKeyboardEvent.ts @@ -12,7 +12,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven return; } - replay.triggerUserActivity(); + // Update user activity, but do not restart recording as it can create + // noisy/low-value replays (e.g. user comes back from idle, hits alt-tab, new + // session with a single "keydown" breadcrumb is created) + replay.updateUserActivity(); const breadcrumb = getKeyboardBreadcrumb(event); diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 718b658ad82d..4d5c918b7610 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -486,6 +486,18 @@ export class ReplayContainer implements ReplayContainerInterface { this._updateSessionActivity(); } + /** + * Updates the user activity timestamp *without* resuming + * recording. Some user events (e.g. keydown) can be create + * low-value replays that only contain the keypress as a + * breadcrumb. Instead this would require other events to + * create a new replay after a session has expired. + */ + public updateUserActivity(): void { + this._updateUserActivity(); + this._updateSessionActivity(); + } + /** * Only flush if `this.recordingMode === 'session'` */ diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 42758c1b06d9..f52c163f6e69 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -556,6 +556,7 @@ export interface ReplayContainer { flushImmediate(): Promise; cancelFlush(): void; triggerUserActivity(): void; + updateUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; getSessionId(): string | undefined; From 1df2367f8c1b62625a9c65c36f80413b7beeb4e9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Jun 2023 10:45:32 +0200 Subject: [PATCH 02/13] fix(nextjs): Strip query params from transaction names of navigations to unknown routes (#8278) Fix a possible scenario in which transaction names can have query params, namely a navigation to an unknown route. --- packages/nextjs/src/client/performance.ts | 5 +++-- packages/nextjs/test/performance/client.test.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/client/performance.ts b/packages/nextjs/src/client/performance.ts index 2212092d5045..0dd0b109408f 100644 --- a/packages/nextjs/src/client/performance.ts +++ b/packages/nextjs/src/client/performance.ts @@ -143,7 +143,8 @@ export function nextRouterInstrumentation( if (startTransactionOnLocationChange) { Router.events.on('routeChangeStart', (navigationTarget: string) => { - const matchedRoute = getNextRouteFromPathname(stripUrlQueryAndFragment(navigationTarget)); + const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget); + const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget); let transactionName: string; let transactionSource: TransactionSource; @@ -152,7 +153,7 @@ export function nextRouterInstrumentation( transactionName = matchedRoute; transactionSource = 'route'; } else { - transactionName = navigationTarget; + transactionName = strippedNavigationTarget; transactionSource = 'url'; } diff --git a/packages/nextjs/test/performance/client.test.ts b/packages/nextjs/test/performance/client.test.ts index 0b3ae0e7437e..cc0f212cbf18 100644 --- a/packages/nextjs/test/performance/client.test.ts +++ b/packages/nextjs/test/performance/client.test.ts @@ -231,6 +231,7 @@ describe('nextRouterInstrumentation', () => { ['/news', '/news', 'route'], ['/news/', '/news', 'route'], ['/some-route-that-is-not-defined-12332', '/some-route-that-is-not-defined-12332', 'url'], // unknown route + ['/some-route-that-is-not-defined-12332?q=42', '/some-route-that-is-not-defined-12332', 'url'], // unknown route w/ query param ['/posts/42', '/posts/[id]', 'route'], ['/posts/42/', '/posts/[id]', 'route'], ['/posts/42?someParam=1', '/posts/[id]', 'route'], // query params are ignored From b532311af91a9153ef61cbdd6d85f0115d3bbe3c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Jun 2023 13:43:02 +0200 Subject: [PATCH 03/13] chore(sveltekit): Update adapter compatibility in Readme (#8272) Update the readme now that we're compatible with more adapters than just the Node adapter. Co-authored-by: Francesco Novy --- packages/sveltekit/README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 309b59fcd2ac..5ca2cff3e73d 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -16,10 +16,19 @@ ## Compatibility -Currently, the minimum supported version of SvelteKit is `1.0.0`. +The minimum supported version of SvelteKit is `1.0.0`. The SDK works best with Vite 4.2 and newer. Older Vite versions might not generate source maps correctly. +The SDK supports the following SvelteKit adapters: +- `@sveltejs/adapter-auto` - for Vercel with the Node runtime. Other deployment targets might work but we don't guarantee compatibility. +- `@sveltejs/adapter-vercel` - only for Node (Lambda) runtimes, not yet Vercel's edge runtime +- `@sveltejs/adapter-node` + +If you use the SDK with other adapters, we cannot guarantee that everything works as expected. +You might need to [manually configure source maps upload](#-configuring-source-maps-upload). +The SDK is currently not compatible with none-Node server runtimes, such as Vercel's Edge runtime or Cloudflare workers. + ## General This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit. @@ -40,9 +49,7 @@ If the setup through the wizard doesn't work for you, you can also set up the SD ### 1. Prerequesits & Installation -1. Ensure you've set up the [`@sveltejs/adapter-node` adapter](https://kit.svelte.dev/docs/adapter-node) - -2. Install the Sentry SvelteKit SDK: +1. Install the Sentry SvelteKit SDK: ```bash # Using npm @@ -310,12 +317,3 @@ export const load = wrapServerLoadWithSentry((event) => { }); ``` - -## Known Limitations - -This SDK is still under active development. -Take a look at our [SvelteKit SDK Development Roadmap](https://github.com/getsentry/sentry-javascript/issues/6692) to follow the progress. - -- **Adapters** other than `@sveltejs/adapter-node` are currently not supported. - We haven't yet tested other platforms like Vercel. - This is on our roadmap but it will come at a later time. From 9ed5cfdefeb012c6523ca04d0ea697d1829fc004 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 2 Jun 2023 08:39:59 -0400 Subject: [PATCH 04/13] feat(replay): Improve types for replay recording events (#8224) More specific types for our replay recording events. These are exported so that our UI can use them as well. ![image](https://github.com/getsentry/sentry-javascript/assets/79684/8b5d3ff1-ac44-4f30-b1b2-0057b98ec4b7) --- .../replay/src/coreHandlers/handleScope.ts | 17 +- packages/replay/src/index.ts | 8 + packages/replay/src/replay.ts | 9 +- packages/replay/src/types/index.ts | 4 + packages/replay/src/types/performance.ts | 160 ++++++++++++++++ .../replay/src/{types.ts => types/replay.ts} | 171 +---------------- packages/replay/src/types/replayFrame.ts | 174 ++++++++++++++++++ packages/replay/src/util/addEvent.ts | 8 +- packages/replay/src/util/createBreadcrumb.ts | 8 +- .../replay/src/util/handleRecordingEmit.ts | 4 +- .../beforeAddRecordingEvent.test.ts | 2 +- .../coreHandlers/handleScope.test.ts | 4 +- .../unit/coreHandlers/handleScope.test.ts | 10 +- .../unit/util/handleRecordingEmit.test.ts | 4 +- 14 files changed, 391 insertions(+), 192 deletions(-) create mode 100644 packages/replay/src/types/index.ts create mode 100644 packages/replay/src/types/performance.ts rename packages/replay/src/{types.ts => types/replay.ts} (73%) create mode 100644 packages/replay/src/types/replayFrame.ts diff --git a/packages/replay/src/coreHandlers/handleScope.ts b/packages/replay/src/coreHandlers/handleScope.ts index 8f937b140882..78d0b6dd3fd3 100644 --- a/packages/replay/src/coreHandlers/handleScope.ts +++ b/packages/replay/src/coreHandlers/handleScope.ts @@ -3,12 +3,19 @@ import { normalize } from '@sentry/utils'; import { CONSOLE_ARG_MAX_SIZE } from '../constants'; import type { ReplayContainer } from '../types'; +import type { ReplayFrame } from '../types/replayFrame'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { fixJson } from '../util/truncateJson/fixJson'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; let _LAST_BREADCRUMB: null | Breadcrumb = null; +type BreadcrumbWithCategory = Required>; + +function isBreadcrumbWithCategory(breadcrumb: Breadcrumb): breadcrumb is BreadcrumbWithCategory { + return !!breadcrumb.category; +} + export const handleScopeListener: (replay: ReplayContainer) => (scope: Scope) => void = (replay: ReplayContainer) => (scope: Scope): void => { @@ -44,9 +51,9 @@ export function handleScope(scope: Scope): Breadcrumb | null { _LAST_BREADCRUMB = newBreadcrumb; if ( - newBreadcrumb.category && - (['fetch', 'xhr', 'sentry.event', 'sentry.transaction'].includes(newBreadcrumb.category) || - newBreadcrumb.category.startsWith('ui.')) + !isBreadcrumbWithCategory(newBreadcrumb) || + ['fetch', 'xhr', 'sentry.event', 'sentry.transaction'].includes(newBreadcrumb.category) || + newBreadcrumb.category.startsWith('ui.') ) { return null; } @@ -59,7 +66,9 @@ export function handleScope(scope: Scope): Breadcrumb | null { } /** exported for tests only */ -export function normalizeConsoleBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { +export function normalizeConsoleBreadcrumb( + breadcrumb: Omit & BreadcrumbWithCategory, +): ReplayFrame { const args = breadcrumb.data && breadcrumb.data.arguments; if (!Array.isArray(args) || args.length === 0) { diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index 0baefd4e9c37..239cd727d67f 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -1 +1,9 @@ export { Replay } from './integration'; +export type { + BreadcrumbFrame, + BreadcrumbFrameEvent, + ReplayFrame, + ReplayFrameEvent, + SpanFrame, + SpanFrameEvent, +} from './types/replayFrame'; diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 4d5c918b7610..e6f7e2128538 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up import { EventType, record } from '@sentry-internal/rrweb'; import { captureException, getCurrentHub } from '@sentry/core'; -import type { Breadcrumb, ReplayRecordingMode, Transaction } from '@sentry/types'; +import type { ReplayRecordingMode, Transaction } from '@sentry/types'; import { logger } from '@sentry/utils'; import { @@ -21,6 +21,7 @@ import type { AddEventResult, AddUpdateCallback, AllPerformanceEntry, + BreadcrumbFrame, EventBuffer, InternalEventContext, PopEventContext, @@ -808,7 +809,7 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Tasks to run when we consider a page to be hidden (via blurring and/or visibility) */ - private _doChangeToBackgroundTasks(breadcrumb?: Breadcrumb): void { + private _doChangeToBackgroundTasks(breadcrumb?: BreadcrumbFrame): void { if (!this.session) { return; } @@ -828,7 +829,7 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Tasks to run when we consider a page to be visible (via focus and/or visibility) */ - private _doChangeToForegroundTasks(breadcrumb?: Breadcrumb): void { + private _doChangeToForegroundTasks(breadcrumb?: BreadcrumbFrame): void { if (!this.session) { return; } @@ -881,7 +882,7 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Helper to create (and buffer) a replay breadcrumb from a core SDK breadcrumb */ - private _createCustomBreadcrumb(breadcrumb: Breadcrumb): void { + private _createCustomBreadcrumb(breadcrumb: BreadcrumbFrame): void { this.addUpdate(() => { void this.throttledAddEvent({ type: EventType.Custom, diff --git a/packages/replay/src/types/index.ts b/packages/replay/src/types/index.ts new file mode 100644 index 000000000000..2461e4f3ac8e --- /dev/null +++ b/packages/replay/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './performance'; +export * from './replay'; +export * from './replayFrame'; +export * from './rrweb'; diff --git a/packages/replay/src/types/performance.ts b/packages/replay/src/types/performance.ts new file mode 100644 index 000000000000..0ff647e7d7d0 --- /dev/null +++ b/packages/replay/src/types/performance.ts @@ -0,0 +1,160 @@ +export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming; + +// PerformancePaintTiming and PerformanceNavigationTiming are only available with TS 4.4 and newer +// Therefore, we're exporting them here to make them available in older TS versions +export type PerformancePaintTiming = PerformanceEntry; +export type PerformanceNavigationTiming = PerformanceEntry & + PerformanceResourceTiming & { + type: string; + transferSize: number; + + /** + * A DOMHighResTimeStamp representing the time immediately before the user agent + * sets the document's readyState to "interactive". + */ + domInteractive: number; + + /** + * A DOMHighResTimeStamp representing the time immediately before the current + * document's DOMContentLoaded event handler starts. + */ + domContentLoadedEventStart: number; + /** + * A DOMHighResTimeStamp representing the time immediately after the current + * document's DOMContentLoaded event handler completes. + */ + domContentLoadedEventEnd: number; + + /** + * A DOMHighResTimeStamp representing the time immediately before the current + * document's load event handler starts. + */ + loadEventStart: number; + + /** + * A DOMHighResTimeStamp representing the time immediately after the current + * document's load event handler completes. + */ + loadEventEnd: number; + + /** + * A DOMHighResTimeStamp representing the time immediately before the user agent + * sets the document's readyState to "complete". + */ + domComplete: number; + + /** + * A number representing the number of redirects since the last non-redirect + * navigation in the current browsing context. + */ + redirectCount: number; + }; +export type ExperimentalPerformanceResourceTiming = PerformanceResourceTiming & { + // Experimental, see: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus + // Requires Chrome 109 + responseStatus?: number; +}; + +export type PaintData = undefined; + +/** + * See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming + * + * Note `navigation.push` will not have any data + */ +export type NavigationData = Partial< + Pick< + PerformanceNavigationTiming, + | 'decodedBodySize' + | 'encodedBodySize' + | 'duration' + | 'domInteractive' + | 'domContentLoadedEventEnd' + | 'domContentLoadedEventStart' + | 'loadEventStart' + | 'loadEventEnd' + | 'domComplete' + | 'redirectCount' + > +> & { + /** + * Transfer size of resource + */ + size?: number; +}; + +export type ResourceData = Pick & { + /** + * Transfer size of resource + */ + size: number; + /** + * HTTP status code. Note this is experimental and not available on all browsers. + */ + statusCode?: number; +}; + +export interface LargestContentfulPaintData { + /** + * Render time (in ms) of the LCP + */ + value: number; + size: number; + /** + * The recording id of the LCP node. -1 if not found + */ + nodeId?: number; +} + +/** + * Entries that come from window.performance + */ +export type AllPerformanceEntryData = PaintData | NavigationData | ResourceData | LargestContentfulPaintData; + +export interface MemoryData { + memory: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + }; +} + +export interface NetworkRequestData { + method?: string; + statusCode?: number; + requestBodySize?: number; + responseBodySize?: number; +} + +export interface HistoryData { + previous: string; +} + +export type AllEntryData = AllPerformanceEntryData | MemoryData | NetworkRequestData | HistoryData; + +export interface ReplayPerformanceEntry { + /** + * One of these types https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType + */ + type: string; + + /** + * A more specific description of the performance entry + */ + name: string; + + /** + * The start timestamp in seconds + */ + start: number; + + /** + * The end timestamp in seconds + */ + end: number; + + /** + * Additional unstructured data to be included + */ + data: T; +} diff --git a/packages/replay/src/types.ts b/packages/replay/src/types/replay.ts similarity index 73% rename from packages/replay/src/types.ts rename to packages/replay/src/types/replay.ts index f52c163f6e69..262dcbf65a88 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types/replay.ts @@ -8,14 +8,14 @@ import type { XhrBreadcrumbHint, } from '@sentry/types'; -import type { eventWithTime, recordOptions } from './types/rrweb'; -import type { SKIPPED, THROTTLED } from './util/throttle'; +import type { SKIPPED, THROTTLED } from '../util/throttle'; +import type { AllPerformanceEntry } from './performance'; +import type { ReplayFrameEvent } from './replayFrame'; +import type { eventWithTime, recordOptions } from './rrweb'; -export type RecordingEvent = eventWithTime; +export type RecordingEvent = ReplayFrameEvent | eventWithTime; export type RecordingOptions = recordOptions; -export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming; - export interface SendReplayData { recordingData: ReplayRecordingData; replayId: string; @@ -41,138 +41,6 @@ export interface WorkerRequest { arg?: string; } -// PerformancePaintTiming and PerformanceNavigationTiming are only available with TS 4.4 and newer -// Therefore, we're exporting them here to make them available in older TS versions -export type PerformancePaintTiming = PerformanceEntry; -export type PerformanceNavigationTiming = PerformanceEntry & - PerformanceResourceTiming & { - type: string; - transferSize: number; - - /** - * A DOMHighResTimeStamp representing the time immediately before the user agent - * sets the document's readyState to "interactive". - */ - domInteractive: number; - - /** - * A DOMHighResTimeStamp representing the time immediately before the current - * document's DOMContentLoaded event handler starts. - */ - domContentLoadedEventStart: number; - /** - * A DOMHighResTimeStamp representing the time immediately after the current - * document's DOMContentLoaded event handler completes. - */ - domContentLoadedEventEnd: number; - - /** - * A DOMHighResTimeStamp representing the time immediately before the current - * document's load event handler starts. - */ - loadEventStart: number; - - /** - * A DOMHighResTimeStamp representing the time immediately after the current - * document's load event handler completes. - */ - loadEventEnd: number; - - /** - * A DOMHighResTimeStamp representing the time immediately before the user agent - * sets the document's readyState to "complete". - */ - domComplete: number; - - /** - * A number representing the number of redirects since the last non-redirect - * navigation in the current browsing context. - */ - redirectCount: number; - }; -export type ExperimentalPerformanceResourceTiming = PerformanceResourceTiming & { - // Experimental, see: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus - // Requires Chrome 109 - responseStatus?: number; -}; - -export type PaintData = undefined; - -/** - * See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming - * - * Note `navigation.push` will not have any data - */ -export type NavigationData = Partial< - Pick< - PerformanceNavigationTiming, - | 'decodedBodySize' - | 'encodedBodySize' - | 'duration' - | 'domInteractive' - | 'domContentLoadedEventEnd' - | 'domContentLoadedEventStart' - | 'loadEventStart' - | 'loadEventEnd' - | 'domComplete' - | 'redirectCount' - > -> & { - /** - * Transfer size of resource - */ - size?: number; -}; - -export type ResourceData = Pick & { - /** - * Transfer size of resource - */ - size: number; - /** - * HTTP status code. Note this is experimental and not available on all browsers. - */ - statusCode?: number; -}; - -export interface LargestContentfulPaintData { - /** - * Render time (in ms) of the LCP - */ - value: number; - size: number; - /** - * The recording id of the LCP node. -1 if not found - */ - nodeId?: number; -} - -/** - * Entries that come from window.performance - */ -export type AllPerformanceEntryData = PaintData | NavigationData | ResourceData | LargestContentfulPaintData; - -export interface MemoryData { - memory: { - jsHeapSizeLimit: number; - totalJSHeapSize: number; - usedJSHeapSize: number; - }; -} - -export interface NetworkRequestData { - method?: string; - statusCode?: number; - requestBodySize?: number; - responseBodySize?: number; -} - -export interface HistoryData { - previous: string; -} - -export type AllEntryData = AllPerformanceEntryData | MemoryData | NetworkRequestData | HistoryData; - /** * The response from the worker */ @@ -186,7 +54,7 @@ export interface WorkerResponse { export type AddEventResult = void; export interface BeforeAddRecordingEvent { - (event: RecordingEvent): RecordingEvent | null | undefined; + (event: ReplayFrameEvent): ReplayFrameEvent | null | undefined; } export interface ReplayNetworkOptions { @@ -565,33 +433,6 @@ export interface ReplayContainer { getCurrentRoute(): string | undefined; } -export interface ReplayPerformanceEntry { - /** - * One of these types https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType - */ - type: string; - - /** - * A more specific description of the performance entry - */ - name: string; - - /** - * The start timestamp in seconds - */ - start: number; - - /** - * The end timestamp in seconds - */ - end: number; - - /** - * Additional unstructured data to be included - */ - data: T; -} - type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; export type XhrHint = XhrBreadcrumbHint & { diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts new file mode 100644 index 000000000000..463e462851e6 --- /dev/null +++ b/packages/replay/src/types/replayFrame.ts @@ -0,0 +1,174 @@ +import type { Breadcrumb, FetchBreadcrumbData, XhrBreadcrumbData } from '@sentry/types'; + +import type { AllEntryData } from './performance'; +import type { EventType } from './rrweb'; + +interface BaseReplayFrame { + timestamp: number; + /** + * For compatibility reasons + */ + type: string; + category: string; + data?: Record; + message?: string; +} + +interface BaseDomFrameData { + nodeId?: number; + node?: { + id: number; + tagName: string; + textContent: string; + attributes: Record; + }; +} + +/* Breadcrumbs from Core SDK */ +interface ConsoleFrameData { + logger: string; + arguments?: unknown[]; +} +interface ConsoleFrame extends BaseReplayFrame { + category: 'console'; + level: Breadcrumb['level']; + message: string; + data: ConsoleFrameData; +} + +type ClickFrameData = BaseDomFrameData; +interface ClickFrame extends BaseReplayFrame { + category: 'ui.click'; + message: string; + data: ClickFrameData; +} + +interface FetchFrame extends BaseReplayFrame { + category: 'fetch'; + type: 'http'; + data: FetchBreadcrumbData; +} + +interface InputFrame extends BaseReplayFrame { + category: 'ui.input'; + message: string; +} + +interface XhrFrame extends BaseReplayFrame { + category: 'xhr'; + type: 'http'; + data: XhrBreadcrumbData; +} + +/* Breadcrumbs from Replay */ +interface MutationFrameData { + count: number; + limit: boolean; +} +interface MutationFrame extends BaseReplayFrame { + category: 'replay.mutations'; + data: MutationFrameData; +} + +interface KeyboardEventFrameData extends BaseDomFrameData { + metaKey: boolean; + shiftKey: boolean; + ctrlKey: boolean; + altKey: boolean; + key: string; +} +interface KeyboardEventFrame extends BaseReplayFrame { + category: 'ui.keyDown'; + data: KeyboardEventFrameData; +} + +interface BlurFrame extends BaseReplayFrame { + category: 'ui.blur'; +} + +interface FocusFrame extends BaseReplayFrame { + category: 'ui.focus'; +} + +interface SlowClickFrameData extends ClickFrameData { + url: string; + timeAfterClickFs: number; + endReason: string; +} +interface SlowClickFrame extends BaseReplayFrame { + category: 'ui.slowClickDetected'; + data: SlowClickFrameData; +} + +interface OptionFrame { + sessionSampleRate: number; + errorSampleRate: number; + useCompressionOption: boolean; + blockAllMedia: boolean; + maskAllText: boolean; + maskAllInputs: boolean; + useCompression: boolean; + networkDetailHasUrls: boolean; + networkCaptureBodies: boolean; + networkRequestHasHeaders: boolean; + networkResponseHasHeaders: boolean; +} + +export type BreadcrumbFrame = + | ConsoleFrame + | ClickFrame + | FetchFrame + | InputFrame + | XhrFrame + | KeyboardEventFrame + | BlurFrame + | FocusFrame + | SlowClickFrame + | MutationFrame + | BaseReplayFrame; + +export interface SpanFrame { + op: string; + description: string; + startTimestamp: number; + endTimestamp: number; + data: AllEntryData; +} + +export type ReplayFrame = BreadcrumbFrame | SpanFrame; + +interface RecordingCustomEvent { + type: EventType.Custom; + timestamp: number; + data: { + tag: string; + payload: unknown; + }; +} + +export interface BreadcrumbFrameEvent extends RecordingCustomEvent { + data: { + tag: 'breadcrumb'; + payload: BreadcrumbFrame; + /** + * This will indicate to backend to additionally log as a metric + */ + metric?: boolean; + }; +} + +export interface SpanFrameEvent extends RecordingCustomEvent { + data: { + tag: 'performanceSpan'; + payload: SpanFrame; + }; +} + +export interface OptionFrameEvent extends RecordingCustomEvent { + data: { + tag: 'options'; + payload: OptionFrame; + }; +} + +export type ReplayFrameEvent = BreadcrumbFrameEvent | SpanFrameEvent | OptionFrameEvent; diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index e40a9c2f6486..2a5458887af6 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -1,10 +1,14 @@ import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; -import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types'; +import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types'; import { EventType } from '../types/rrweb'; import { timestampToMs } from './timestampToMs'; +function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent { + return event.type === EventType.Custom; +} + /** * Add an event to the event buffer. * `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`. @@ -42,7 +46,7 @@ export async function addEvent( const replayOptions = replay.getOptions(); const eventAfterPossibleCallback = - typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom + typeof replayOptions.beforeAddRecordingEvent === 'function' && isCustomEvent(event) ? replayOptions.beforeAddRecordingEvent(event) : event; diff --git a/packages/replay/src/util/createBreadcrumb.ts b/packages/replay/src/util/createBreadcrumb.ts index b8ff6097d571..5cf044333876 100644 --- a/packages/replay/src/util/createBreadcrumb.ts +++ b/packages/replay/src/util/createBreadcrumb.ts @@ -1,13 +1,11 @@ -import type { Breadcrumb } from '@sentry/types'; - -type RequiredProperties = 'category' | 'message'; +import type { BreadcrumbFrame } from '../types/replayFrame'; /** * Create a breadcrumb for a replay. */ export function createBreadcrumb( - breadcrumb: Pick & Partial>, -): Breadcrumb { + breadcrumb: Omit & Partial>, +): BreadcrumbFrame { return { timestamp: Date.now() / 1000, type: 'default', diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index 3a9dcc211edd..e4d507d33456 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -1,7 +1,7 @@ import { logger } from '@sentry/utils'; import { saveSession } from '../session/saveSession'; -import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types'; +import type { AddEventResult, OptionFrameEvent, RecordingEvent, ReplayContainer } from '../types'; import { EventType } from '../types/rrweb'; import { addEvent } from './addEvent'; @@ -121,7 +121,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa /** * Exported for tests */ -export function createOptionsEvent(replay: ReplayContainer): RecordingEvent { +export function createOptionsEvent(replay: ReplayContainer): OptionFrameEvent { const options = replay.getOptions(); return { type: EventType.Custom, diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts index c01140045389..0f9db6554ec9 100644 --- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -37,7 +37,7 @@ describe('Integration | beforeAddRecordingEvent', () => { ({ replay, integration } = await mockSdk({ replayOptions: { beforeAddRecordingEvent: event => { - const eventData = event.data as Record; + const eventData = event.data; if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') { return { diff --git a/packages/replay/test/integration/coreHandlers/handleScope.test.ts b/packages/replay/test/integration/coreHandlers/handleScope.test.ts index ec17d430ff52..d9d30d710a6a 100644 --- a/packages/replay/test/integration/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleScope.test.ts @@ -23,10 +23,10 @@ describe('Integration | coreHandlers | handleScope', () => { expect(mockHandleScopeListener).toHaveBeenCalledTimes(1); - getCurrentHub().getScope()?.addBreadcrumb({ message: 'testing' }); + getCurrentHub().getScope()?.addBreadcrumb({ category: 'console', message: 'testing' }); expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing' })); + expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ category: 'console', message: 'testing' })); mockHandleScope.mockClear(); diff --git a/packages/replay/test/unit/coreHandlers/handleScope.test.ts b/packages/replay/test/unit/coreHandlers/handleScope.test.ts index 1bce28f860c8..9ce56b89d1e0 100644 --- a/packages/replay/test/unit/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleScope.test.ts @@ -63,21 +63,21 @@ describe('Unit | coreHandlers | handleScope', () => { describe('normalizeConsoleBreadcrumb', () => { it('handles console messages with no arguments', () => { - const breadcrumb: Breadcrumb = { category: 'console', message: 'test' }; + const breadcrumb = { category: 'console', message: 'test' }; const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); expect(actual).toMatchObject({ category: 'console', message: 'test' }); }); it('handles console messages with empty arguments', () => { - const breadcrumb: Breadcrumb = { category: 'console', message: 'test', data: { arguments: [] } }; + const breadcrumb = { category: 'console', message: 'test', data: { arguments: [] } }; const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); expect(actual).toMatchObject({ category: 'console', message: 'test', data: { arguments: [] } }); }); it('handles console messages with simple arguments', () => { - const breadcrumb: Breadcrumb = { + const breadcrumb = { category: 'console', message: 'test', data: { arguments: [1, 'a', true, null, undefined] }, @@ -94,7 +94,7 @@ describe('Unit | coreHandlers | handleScope', () => { }); it('truncates large strings', () => { - const breadcrumb: Breadcrumb = { + const breadcrumb = { category: 'console', message: 'test', data: { @@ -114,7 +114,7 @@ describe('Unit | coreHandlers | handleScope', () => { }); it('truncates large JSON objects', () => { - const breadcrumb: Breadcrumb = { + const breadcrumb = { category: 'console', message: 'test', data: { diff --git a/packages/replay/test/unit/util/handleRecordingEmit.test.ts b/packages/replay/test/unit/util/handleRecordingEmit.test.ts index a4c7f82c425d..7978939291bd 100644 --- a/packages/replay/test/unit/util/handleRecordingEmit.test.ts +++ b/packages/replay/test/unit/util/handleRecordingEmit.test.ts @@ -1,7 +1,7 @@ import { EventType } from '@sentry-internal/rrweb'; import { BASE_TIMESTAMP } from '../..'; -import type { RecordingEvent } from '../../../src/types'; +import type { OptionFrameEvent } from '../../../src/types'; import * as SentryAddEvent from '../../../src/util/addEvent'; import { createOptionsEvent, getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; @@ -9,7 +9,7 @@ import { useFakeTimers } from '../../utils/use-fake-timers'; useFakeTimers(); -let optionsEvent: RecordingEvent; +let optionsEvent: OptionFrameEvent; describe('Unit | util | handleRecordingEmit', () => { let addEventMock: jest.SpyInstance; From 9304ec3ab4a7bf05a08813d37d6becbce49776f7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 5 Jun 2023 10:02:00 +0200 Subject: [PATCH 05/13] ref(svelte): Add Svelte 4 as a peer dependency (#8280) --- packages/svelte/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fa63ba9d68e3..33114f3b9f3d 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -23,7 +23,7 @@ "tslib": "^1.9.3" }, "peerDependencies": { - "svelte": "3.x" + "svelte": "3.x || 4.x" }, "devDependencies": { "@testing-library/svelte": "^3.2.1", From bca567e56dc2beab367eeac22a6babea4a959d1e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 5 Jun 2023 13:02:44 +0200 Subject: [PATCH 06/13] test(e2e): Disable concurrent tests for canary tests (#8287) --- packages/e2e-tests/lib/runAllTestApps.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/e2e-tests/lib/runAllTestApps.ts b/packages/e2e-tests/lib/runAllTestApps.ts index ad30c34e4738..d5f4c247f2e6 100644 --- a/packages/e2e-tests/lib/runAllTestApps.ts +++ b/packages/e2e-tests/lib/runAllTestApps.ts @@ -8,7 +8,11 @@ export async function runAllTestApps( recipePaths: string[], envVarsToInject: Record, ): Promise { - const maxParallel = process.env.CI ? 3 : 6; + const maxParallel = process.env.CANARY_E2E_TEST + ? 1 // TODO: figure out why concurrent tests fail for Next.js and remove this concurrency limitation + : process.env.CI + ? 3 + : 6; const recipeInstances = constructRecipeInstances(recipePaths); From 62e72656104c4e386ca56afd3ecdb9fb34f6d631 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jun 2023 02:04:11 +0000 Subject: [PATCH 07/13] chore(deps-dev): bump vite from 4.0.0 to 4.0.5 Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.0.0 to 4.0.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v4.0.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v4.0.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- packages/sveltekit/package.json | 2 +- yarn.lock | 56 +++++++++++---------------------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index bdd7487c5e81..95289150547e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -36,7 +36,7 @@ "rollup": "^3.20.2", "svelte": "^3.44.0", "typescript": "^4.9.3", - "vite": "4.0.0" + "vite": "4.0.5" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/yarn.lock b/yarn.lock index d118d336daf6..b4ab26edf619 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18928,11 +18928,16 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== -nanoid@^3.1.16, nanoid@^3.1.20, nanoid@^3.1.23, nanoid@^3.3.4: +nanoid@^3.1.16, nanoid@^3.1.20, nanoid@^3.1.23: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.6: + version "3.3.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -22042,21 +22047,12 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2 picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.1.10, postcss@^8.1.7, postcss@^8.2.15: - version "8.4.19" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc" - integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA== +postcss@^8.1.10, postcss@^8.1.7, postcss@^8.2.15, postcss@^8.2.4, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.20, postcss@^8.4.21: + version "8.4.24" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df" + integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg== dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.2.4, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.19, postcss@^8.4.21: - version "8.4.21" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" - integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== - dependencies: - nanoid "^3.3.4" + nanoid "^3.3.6" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -23638,41 +23634,27 @@ rollup@2.26.5: optionalDependencies: fsevents "~2.1.2" -rollup@2.78.0, rollup@^2.67.1, rollup@^2.8.0: +rollup@2.78.0: version "2.78.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e" integrity sha512-4+YfbQC9QEVvKTanHhIAFVUFSRsezvQF8vFOJwtGfb9Bb+r014S+qryr9PSmw8x6sMnPkmFBGAvIFVQxvJxjtg== optionalDependencies: fsevents "~2.3.2" -rollup@^2.45.1: +rollup@^2.45.1, rollup@^2.67.1, rollup@^2.8.0: version "2.79.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== optionalDependencies: fsevents "~2.3.2" -rollup@^3.10.0: - version "3.19.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.19.1.tgz#2b3a31ac1ff9f3afab2e523fa687fef5b0ee20fc" - integrity sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg== - optionalDependencies: - fsevents "~2.3.2" - -rollup@^3.20.2: +rollup@^3.10.0, rollup@^3.20.2, rollup@^3.7.0: version "3.20.2" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.2.tgz#f798c600317f216de2e4ad9f4d9ab30a89b690ff" integrity sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg== optionalDependencies: fsevents "~2.3.2" -rollup@^3.7.0: - version "3.18.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.18.0.tgz#2354ba63ba66d6a09c652c3ea0dbcd9dad72bbde" - integrity sha512-J8C6VfEBjkvYPESMQYxKHxNOh4A5a3FlP+0BETGo34HEcE4eTlgCrO2+eWzlu2a/sHs2QUkZco+wscH7jhhgWg== - optionalDependencies: - fsevents "~2.3.2" - rsvp@^3.0.14, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21, rsvp@^3.0.6, rsvp@^3.1.0: version "3.6.2" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" @@ -27017,13 +26999,13 @@ vite-node@0.29.2: picocolors "^1.0.0" vite "^3.0.0 || ^4.0.0" -vite@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.0.0.tgz#b81b88349a06b2faaa53ae14cf96c942548e3454" - integrity sha512-ynad+4kYs8Jcnn8J7SacS9vAbk7eMy0xWg6E7bAhS1s79TK+D7tVFGXVZ55S7RNLRROU1rxoKlvZ/qjaB41DGA== +vite@4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.0.5.tgz#634f0bd1edf8bb8468ed42a1c3fd938c67d2f94b" + integrity sha512-7m87RC+caiAxG+8j3jObveRLqaWA/neAdCat6JAZwMkSWqFHOvg8MYe5fAQxVBRAuKAQ1S6XDh3CBQuLNbY33w== dependencies: esbuild "^0.16.3" - postcss "^8.4.19" + postcss "^8.4.20" resolve "^1.22.1" rollup "^3.7.0" optionalDependencies: From 8ffde2a653eb089f33e5f648bd0eeca2176a5f36 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 6 Jun 2023 10:02:05 -0400 Subject: [PATCH 08/13] fix(replay): Ignore max session life for buffered sessions (#8258) Theres an edge case where a buffered session becomes expired, an error comes in, the link from error to replay is lost because a new session is created due to the session being expired. We should either ignore the max session life for a buffered session, or possibly check/refresh session when an error comes in. --- packages/replay/src/replay.ts | 16 ++- packages/replay/src/session/getSession.ts | 8 +- .../replay/src/util/handleRecordingEmit.ts | 4 + .../errorSampleRate-delayFlush.test.ts | 117 ++++++++++++++++-- .../test/integration/errorSampleRate.test.ts | 69 ++++++++--- .../test/unit/session/getSession.test.ts | 59 +++++++++ 6 files changed, 236 insertions(+), 37 deletions(-) diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index e6f7e2128538..a00b96f23c19 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -403,6 +403,8 @@ export class ReplayContainer implements ReplayContainerInterface { return this.flushImmediate(); } + const activityTime = Date.now(); + // Allow flush to complete before resuming as a session recording, otherwise // the checkout from `startRecording` may be included in the payload. // Prefer to keep the error replay as a separate (and smaller) segment @@ -424,6 +426,18 @@ export class ReplayContainer implements ReplayContainerInterface { // Once this session ends, we do not want to refresh it if (this.session) { this.session.shouldRefresh = false; + + // It's possible that the session lifespan is > max session lifespan + // because we have been buffering beyond max session lifespan (we ignore + // expiration given that `shouldRefresh` is true). Since we flip + // `shouldRefresh`, the session could be considered expired due to + // lifespan, which is not what we want. Update session start date to be + // the current timestamp, so that session is not considered to be + // expired. This means that max replay duration can be MAX_SESSION_LIFE + + // (length of buffer), which we are ok with. + this._updateUserActivity(activityTime); + this._updateSessionActivity(activityTime); + this.session.started = activityTime; this._maybeSaveSession(); } @@ -689,7 +703,7 @@ export class ReplayContainer implements ReplayContainerInterface { stickySession: Boolean(this._options.stickySession), currentSession: this.session, sessionSampleRate: this._options.sessionSampleRate, - allowBuffering: this._options.errorSampleRate > 0, + allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer', }); // If session was newly created (i.e. was not loaded from storage), then diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts index ff993887e64b..73554a8860de 100644 --- a/packages/replay/src/session/getSession.ts +++ b/packages/replay/src/session/getSession.ts @@ -34,11 +34,13 @@ export function getSession({ // within "max session time"). const isExpired = isSessionExpired(session, timeouts); - if (!isExpired) { + if (!isExpired || (allowBuffering && session.shouldRefresh)) { return { type: 'saved', session }; } else if (!session.shouldRefresh) { - // In this case, stop - // This is the case if we have an error session that is completed (=triggered an error) + // This is the case if we have an error session that is completed + // (=triggered an error). Session will continue as session-based replay, + // and when this session is expired, it will not be renewed until user + // reloads. const discardedSession = makeSession({ sampled: false }); return { type: 'new', session: discardedSession }; } else { diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index e4d507d33456..ffb0a993dc89 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -89,6 +89,10 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // a previous session ID. In this case, we want to buffer events // for a set amount of time before flushing. This can help avoid // capturing replays of users that immediately close the window. + // TODO: We should check `recordingMode` here and do nothing if it's + // buffer, instead of checking inside of timeout, this will make our + // tests a bit cleaner as we will need to wait on the delay in order to + // do nothing. setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout); // Cancel any previously debounced flushes to ensure there are no [near] diff --git a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts index 20645b1b85a4..f691d8e953c1 100644 --- a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts +++ b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts @@ -573,6 +573,7 @@ describe('Integration | errorSampleRate with delayed flush', () => { it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { const ELAPSED = BUFFER_CHECKOUT_TIME; + const TICK = 20; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -593,26 +594,29 @@ describe('Integration | errorSampleRate with delayed flush', () => { const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); - jest.advanceTimersByTime(20); await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); await waitForBufferFlush(); - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20); + // See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a + // noop when it can be skipped altogether + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK); // Does not capture mouse click expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000, + replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + 20, + timestamp: BASE_TIMESTAMP + ELAPSED + TICK, type: 2, }, optionsEvent, @@ -662,7 +666,8 @@ describe('Integration | errorSampleRate with delayed flush', () => { expect(replay.isEnabled()).toBe(false); }); - it('stops replay when session exceeds max length', async () => { + it('stops replay when session exceeds max length after latest captured error', async () => { + const sessionId = replay.session?.id; jest.setSystemTime(BASE_TIMESTAMP); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; @@ -674,34 +679,120 @@ describe('Integration | errorSampleRate with delayed flush', () => { jest.runAllTimers(); await new Promise(process.nextTick); + jest.advanceTimersByTime(2 * MAX_SESSION_LIFE); + captureException(new Error('testing')); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + // Flush due to exception + await new Promise(process.nextTick); + await waitForFlush(); + + expect(replay.session?.id).toBe(sessionId); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + }); + + // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` + await waitForFlush(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + recordingData: JSON.stringify([ + { + data: { + isCheckout: true, + }, + timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, + type: 2, + }, + ]), + }); + + // Now wait after session expires - should stop recording + mockRecord.takeFullSnapshot.mockClear(); + (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + + jest.advanceTimersByTime(MAX_SESSION_LIFE); + await new Promise(process.nextTick); + + mockRecord._emitter(TEST_EVENT); + jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(false); + + // Once the session is stopped after capturing a replay already + // (buffer-mode), another error will not trigger a new replay + captureException(new Error('testing')); - // Wait a bit, shortly before session expires - jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000); await new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + }); + it('does not stop replay based on earliest event in buffer', async () => { + jest.setSystemTime(BASE_TIMESTAMP); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; mockRecord._emitter(TEST_EVENT); - replay.triggerUserActivity(); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); + + await waitForBufferFlush(); expect(replay).toHaveLastSentReplay(); + // Flush from calling `stopRecording` + await waitForFlush(); + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - jest.advanceTimersByTime(10_000); + expect(replay).not.toHaveLastSentReplay(); + + const TICKS = 80; + + // We advance time so that we are on the border of expiring, taking into + // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The + // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has + // happened, and for the next two that will happen. The first following + // `waitForFlush` does not expire session, but the following one will. + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); await new Promise(process.nextTick); mockRecord._emitter(TEST_EVENT); - replay.triggerUserActivity(); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + // It's hard to test, but if we advance the below time less 1 ms, it should + // be enabled, but we can't trigger a session check via flush without + // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. + jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 3145ba37e7f9..ea1825dd8429 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -432,6 +432,9 @@ describe('Integration | errorSampleRate', () => { ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], ['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION], ])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => { + const oldSessionId = replay.session?.id; + expect(oldSessionId).toBeDefined(); + expect(replay).not.toHaveLastSentReplay(); // Idle for given time @@ -475,13 +478,24 @@ describe('Integration | errorSampleRate', () => { jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); await new Promise(process.nextTick); - expect(replay).toHaveLastSentReplay({ + expect(replay.session?.id).toBe(oldSessionId); + + // Flush of buffered events + expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), }); + // Checkout from `startRecording` + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); + expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); expect(replay.recordingMode).toBe('session'); @@ -491,6 +505,9 @@ describe('Integration | errorSampleRate', () => { // Should behave the same as above test it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => { + const oldSessionId = replay.session?.id; + expect(oldSessionId).toBeDefined(); + // Idle for 15 minutes jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); @@ -517,14 +534,24 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); await new Promise(process.nextTick); + expect(replay.session?.id).toBe(oldSessionId); - expect(replay).toHaveLastSentReplay({ + // buffered events + expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), }); + // `startRecording` full checkout + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); + expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); expect(replay.recordingMode).toBe('session'); @@ -595,17 +622,15 @@ describe('Integration | errorSampleRate', () => { const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); - jest.advanceTimersByTime(20); await new Promise(process.nextTick); captureException(new Error('testing')); await new Promise(process.nextTick); jest.runAllTimers(); - jest.advanceTimersByTime(20); await new Promise(process.nextTick); - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20); + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 40); // Does not capture mouse click expect(replay).toHaveSentReplay({ @@ -667,7 +692,8 @@ describe('Integration | errorSampleRate', () => { expect(replay.isEnabled()).toBe(false); }); - it('stops replay when session exceeds max length', async () => { + it('stops replay when session exceeds max length after latest captured error', async () => { + const sessionId = replay.session?.id; jest.setSystemTime(BASE_TIMESTAMP); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; @@ -679,37 +705,40 @@ describe('Integration | errorSampleRate', () => { jest.runAllTimers(); await new Promise(process.nextTick); + jest.advanceTimersByTime(2 * MAX_SESSION_LIFE); + captureException(new Error('testing')); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + // Flush due to exception await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - // Wait a bit, shortly before session expires - jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - replay.triggerUserActivity(); - + expect(replay.session?.id).toBe(sessionId); expect(replay).toHaveLastSentReplay(); - // Now wait after session expires - should stop recording + // Now wait after session expires - should re-start into buffering mode mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - jest.advanceTimersByTime(10_000); + jest.advanceTimersByTime(MAX_SESSION_LIFE); await new Promise(process.nextTick); mockRecord._emitter(TEST_EVENT); - replay.triggerUserActivity(); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); expect(replay.isEnabled()).toBe(false); + + // Once the session is stopped after capturing a replay already + // (buffer-mode), another error should trigger a new replay + captureException(new Error('testing')); + + await new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); }); }); diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts index 2905e1bd72d6..aa3110d114f2 100644 --- a/packages/replay/test/unit/session/getSession.test.ts +++ b/packages/replay/test/unit/session/getSession.test.ts @@ -229,4 +229,63 @@ describe('Unit | session | getSession', () => { expect(session.id).toBe('test_session_uuid_2'); expect(session.segmentId).toBe(0); }); + + it('re-uses the same "buffer" session if it is expired and has never sent a buffered replay', function () { + const { type, session } = getSession({ + timeouts: { + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, + maxSessionLife: MAX_SESSION_LIFE, + }, + stickySession: false, + ...SAMPLE_OPTIONS, + currentSession: makeSession({ + id: 'test_session_uuid_2', + lastActivity: +new Date() - MAX_SESSION_LIFE - 1, + started: +new Date() - MAX_SESSION_LIFE - 1, + segmentId: 0, + sampled: 'buffer', + }), + allowBuffering: true, + }); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(type).toBe('saved'); + expect(session.id).toBe('test_session_uuid_2'); + expect(session.sampled).toBe('buffer'); + expect(session.segmentId).toBe(0); + }); + + it('creates a new session if it is expired and it was a "buffer" session that has sent a replay', function () { + const currentSession = makeSession({ + id: 'test_session_uuid_2', + lastActivity: +new Date() - MAX_SESSION_LIFE - 1, + started: +new Date() - MAX_SESSION_LIFE - 1, + segmentId: 0, + sampled: 'buffer', + }); + currentSession.shouldRefresh = false; + + const { type, session } = getSession({ + timeouts: { + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, + maxSessionLife: MAX_SESSION_LIFE, + }, + stickySession: false, + ...SAMPLE_OPTIONS, + currentSession, + allowBuffering: true, + }); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(type).toBe('new'); + expect(session.id).not.toBe('test_session_uuid_2'); + expect(session.sampled).toBe(false); + expect(session.segmentId).toBe(0); + }); }); From 1b375173046f9a583327e4e95d0b7990c8a3e247 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 7 Jun 2023 15:08:52 +0200 Subject: [PATCH 09/13] feat(replay): Capture slow clicks (GA) (#8298) This moves the slow click detection out of GA and makes it generally available. You can opt-out of this by setting `slowClickTimeout: 0`. It also adds `disabled` & `aria-disabled` attributes to captured DOM elements. note: We now capture this for ``, `