From 41dad559787554cdbd4acea3b0a892e0603158cf Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 9 Jan 2023 11:30:48 +0100 Subject: [PATCH 1/4] fix(nextjs): Don't write to `res.end` to fix `next export` (#6682) --- packages/nextjs/src/config/wrappers/utils/responseEnd.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/wrappers/utils/responseEnd.ts b/packages/nextjs/src/config/wrappers/utils/responseEnd.ts index 21d5f5d850a5..82b6fb69ea28 100644 --- a/packages/nextjs/src/config/wrappers/utils/responseEnd.ts +++ b/packages/nextjs/src/config/wrappers/utils/responseEnd.ts @@ -31,7 +31,8 @@ export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: S }; // Prevent double-wrapping - if (!(res.end as WrappedResponseEndMethod).__sentry_original__) { + // res.end may be undefined during build when using `next export` to statically export a Next.js app + if (res.end && !(res.end as WrappedResponseEndMethod).__sentry_original__) { fill(res, 'end', wrapEndMethod); } } From e5422c143495e5c385505228734f2b111e243794 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 9 Jan 2023 11:32:31 +0100 Subject: [PATCH 2/4] fix(nextjs): Don't wrap `res.json` and `res.send` (#6674) --- packages/nextjs/src/config/wrappers/types.ts | 5 ++++ .../src/config/wrappers/withSentryAPI.ts | 28 +++---------------- .../pages/api/doubleEndMethodOnVercel.ts | 11 ++++++++ .../test/server/doubleEndMethodOnVercel.js | 10 +++++++ 4 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts create mode 100644 packages/nextjs/test/integration/test/server/doubleEndMethodOnVercel.js diff --git a/packages/nextjs/src/config/wrappers/types.ts b/packages/nextjs/src/config/wrappers/types.ts index 7ad042cc2474..a411c4ea62cf 100644 --- a/packages/nextjs/src/config/wrappers/types.ts +++ b/packages/nextjs/src/config/wrappers/types.ts @@ -25,6 +25,11 @@ import type { NextApiRequest, NextApiResponse } from 'next'; export type NextApiHandler = { (req: NextApiRequest, res: NextApiResponse): void | Promise | unknown | Promise; __sentry_route__?: string; + + /** + * A property we set in our integration tests to simulate running an API route on platforms that don't support streaming. + */ + __sentry_test_doesnt_support_streaming__?: true; }; export type WrappedNextApiHandler = { diff --git a/packages/nextjs/src/config/wrappers/withSentryAPI.ts b/packages/nextjs/src/config/wrappers/withSentryAPI.ts index e047be166ed4..561939685aa0 100644 --- a/packages/nextjs/src/config/wrappers/withSentryAPI.ts +++ b/packages/nextjs/src/config/wrappers/withSentryAPI.ts @@ -130,31 +130,11 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str ); currentScope.setSpan(transaction); - if (platformSupportsStreaming()) { + if (platformSupportsStreaming() && !origHandler.__sentry_test_doesnt_support_streaming__) { autoEndTransactionOnResponseEnd(transaction, res); } else { - // If we're not on a platform that supports streaming, we're blocking all response-ending methods until the - // queue is flushed. - - const origResSend = res.send; - res.send = async function (this: unknown, ...args: unknown[]) { - if (transaction) { - await finishTransaction(transaction, res); - await flushQueue(); - } - - origResSend.apply(this, args); - }; - - const origResJson = res.json; - res.json = async function (this: unknown, ...args: unknown[]) { - if (transaction) { - await finishTransaction(transaction, res); - await flushQueue(); - } - - origResJson.apply(this, args); - }; + // If we're not on a platform that supports streaming, we're blocking res.end() until the queue is flushed. + // res.json() and res.send() will implicitly call res.end(), so it is enough to wrap res.end(). // eslint-disable-next-line @typescript-eslint/unbound-method const origResEnd = res.end; @@ -223,7 +203,7 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not // deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already // be finished and the queue will already be empty, so effectively it'll just no-op.) - if (platformSupportsStreaming()) { + if (platformSupportsStreaming() && !origHandler.__sentry_test_doesnt_support_streaming__) { void finishTransaction(transaction, res); } else { await finishTransaction(transaction, res); diff --git a/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts b/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts new file mode 100644 index 000000000000..f32fcf55fafd --- /dev/null +++ b/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts @@ -0,0 +1,11 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { + // This handler calls .end twice. We test this to verify that this still doesn't throw because we're wrapping `.end`. + res.status(200).json({ success: true }); + res.end(); +}; + +handler.__sentry_test_doesnt_support_streaming__ = true; + +export default handler; diff --git a/packages/nextjs/test/integration/test/server/doubleEndMethodOnVercel.js b/packages/nextjs/test/integration/test/server/doubleEndMethodOnVercel.js new file mode 100644 index 000000000000..fa2b0e7cbeb4 --- /dev/null +++ b/packages/nextjs/test/integration/test/server/doubleEndMethodOnVercel.js @@ -0,0 +1,10 @@ +const assert = require('assert'); +const { getAsync } = require('../utils/server'); + +// This test asserts that our wrapping of `res.end` doesn't break API routes on Vercel if people call `res.json` or +// `res.send` multiple times in one request handler. +// https://github.com/getsentry/sentry-javascript/issues/6670 +module.exports = async ({ url: urlBase }) => { + const response = await getAsync(`${urlBase}/api/doubleEndMethodOnVercel`); + assert.equal(response, '{"success":true}'); +}; From 1b4d9daffbc39946bcf0fa3cde259379ff66f227 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 9 Jan 2023 11:48:55 +0100 Subject: [PATCH 3/4] ref(replay): Add jsdoc to all replay modules (#6654) This is one more step of https://github.com/getsentry/sentry-javascript/issues/6323. Note that this also renames/moves a few things to make more sense. --- docs/event-sending.md | 4 +- packages/replay/.eslintrc.js | 4 +- .../src/coreHandlers/breadcrumbHandler.ts | 3 + packages/replay/src/coreHandlers/handleDom.ts | 3 + .../replay/src/coreHandlers/handleFetch.ts | 3 +- .../replay/src/coreHandlers/handleHistory.ts | 3 +- .../replay/src/coreHandlers/handleScope.ts | 3 + packages/replay/src/coreHandlers/handleXhr.ts | 3 +- packages/replay/src/createPerformanceEntry.ts | 85 ++++--------------- packages/replay/src/eventBuffer.ts | 24 +++++- packages/replay/src/integration.ts | 6 ++ packages/replay/src/replay.ts | 16 ++-- packages/replay/src/session/saveSession.ts | 3 + packages/replay/src/types.ts | 27 ++++++ packages/replay/src/util/addMemoryEntry.ts | 30 ++++++- packages/replay/src/util/createBreadcrumb.ts | 3 + .../replay/src/util/createPerformanceSpans.ts | 3 +- ...reatePayload.ts => createRecordingData.ts} | 5 +- .../replay/src/util/createReplayEnvelope.ts | 4 + packages/replay/src/util/isBrowser.ts | 3 + packages/replay/src/util/isRrwebError.ts | 3 + .../src/util/monkeyPatchRecordDroppedEvent.ts | 6 ++ ...etReplayEvent.ts => prepareReplayEvent.ts} | 5 +- ...ent.test.ts => prepareReplayEvent.test.ts} | 7 +- 24 files changed, 161 insertions(+), 95 deletions(-) rename packages/replay/src/util/{createPayload.ts => createRecordingData.ts} (90%) rename packages/replay/src/util/{getReplayEvent.ts => prepareReplayEvent.ts} (90%) rename packages/replay/test/unit/util/{getReplayEvent.test.ts => prepareReplayEvent.test.ts} (88%) diff --git a/docs/event-sending.md b/docs/event-sending.md index 06077e807ed3..f014e1f2f94e 100644 --- a/docs/event-sending.md +++ b/docs/event-sending.md @@ -77,8 +77,8 @@ This document gives an outline for how event sending works, and which which plac ## Replay (WIP) * `replay.sendReplayRequest()` - * `createPayload()` - * `getReplayEvent()` + * `createRecordingData()` + * `prepareReplayEvent()` * `client._prepareEvent()` (see baseclient) * `baseclient._applyClientOptions()` * `baseclient._applyIntegrationsMetadata()` diff --git a/packages/replay/.eslintrc.js b/packages/replay/.eslintrc.js index 9df1f4dffa5f..cd80e893576c 100644 --- a/packages/replay/.eslintrc.js +++ b/packages/replay/.eslintrc.js @@ -25,10 +25,8 @@ module.exports = { rules: { // TODO (high-prio): Re-enable this after migration '@typescript-eslint/explicit-member-accessibility': 'off', - // TODO (high-prio): Re-enable this after migration + // Since we target only es6 here, we can leave this off '@sentry-internal/sdk/no-async-await': 'off', - // TODO (medium-prio): Re-enable this after migration - 'jsdoc/require-jsdoc': 'off', }, }, { diff --git a/packages/replay/src/coreHandlers/breadcrumbHandler.ts b/packages/replay/src/coreHandlers/breadcrumbHandler.ts index fe0504b0230f..f790e28db3c1 100644 --- a/packages/replay/src/coreHandlers/breadcrumbHandler.ts +++ b/packages/replay/src/coreHandlers/breadcrumbHandler.ts @@ -4,6 +4,9 @@ import type { InstrumentationTypeBreadcrumb } from '../types'; import { DomHandlerData, handleDom } from './handleDom'; import { handleScope } from './handleScope'; +/** + * An event handler to react to breadcrumbs. + */ export function breadcrumbHandler(type: InstrumentationTypeBreadcrumb, handlerData: unknown): Breadcrumb | null { if (type === 'scope') { return handleScope(handlerData as Scope); diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts index 412bbb6cefd9..976adf7761c7 100644 --- a/packages/replay/src/coreHandlers/handleDom.ts +++ b/packages/replay/src/coreHandlers/handleDom.ts @@ -9,6 +9,9 @@ export interface DomHandlerData { event: Node | { target: Node }; } +/** + * An event handler to react to DOM events. + */ export function handleDom(handlerData: DomHandlerData): Breadcrumb | null { // Taken from https://github.com/getsentry/sentry-javascript/blob/master/packages/browser/src/integrations/breadcrumbs.ts#L112 let target; diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 961a18b638d6..290a58d4531d 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -1,5 +1,4 @@ -import type { ReplayPerformanceEntry } from '../createPerformanceEntry'; -import type { ReplayContainer } from '../types'; +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; import { createPerformanceSpans } from '../util/createPerformanceSpans'; import { shouldFilterRequest } from '../util/shouldFilterRequest'; diff --git a/packages/replay/src/coreHandlers/handleHistory.ts b/packages/replay/src/coreHandlers/handleHistory.ts index f806d2d3c75b..4732c0118831 100644 --- a/packages/replay/src/coreHandlers/handleHistory.ts +++ b/packages/replay/src/coreHandlers/handleHistory.ts @@ -1,5 +1,4 @@ -import { ReplayPerformanceEntry } from '../createPerformanceEntry'; -import type { ReplayContainer } from '../types'; +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; import { createPerformanceSpans } from '../util/createPerformanceSpans'; interface HistoryHandlerData { diff --git a/packages/replay/src/coreHandlers/handleScope.ts b/packages/replay/src/coreHandlers/handleScope.ts index 41cc4a6d4e02..429fc9d9a3bb 100644 --- a/packages/replay/src/coreHandlers/handleScope.ts +++ b/packages/replay/src/coreHandlers/handleScope.ts @@ -4,6 +4,9 @@ import { createBreadcrumb } from '../util/createBreadcrumb'; let _LAST_BREADCRUMB: null | Breadcrumb = null; +/** + * An event handler to handle scope changes. + */ export function handleScope(scope: Scope): Breadcrumb | null { const newBreadcrumb = scope.getLastBreadcrumb(); diff --git a/packages/replay/src/coreHandlers/handleXhr.ts b/packages/replay/src/coreHandlers/handleXhr.ts index a225345afe2f..883201d825e9 100644 --- a/packages/replay/src/coreHandlers/handleXhr.ts +++ b/packages/replay/src/coreHandlers/handleXhr.ts @@ -1,5 +1,4 @@ -import { ReplayPerformanceEntry } from '../createPerformanceEntry'; -import type { ReplayContainer } from '../types'; +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; import { createPerformanceSpans } from '../util/createPerformanceSpans'; import { shouldFilterRequest } from '../util/shouldFilterRequest'; diff --git a/packages/replay/src/createPerformanceEntry.ts b/packages/replay/src/createPerformanceEntry.ts index c4fb293429dd..e502e4d96247 100644 --- a/packages/replay/src/createPerformanceEntry.ts +++ b/packages/replay/src/createPerformanceEntry.ts @@ -2,40 +2,12 @@ import { browserPerformanceTimeOrigin } from '@sentry/utils'; import { record } from 'rrweb'; import { WINDOW } from './constants'; -import type { AllPerformanceEntry, PerformanceNavigationTiming, PerformancePaintTiming } from './types'; - -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?: Record; -} - -interface MemoryInfo { - jsHeapSizeLimit: number; - totalJSHeapSize: number; - usedJSHeapSize: number; -} +import type { + AllPerformanceEntry, + PerformanceNavigationTiming, + PerformancePaintTiming, + ReplayPerformanceEntry, +} from './types'; // Map entryType -> function to normalize data for event // @ts-ignore TODO: entry type does not fit the create* functions entry type @@ -46,9 +18,12 @@ const ENTRY_TYPES: Record null | ReplayP // @ts-ignore TODO: entry type does not fit the create* functions entry type navigation: createNavigationEntry, // @ts-ignore TODO: entry type does not fit the create* functions entry type - ['largest-contentful-paint']: createLargestContentfulPaint, + 'largest-contentful-paint': createLargestContentfulPaint, }; +/** + * Create replay performance entries from the browser performance entries. + */ export function createPerformanceEntries(entries: AllPerformanceEntry[]): ReplayPerformanceEntry[] { return entries.map(createPerformanceEntry).filter(Boolean) as ReplayPerformanceEntry[]; } @@ -67,9 +42,7 @@ function getAbsoluteTime(time: number): number { return ((browserPerformanceTimeOrigin || WINDOW.performance.timeOrigin) + time) / 1000; } -// TODO: type definition! -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createPaintEntry(entry: PerformancePaintTiming) { +function createPaintEntry(entry: PerformancePaintTiming): ReplayPerformanceEntry { const { duration, entryType, name, startTime } = entry; const start = getAbsoluteTime(startTime); @@ -81,9 +54,7 @@ function createPaintEntry(entry: PerformancePaintTiming) { }; } -// TODO: type definition! -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createNavigationEntry(entry: PerformanceNavigationTiming) { +function createNavigationEntry(entry: PerformanceNavigationTiming): ReplayPerformanceEntry | null { // TODO: There looks to be some more interesting bits in here (domComplete, domContentLoaded) const { entryType, name, duration, domComplete, startTime, transferSize, type } = entry; @@ -104,9 +75,7 @@ function createNavigationEntry(entry: PerformanceNavigationTiming) { }; } -// TODO: type definition! -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createResourceEntry(entry: PerformanceResourceTiming) { +function createResourceEntry(entry: PerformanceResourceTiming): ReplayPerformanceEntry | null { const { entryType, initiatorType, name, responseEnd, startTime, encodedBodySize, transferSize } = entry; // Core SDK handles these @@ -126,9 +95,9 @@ function createResourceEntry(entry: PerformanceResourceTiming) { }; } -// TODO: type definition! -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createLargestContentfulPaint(entry: PerformanceEntry & { size: number; element: Node }) { +function createLargestContentfulPaint( + entry: PerformanceEntry & { size: number; element: Node }, +): ReplayPerformanceEntry { const { duration, entryType, startTime, size } = entry; const start = getAbsoluteTime(startTime); @@ -147,25 +116,3 @@ function createLargestContentfulPaint(entry: PerformanceEntry & { size: number; }, }; } - -type ReplayMemoryEntry = ReplayPerformanceEntry & { data: { memory: MemoryInfo } }; - -export function createMemoryEntry(memoryEntry: MemoryInfo): ReplayMemoryEntry { - const { jsHeapSizeLimit, totalJSHeapSize, usedJSHeapSize } = memoryEntry; - // we don't want to use `getAbsoluteTime` because it adds the event time to the - // time origin, so we get the current timestamp instead - const time = new Date().getTime() / 1000; - return { - type: 'memory', - name: 'memory', - start: time, - end: time, - data: { - memory: { - jsHeapSizeLimit, - totalJSHeapSize, - usedJSHeapSize, - }, - }, - }; -} diff --git a/packages/replay/src/eventBuffer.ts b/packages/replay/src/eventBuffer.ts index f5fbfb2497ff..a755ab0676e8 100644 --- a/packages/replay/src/eventBuffer.ts +++ b/packages/replay/src/eventBuffer.ts @@ -12,6 +12,9 @@ interface CreateEventBufferParams { useCompression: boolean; } +/** + * Create an event buffer for replays. + */ export function createEventBuffer({ useCompression }: CreateEventBufferParams): EventBuffer { // eslint-disable-next-line no-restricted-globals if (useCompression && window.Worker) { @@ -72,7 +75,10 @@ class EventBufferArray implements EventBuffer { } } -// exporting for testing +/** + * Event buffer that uses a web worker to compress events. + * Exported only for testing. + */ export class EventBufferCompressionWorker implements EventBuffer { private _worker: null | Worker; private _eventBufferItemLength: number = 0; @@ -90,12 +96,18 @@ export class EventBufferCompressionWorker implements EventBuffer { return this._eventBufferItemLength; } + /** + * Destroy the event buffer. + */ public destroy(): void { __DEBUG_BUILD__ && logger.log('[Replay] Destroying compression worker'); this._worker?.terminate(); this._worker = null; } + /** + * Add an event to the event buffer. + */ public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise { if (isCheckout) { // This event is a checkout, make sure worker buffer is cleared before @@ -110,6 +122,9 @@ export class EventBufferCompressionWorker implements EventBuffer { return this._sendEventToWorker(event); } + /** + * Finish the event buffer and return the compressed data. + */ public finish(): Promise { return this._finishRequest(this._getAndIncrementId()); } @@ -160,6 +175,9 @@ export class EventBufferCompressionWorker implements EventBuffer { }); } + /** + * Send the event to the worker. + */ private _sendEventToWorker(event: RecordingEvent): Promise { const promise = this._postMessage({ id: this._getAndIncrementId(), @@ -173,6 +191,9 @@ export class EventBufferCompressionWorker implements EventBuffer { return promise; } + /** + * Finish the request and return the compressed data from the worker. + */ private async _finishRequest(id: number): Promise { const promise = this._postMessage({ id, method: 'finish', args: [] }); @@ -182,6 +203,7 @@ export class EventBufferCompressionWorker implements EventBuffer { return promise as Promise; } + /** Get the current ID and increment it for the next call. */ private _getAndIncrementId(): number { return this._id++; } diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index d5fe1275f7bd..865472c1571c 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -18,6 +18,9 @@ const MEDIA_SELECTORS = 'img,image,svg,path,rect,area,video,object,picture,embed let _initialized = false; +/** + * The main replay integration class, to be passed to `init({ integrations: [] })`. + */ export class Replay implements Integration { /** * @inheritDoc @@ -126,10 +129,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, this._isInitialized = true; } + /** If replay has already been initialized */ protected get _isInitialized(): boolean { return _initialized; } + /** Update _isInitialized */ protected set _isInitialized(value: boolean) { _initialized = value; } @@ -181,6 +186,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, this._replay.stop(); } + /** Setup the integration. */ private _setup(): void { // Client is not available in constructor, so we need to wait until setupOnce this._loadReplayOptionsFromClient(); diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 84465de40de5..a54a5453e689 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -40,14 +40,14 @@ import type { import { addEvent } from './util/addEvent'; import { addMemoryEntry } from './util/addMemoryEntry'; import { createBreadcrumb } from './util/createBreadcrumb'; -import { createPayload } from './util/createPayload'; import { createPerformanceSpans } from './util/createPerformanceSpans'; +import { createRecordingData } from './util/createRecordingData'; import { createReplayEnvelope } from './util/createReplayEnvelope'; import { debounce } from './util/debounce'; -import { getReplayEvent } from './util/getReplayEvent'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent'; +import { prepareReplayEvent } from './util/prepareReplayEvent'; /** * Returns true to return control to calling function, otherwise continue with normal batching @@ -56,6 +56,9 @@ import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/m const BASE_RETRY_INTERVAL = 5000; const MAX_RETRY_COUNT = 3; +/** + * The main replay container class, which holds all the state and methods for recording and sending replays. + */ export class ReplayContainer implements ReplayContainerInterface { public eventBuffer: EventBuffer | null = null; @@ -904,7 +907,7 @@ export class ReplayContainer implements ReplayContainerInterface { includeReplayStartTimestamp, eventContext, }: SendReplay): Promise { - const payloadWithSequence = createPayload({ + const recordingData = createRecordingData({ events, headers: { segment_id, @@ -938,7 +941,7 @@ export class ReplayContainer implements ReplayContainerInterface { replay_type: this.session.sampled, }; - const replayEvent = await getReplayEvent({ scope, client, replayId, event: baseEvent }); + const replayEvent = await prepareReplayEvent({ scope, client, replayId, event: baseEvent }); if (!replayEvent) { // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions @@ -989,7 +992,7 @@ export class ReplayContainer implements ReplayContainerInterface { } */ - const envelope = createReplayEnvelope(replayEvent, payloadWithSequence, dsn, client.getOptions().tunnel); + const envelope = createReplayEnvelope(replayEvent, recordingData, dsn, client.getOptions().tunnel); try { return await transport.send(envelope); @@ -998,6 +1001,9 @@ export class ReplayContainer implements ReplayContainerInterface { } } + /** + * Reset the counter of retries for sending replays. + */ resetRetries(): void { this._retryCount = 0; this._retryInterval = BASE_RETRY_INTERVAL; diff --git a/packages/replay/src/session/saveSession.ts b/packages/replay/src/session/saveSession.ts index a506625436f8..8f75d0ab50ed 100644 --- a/packages/replay/src/session/saveSession.ts +++ b/packages/replay/src/session/saveSession.ts @@ -1,6 +1,9 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import type { Session } from '../types'; +/** + * Save a session to session storage. + */ export function saveSession(session: Session): void { const hasSessionStorage = 'sessionStorage' in WINDOW; if (!hasSessionStorage) { diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index df35aefd2c60..029bd109c31f 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -235,3 +235,30 @@ export interface ReplayContainer { addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; } + +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?: Record; +} diff --git a/packages/replay/src/util/addMemoryEntry.ts b/packages/replay/src/util/addMemoryEntry.ts index bac07200cd6c..0b847a25a42d 100644 --- a/packages/replay/src/util/addMemoryEntry.ts +++ b/packages/replay/src/util/addMemoryEntry.ts @@ -1,7 +1,15 @@ import { WINDOW } from '../constants'; -import type { ReplayContainer } from '../types'; +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; import { createPerformanceSpans } from './createPerformanceSpans'; +type ReplayMemoryEntry = ReplayPerformanceEntry & { data: { memory: MemoryInfo } }; + +interface MemoryInfo { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; +} + /** * Create a "span" for the total amount of memory being used by JS objects * (including v8 internal objects). @@ -17,3 +25,23 @@ export function addMemoryEntry(replay: ReplayContainer): void { // Do nothing } } + +function createMemoryEntry(memoryEntry: MemoryInfo): ReplayMemoryEntry { + const { jsHeapSizeLimit, totalJSHeapSize, usedJSHeapSize } = memoryEntry; + // we don't want to use `getAbsoluteTime` because it adds the event time to the + // time origin, so we get the current timestamp instead + const time = new Date().getTime() / 1000; + return { + type: 'memory', + name: 'memory', + start: time, + end: time, + data: { + memory: { + jsHeapSizeLimit, + totalJSHeapSize, + usedJSHeapSize, + }, + }, + }; +} diff --git a/packages/replay/src/util/createBreadcrumb.ts b/packages/replay/src/util/createBreadcrumb.ts index bb1d2eb49ec1..b9f7527b0180 100644 --- a/packages/replay/src/util/createBreadcrumb.ts +++ b/packages/replay/src/util/createBreadcrumb.ts @@ -2,6 +2,9 @@ import type { Breadcrumb } from '@sentry/types'; type RequiredProperties = 'category' | 'message'; +/** + * Create a breadcrumb for a replay. + */ export function createBreadcrumb( breadcrumb: Pick & Partial>, ): Breadcrumb { diff --git a/packages/replay/src/util/createPerformanceSpans.ts b/packages/replay/src/util/createPerformanceSpans.ts index 9bb999a0faa3..f7327ff488b0 100644 --- a/packages/replay/src/util/createPerformanceSpans.ts +++ b/packages/replay/src/util/createPerformanceSpans.ts @@ -1,7 +1,6 @@ import { EventType } from 'rrweb'; -import { ReplayPerformanceEntry } from '../createPerformanceEntry'; -import type { ReplayContainer } from '../types'; +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; import { addEvent } from './addEvent'; /** diff --git a/packages/replay/src/util/createPayload.ts b/packages/replay/src/util/createRecordingData.ts similarity index 90% rename from packages/replay/src/util/createPayload.ts rename to packages/replay/src/util/createRecordingData.ts index b3b6615b1b40..63c1db5f6e7b 100644 --- a/packages/replay/src/util/createPayload.ts +++ b/packages/replay/src/util/createRecordingData.ts @@ -2,7 +2,10 @@ import { ReplayRecordingData } from '@sentry/types'; import type { RecordedEvents } from '../types'; -export function createPayload({ +/** + * Create the recording data ready to be sent. + */ +export function createRecordingData({ events, headers, }: { diff --git a/packages/replay/src/util/createReplayEnvelope.ts b/packages/replay/src/util/createReplayEnvelope.ts index 3d950e06cb29..3a052f32fdd6 100644 --- a/packages/replay/src/util/createReplayEnvelope.ts +++ b/packages/replay/src/util/createReplayEnvelope.ts @@ -1,6 +1,10 @@ import { DsnComponents, ReplayEnvelope, ReplayEvent, ReplayRecordingData } from '@sentry/types'; import { createEnvelope, createEventEnvelopeHeaders, getSdkMetadataForEnvelopeHeader } from '@sentry/utils'; +/** + * Create a replay envelope ready to be sent. + * This includes both the replay event, as well as the recording data. + */ export function createReplayEnvelope( replayEvent: ReplayEvent, recordingData: ReplayRecordingData, diff --git a/packages/replay/src/util/isBrowser.ts b/packages/replay/src/util/isBrowser.ts index 3ad78dce93a5..6a64317ba3fa 100644 --- a/packages/replay/src/util/isBrowser.ts +++ b/packages/replay/src/util/isBrowser.ts @@ -1,5 +1,8 @@ import { isNodeEnv } from '@sentry/utils'; +/** + * Returns true if we are in the browser. + */ export function isBrowser(): boolean { // eslint-disable-next-line no-restricted-globals return typeof window !== 'undefined' && (!isNodeEnv() || isElectronNodeRenderer()); diff --git a/packages/replay/src/util/isRrwebError.ts b/packages/replay/src/util/isRrwebError.ts index d9b065857062..1607f55255ef 100644 --- a/packages/replay/src/util/isRrwebError.ts +++ b/packages/replay/src/util/isRrwebError.ts @@ -1,5 +1,8 @@ import { Event } from '@sentry/types'; +/** + * Returns true if we think the given event is an error originating inside of rrweb. + */ export function isRrwebError(event: Event): boolean { if (event.type || !event.exception?.values?.length) { return false; diff --git a/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts b/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts index 76825f727e6b..70cf11faf450 100644 --- a/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts +++ b/packages/replay/src/util/monkeyPatchRecordDroppedEvent.ts @@ -3,6 +3,9 @@ import { Client, DataCategory, Event, EventDropReason } from '@sentry/types'; let _originalRecordDroppedEvent: Client['recordDroppedEvent'] | undefined; +/** + * Overwrite the `recordDroppedEvent` method on the client, so we can find out which events were dropped. + * */ export function overwriteRecordDroppedEvent(errorIds: Set): void { const client = getCurrentHub().getClient(); @@ -28,6 +31,9 @@ export function overwriteRecordDroppedEvent(errorIds: Set): void { _originalRecordDroppedEvent = _originalCallback; } +/** + * Restore the original method. + * */ export function restoreRecordDroppedEvent(): void { const client = getCurrentHub().getClient(); diff --git a/packages/replay/src/util/getReplayEvent.ts b/packages/replay/src/util/prepareReplayEvent.ts similarity index 90% rename from packages/replay/src/util/getReplayEvent.ts rename to packages/replay/src/util/prepareReplayEvent.ts index 5a0cfd0ac8ff..a7e2fd6da995 100644 --- a/packages/replay/src/util/getReplayEvent.ts +++ b/packages/replay/src/util/prepareReplayEvent.ts @@ -1,7 +1,10 @@ import { prepareEvent, Scope } from '@sentry/core'; import { Client, ReplayEvent } from '@sentry/types'; -export async function getReplayEvent({ +/** + * Prepare a replay event & enrich it with the SDK metadata. + */ +export async function prepareReplayEvent({ client, scope, replayId: event_id, diff --git a/packages/replay/test/unit/util/getReplayEvent.test.ts b/packages/replay/test/unit/util/prepareReplayEvent.test.ts similarity index 88% rename from packages/replay/test/unit/util/getReplayEvent.test.ts rename to packages/replay/test/unit/util/prepareReplayEvent.test.ts index 1949b722c545..4c7785bf3735 100644 --- a/packages/replay/test/unit/util/getReplayEvent.test.ts +++ b/packages/replay/test/unit/util/prepareReplayEvent.test.ts @@ -3,10 +3,10 @@ import { getCurrentHub, Hub, Scope } from '@sentry/core'; import { Client, ReplayEvent } from '@sentry/types'; import { REPLAY_EVENT_NAME } from '../../../src/constants'; -import { getReplayEvent } from '../../../src/util/getReplayEvent'; +import { prepareReplayEvent } from '../../../src/util/prepareReplayEvent'; import { getDefaultBrowserClientOptions } from '../../utils/getDefaultBrowserClientOptions'; -describe('getReplayEvent', () => { +describe('prepareReplayEvent', () => { let hub: Hub; let client: Client; let scope: Scope; @@ -33,12 +33,11 @@ describe('getReplayEvent', () => { trace_ids: ['trace-ID'], urls: ['https://sentry.io/'], replay_id: replayId, - event_id: replayId, replay_type: 'session', segment_id: 3, }; - const replayEvent = await getReplayEvent({ scope, client, replayId, event }); + const replayEvent = await prepareReplayEvent({ scope, client, replayId, event }); expect(replayEvent).toEqual({ type: 'replay_event', From 7316b850988df64467a7052082db84461f8a2cd2 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 9 Jan 2023 11:57:37 +0100 Subject: [PATCH 4/4] fix(nextjs): Exclude SDK from Edge runtime bundles (#6683) --- .../nextjs/src/config/loaders/proxyLoader.ts | 7 ++++ .../templates/apiProxyLoaderTemplate.ts | 18 ++--------- packages/nextjs/src/config/types.ts | 2 +- packages/nextjs/src/config/webpack.ts | 32 +++++++++++++++++-- packages/nextjs/src/index.client.ts | 15 --------- packages/nextjs/src/index.server.ts | 11 ------- 6 files changed, 40 insertions(+), 45 deletions(-) diff --git a/packages/nextjs/src/config/loaders/proxyLoader.ts b/packages/nextjs/src/config/loaders/proxyLoader.ts index c6bee0ec9f3a..42aa1a7a230f 100644 --- a/packages/nextjs/src/config/loaders/proxyLoader.ts +++ b/packages/nextjs/src/config/loaders/proxyLoader.ts @@ -9,6 +9,7 @@ type LoaderOptions = { pagesDir: string; pageExtensionRegex: string; excludeServerRoutes: Array; + isEdgeRuntime: boolean; }; /** @@ -22,8 +23,14 @@ export default async function proxyLoader(this: LoaderThis, userC pagesDir, pageExtensionRegex, excludeServerRoutes = [], + isEdgeRuntime, } = 'getOptions' in this ? this.getOptions() : this.query; + // We currently don't support the edge runtime + if (isEdgeRuntime) { + return userCode; + } + // Get the parameterized route name from this page's filepath const parameterizedRoute = path // Get the path of the file insde of the pages directory diff --git a/packages/nextjs/src/config/templates/apiProxyLoaderTemplate.ts b/packages/nextjs/src/config/templates/apiProxyLoaderTemplate.ts index 230eb55e457f..1cd7c40181eb 100644 --- a/packages/nextjs/src/config/templates/apiProxyLoaderTemplate.ts +++ b/packages/nextjs/src/config/templates/apiProxyLoaderTemplate.ts @@ -23,7 +23,7 @@ type NextApiModule = ( } // CJS export | NextApiHandler -) & { config?: PageConfig & { runtime?: string } }; +) & { config?: PageConfig }; const userApiModule = origModule as NextApiModule; @@ -53,21 +53,7 @@ export const config = { }, }; -// This is a variable that Next.js will string replace during build with a string if run in an edge runtime from Next.js -// v12.2.1-canary.3 onwards: -// https://github.com/vercel/next.js/blob/166e5fb9b92f64c4b5d1f6560a05e2b9778c16fb/packages/next/build/webpack-config.ts#L206 -// https://edge-runtime.vercel.sh/features/available-apis#addressing-the-runtime -declare const EdgeRuntime: string | undefined; - -let exportedHandler; - -if (typeof EdgeRuntime === 'string') { - exportedHandler = userProvidedHandler; -} else { - exportedHandler = userProvidedHandler ? Sentry.withSentryAPI(userProvidedHandler, '__ROUTE__') : undefined; -} - -export default exportedHandler; +export default userProvidedHandler ? Sentry.withSentryAPI(userProvidedHandler, '__ROUTE__') : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to // not include anything whose name matchs something we've explicitly exported above. diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f596252fd472..6a815664e527 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -137,7 +137,7 @@ export type BuildContext = { // eslint-disable-next-line @typescript-eslint/no-explicit-any defaultLoaders: any; totalPages: number; - nextRuntime?: 'nodejs' | 'edge'; + nextRuntime?: 'nodejs' | 'edge'; // Added in Next.js 12+ }; /** diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 988c6cc8b4b3..15cf51e2b742 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -85,6 +85,13 @@ export function constructWebpackConfigFunction( // Add a loader which will inject code that sets global values addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions); + if (buildContext.nextRuntime === 'edge') { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You are using edge functions or middleware. Please note that Sentry does not yet support error monitoring for these features.', + ); + } + if (isServer) { if (userSentryOptions.autoInstrumentServerFunctions !== false) { const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string; @@ -102,6 +109,7 @@ export function constructWebpackConfigFunction( pagesDir, pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, + isEdgeRuntime: buildContext.nextRuntime === 'edge', }, }, ], @@ -305,7 +313,15 @@ async function addSentryToEntryProperty( // inject into all entry points which might contain user's code for (const entryPointName in newEntryProperty) { - if (shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev)) { + if ( + shouldAddSentryToEntryPoint( + entryPointName, + isServer, + userSentryOptions.excludeServerRoutes, + isDev, + buildContext.nextRuntime === 'edge', + ) + ) { addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject); } else { if ( @@ -432,7 +448,13 @@ function shouldAddSentryToEntryPoint( isServer: boolean, excludeServerRoutes: Array = [], isDev: boolean, + isEdgeRuntime: boolean, ): boolean { + // We don't support the Edge runtime yet + if (isEdgeRuntime) { + return false; + } + // On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions). if (isServer) { const entryPointRoute = entryPointName.replace(/^pages/, ''); @@ -529,7 +551,13 @@ export function getWebpackPluginOptions( stripPrefix: ['webpack://_N_E/'], urlPrefix, entries: (entryPointName: string) => - shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev), + shouldAddSentryToEntryPoint( + entryPointName, + isServer, + userSentryOptions.excludeServerRoutes, + isDev, + buildContext.nextRuntime === 'edge', + ), release: getSentryRelease(buildId), dryRun: isDev, }); diff --git a/packages/nextjs/src/index.client.ts b/packages/nextjs/src/index.client.ts index 2d9e29d48ac8..98ea9928b8ed 100644 --- a/packages/nextjs/src/index.client.ts +++ b/packages/nextjs/src/index.client.ts @@ -2,7 +2,6 @@ import { RewriteFrames } from '@sentry/integrations'; import { configureScope, init as reactInit, Integrations } from '@sentry/react'; import { BrowserTracing, defaultRequestInstrumentationOptions, hasTracingEnabled } from '@sentry/tracing'; import { EventProcessor } from '@sentry/types'; -import { logger } from '@sentry/utils'; import { nextRouterInstrumentation } from './performance/client'; import { buildMetadata } from './utils/metadata'; @@ -31,26 +30,12 @@ export { BrowserTracing }; // Treeshakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; -// This is a variable that Next.js will string replace during build with a string if run in an edge runtime from Next.js -// v12.2.1-canary.3 onwards: -// https://github.com/vercel/next.js/blob/166e5fb9b92f64c4b5d1f6560a05e2b9778c16fb/packages/next/build/webpack-config.ts#L206 -// https://edge-runtime.vercel.sh/features/available-apis#addressing-the-runtime -declare const EdgeRuntime: string | undefined; - const globalWithInjectedValues = global as typeof global & { __rewriteFramesAssetPrefixPath__: string; }; /** Inits the Sentry NextJS SDK on the browser with the React SDK. */ export function init(options: NextjsOptions): void { - if (typeof EdgeRuntime === 'string') { - // If the SDK is imported when using the Vercel Edge Runtime, it will import the browser SDK, even though it is - // running the server part of a Next.js application. We can use the `EdgeRuntime` to check for that case and make - // the init call a no-op. This will prevent the SDK from crashing on the Edge Runtime. - __DEBUG_BUILD__ && logger.log('Vercel Edge Runtime detected. Will not initialize SDK.'); - return; - } - applyTunnelRouteOption(options); buildMetadata(options, ['nextjs', 'react']); options.environment = options.environment || process.env.NODE_ENV; diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index 922be7aa09b6..0500bd802300 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -30,12 +30,6 @@ const globalWithInjectedValues = global as typeof global & { const domain = domainModule as typeof domainModule & { active: (domainModule.Domain & Carrier) | null }; -// This is a variable that Next.js will string replace during build with a string if run in an edge runtime from Next.js -// v12.2.1-canary.3 onwards: -// https://github.com/vercel/next.js/blob/166e5fb9b92f64c4b5d1f6560a05e2b9778c16fb/packages/next/build/webpack-config.ts#L206 -// https://edge-runtime.vercel.sh/features/available-apis#addressing-the-runtime -declare const EdgeRuntime: string | undefined; - // Exporting this constant means we can compute it without the linter complaining, even if we stop directly using it in // this file. It's important that it be computed as early as possible, because one of its indicators is seeing 'build' // (as in the CLI command `next build`) in `process.argv`. Later on in the build process, everything's been spun out @@ -51,11 +45,6 @@ export function init(options: NextjsOptions): void { logger.enable(); } - if (typeof EdgeRuntime === 'string') { - __DEBUG_BUILD__ && logger.log('Vercel Edge Runtime detected. Will not initialize SDK.'); - return; - } - __DEBUG_BUILD__ && logger.log('Initializing SDK...'); if (sdkAlreadyInitialized()) {