From d780165434213565e047f478e92028c41fcc69ea Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 21 Nov 2023 14:53:08 +0100 Subject: [PATCH 01/21] feat(core): Add optional `setup` hook to integrations (#9556) This can be used to replace some of our `setupOnce` hooks, and does not rely on global state. --- packages/core/src/integration.ts | 6 ++++ packages/core/test/lib/integration.test.ts | 38 ++++++++++++++++++++++ packages/types/src/integration.ts | 10 ++++++ 3 files changed, 54 insertions(+) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index b4d32ea38e87..25e8d41225bd 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -101,11 +101,17 @@ export function setupIntegrations(client: Client, integrations: Integration[]): export function setupIntegration(client: Client, integration: Integration, integrationIndex: IntegrationIndex): void { integrationIndex[integration.name] = integration; + // `setupOnce` is only called the first time if (installedIntegrations.indexOf(integration.name) === -1) { integration.setupOnce(addGlobalEventProcessor, getCurrentHub); installedIntegrations.push(integration.name); } + // `setup` is run for each client + if (integration.setup && typeof integration.setup === 'function') { + integration.setup(client); + } + if (client.on && typeof integration.preprocessEvent === 'function') { const callback = integration.preprocessEvent.bind(integration) as typeof integration.preprocessEvent; client.on('preprocessEvent', (event, hint) => callback(event, hint, client)); diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 7ffcdb572994..37e604527f29 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -378,6 +378,44 @@ describe('setupIntegration', () => { expect(integration4.setupOnce).not.toHaveBeenCalled(); }); + it('calls setup for each client', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + setup = jest.fn(); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + + expect(integration1.setup).toHaveBeenCalledTimes(1); + expect(integration2.setup).toHaveBeenCalledTimes(1); + expect(integration3.setup).toHaveBeenCalledTimes(1); + expect(integration4.setup).toHaveBeenCalledTimes(1); + + expect(integration1.setup).toHaveBeenCalledWith(client1); + expect(integration2.setup).toHaveBeenCalledWith(client1); + expect(integration3.setup).toHaveBeenCalledWith(client2); + expect(integration4.setup).toHaveBeenCalledWith(client2); + }); + it('binds preprocessEvent for each client', () => { class CustomIntegration implements Integration { name = 'test'; diff --git a/packages/types/src/integration.ts b/packages/types/src/integration.ts index 0e60b7a530ee..0c18845414e3 100644 --- a/packages/types/src/integration.ts +++ b/packages/types/src/integration.ts @@ -26,6 +26,16 @@ export interface Integration { */ setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void; + /** + * Set up an integration for the given client. + * Receives the client as argument. + * + * Whenever possible, prefer this over `setupOnce`, as that is only run for the first client, + * whereas `setup` runs for each client. Only truly global things (e.g. registering global handlers) + * should be done in `setupOnce`. + */ + setup?(client: Client): void; + /** * An optional hook that allows to preprocess an event _before_ it is passed to all other event processors. */ From 83eaf9eb09a398c701875d39e9c84e531ff9e735 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Nov 2023 12:04:30 -0500 Subject: [PATCH 02/21] ref(node): Use Sentry.continueTrace in node (#9607) --- packages/node/src/handlers.ts | 44 ++++++++++++++++------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index ab6dd6325ce6..9ba531dff428 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { captureException, + continueTrace, flush, getCurrentHub, hasTracingEnabled, @@ -19,7 +20,6 @@ import { isThenable, logger, normalize, - tracingContextFromHeaders, } from '@sentry/utils'; import type * as http from 'http'; @@ -57,35 +57,31 @@ export function tracingHandler(): ( const sentryTrace = req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined; const baggage = req.headers?.baggage; - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - sentryTrace, - baggage, - ); - hub.getScope().setPropagationContext(propagationContext); - if (!hasTracingEnabled(options)) { return next(); } const [name, source] = extractPathForTransaction(req, { path: true, method: true }); - const transaction = startTransaction( - { - name, - op: 'http.server', - origin: 'auto.http.node.tracingHandler', - ...traceparentData, - metadata: { - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - // The request should already have been stored in `scope.sdkProcessingMetadata` (which will become - // `event.sdkProcessingMetadata` the same way the metadata here will) by `sentryRequestMiddleware`, but on the - // off chance someone is using `sentryTracingMiddleware` without `sentryRequestMiddleware`, it doesn't hurt to - // be sure - request: req, - source, + const transaction = continueTrace({ sentryTrace, baggage }, ctx => + startTransaction( + { + name, + op: 'http.server', + origin: 'auto.http.node.tracingHandler', + ...ctx, + metadata: { + ...ctx.metadata, + // The request should already have been stored in `scope.sdkProcessingMetadata` (which will become + // `event.sdkProcessingMetadata` the same way the metadata here will) by `sentryRequestMiddleware`, but on the + // off chance someone is using `sentryTracingMiddleware` without `sentryRequestMiddleware`, it doesn't hurt to + // be sure + request: req, + source, + }, }, - }, - // extra context passed to the tracesSampler - { request: extractRequestData(req) }, + // extra context passed to the tracesSampler + { request: extractRequestData(req) }, + ), ); // We put the transaction on the scope so users can attach children to it From 51576c2aa8a054dafa813b9af04a37be9cf561a1 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Nov 2023 12:04:51 -0500 Subject: [PATCH 03/21] ref(bun): Use Sentry.continueTrace in Bun (#9606) --- packages/bun/src/integrations/bunserver.ts | 90 +++++++++++----------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 915856f473db..d20953628544 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,6 +1,6 @@ -import { captureException, getCurrentHub, runWithAsyncContext, startSpan, Transaction } from '@sentry/core'; +import { captureException, continueTrace, runWithAsyncContext, startSpan, Transaction } from '@sentry/core'; import type { Integration } from '@sentry/types'; -import { addExceptionMechanism, getSanitizedUrlString, parseUrl, tracingContextFromHeaders } from '@sentry/utils'; +import { addExceptionMechanism, getSanitizedUrlString, parseUrl } from '@sentry/utils'; function sendErrorToSentry(e: unknown): unknown { captureException(e, scope => { @@ -62,22 +62,12 @@ function instrumentBunServeOptions(serveOptions: Parameters[0] serveOptions.fetch = new Proxy(serveOptions.fetch, { apply(fetchTarget, fetchThisArg, fetchArgs: Parameters) { return runWithAsyncContext(() => { - const hub = getCurrentHub(); - const request = fetchArgs[0]; const upperCaseMethod = request.method.toUpperCase(); if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { return fetchTarget.apply(fetchThisArg, fetchArgs); } - const sentryTrace = request.headers.get('sentry-trace') || ''; - const baggage = request.headers.get('baggage'); - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - sentryTrace, - baggage, - ); - hub.getScope().setPropagationContext(propagationContext); - const parsedUrl = parseUrl(request.url); const data: Record = { 'http.request.method': request.method || 'GET', @@ -87,43 +77,49 @@ function instrumentBunServeOptions(serveOptions: Parameters[0] } const url = getSanitizedUrlString(parsedUrl); - return startSpan( - { - op: 'http.server', - name: `${request.method} ${parsedUrl.path || '/'}`, - origin: 'auto.http.bun.serve', - ...traceparentData, - data, - metadata: { - source: 'url', - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - request: { - url, - method: request.method, - headers: request.headers.toJSON(), + + return continueTrace( + { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, + ctx => { + return startSpan( + { + op: 'http.server', + name: `${request.method} ${parsedUrl.path || '/'}`, + origin: 'auto.http.bun.serve', + ...ctx, + data, + metadata: { + ...ctx.metadata, + source: 'url', + request: { + url, + method: request.method, + headers: request.headers.toJSON(), + }, + }, }, - }, - }, - async span => { - try { - const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< - typeof serveOptions.fetch - >); - if (response && response.status) { - span?.setHttpStatus(response.status); - span?.setData('http.response.status_code', response.status); - if (span instanceof Transaction) { - span.setContext('response', { - headers: response.headers.toJSON(), - status_code: response.status, - }); + async span => { + try { + const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< + typeof serveOptions.fetch + >); + if (response && response.status) { + span?.setHttpStatus(response.status); + span?.setData('http.response.status_code', response.status); + if (span instanceof Transaction) { + span.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + } + return response; + } catch (e) { + sendErrorToSentry(e); + throw e; } - } - return response; - } catch (e) { - sendErrorToSentry(e); - throw e; - } + }, + ); }, ); }); From 387ce48c0d2ac8f5f665be6292811e792e0328d1 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:04:49 -0500 Subject: [PATCH 04/21] ref(feedback): Rename onDialog* to onForm*, remove onActorClick (#9625) Rename the onDialogOpen and onDialogClose callbacks to onFormOpen and onFormClose to match our defined terminology and remove onActorClick as it's not necessary. Closes https://github.com/getsentry/sentry-javascript/issues/9605 --- packages/feedback/README.md | 5 ++-- packages/feedback/src/integration.ts | 10 +++---- packages/feedback/src/types/index.ts | 13 +++------ packages/feedback/src/widget/createWidget.ts | 20 +++++-------- packages/feedback/test/integration.test.ts | 6 ++-- .../feedback/test/widget/createWidget.test.ts | 29 +++++++++---------- 6 files changed, 35 insertions(+), 48 deletions(-) diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 3772b65f95d6..7aa8df72cd80 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -180,9 +180,8 @@ Pass these callbacks when you initialize the Feedback integration: ```javascript new Feedback({ - onActorClick: () => {}, - onDialogOpen: () => {}, - onDialogClose: () => {}, + onFormOpen: () => {}, + onFormClose: () => {}, onSubmitSuccess: () => {}, onSubmitError: () => {}, }); diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts index cb27042c20fc..13f155d1ced5 100644 --- a/packages/feedback/src/integration.ts +++ b/packages/feedback/src/integration.ts @@ -99,9 +99,8 @@ export class Feedback implements Integration { nameLabel = NAME_LABEL, successMessageText = SUCCESS_MESSAGE_TEXT, - onActorClick, - onDialogClose, - onDialogOpen, + onFormClose, + onFormOpen, onSubmitError, onSubmitSuccess, }: OptionalFeedbackConfiguration = {}) { @@ -147,9 +146,8 @@ export class Feedback implements Integration { namePlaceholder, successMessageText, - onActorClick, - onDialogClose, - onDialogOpen, + onFormClose, + onFormOpen, onSubmitError, onSubmitSuccess, }; diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 89965f017cdd..a86fd4ee4107 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -156,19 +156,14 @@ export interface FeedbackTextConfiguration { */ export interface FeedbackCallbacks { /** - * Callback when dialog is closed + * Callback when form is closed */ - onDialogClose?: () => void; + onFormClose?: () => void; /** - * Callback when dialog is opened + * Callback when form is opened */ - onDialogOpen?: () => void; - - /** - * Callback when widget actor is clicked - */ - onActorClick?: () => void; + onFormOpen?: () => void; /** * Callback when feedback is successfully submitted diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts index 1a1077b0c257..35f9fcf51f71 100644 --- a/packages/feedback/src/widget/createWidget.ts +++ b/packages/feedback/src/widget/createWidget.ts @@ -153,8 +153,8 @@ export function createWidget({ if (dialog) { dialog.open(); isDialogOpen = true; - if (options.onDialogOpen) { - options.onDialogOpen(); + if (options.onFormOpen) { + options.onFormOpen(); } return; } @@ -185,8 +185,8 @@ export function createWidget({ showActor(); isDialogOpen = false; - if (options.onDialogClose) { - options.onDialogClose(); + if (options.onFormClose) { + options.onFormClose(); } }, onCancel: () => { @@ -205,8 +205,8 @@ export function createWidget({ // Hides the default actor whenever dialog is opened hideActor(); - if (options.onDialogOpen) { - options.onDialogOpen(); + if (options.onFormOpen) { + options.onFormOpen(); } } catch (err) { // TODO: Error handling? @@ -222,8 +222,8 @@ export function createWidget({ dialog.close(); isDialogOpen = false; - if (options.onDialogClose) { - options.onDialogClose(); + if (options.onFormClose) { + options.onFormClose(); } } } @@ -251,10 +251,6 @@ export function createWidget({ // Hide actor button hideActor(); - - if (options.onActorClick) { - options.onActorClick(); - } } if (attachTo) { diff --git a/packages/feedback/test/integration.test.ts b/packages/feedback/test/integration.test.ts index 6857e6ce7897..fd5ef67bed1f 100644 --- a/packages/feedback/test/integration.test.ts +++ b/packages/feedback/test/integration.test.ts @@ -70,7 +70,7 @@ describe('Feedback integration', () => { }); it('attaches to a custom actor element', () => { - const onDialogOpen = jest.fn(); + const onFormOpen = jest.fn(); // This element is in the normal DOM const myActor = document.createElement('div'); myActor.textContent = 'my button'; @@ -79,7 +79,7 @@ describe('Feedback integration', () => { let widget = feedback.getWidget(); expect(widget).toBe(null); - feedback.attachTo(myActor, { onDialogOpen }); + feedback.attachTo(myActor, { onFormOpen }); myActor.dispatchEvent(new Event('click')); @@ -87,7 +87,7 @@ describe('Feedback integration', () => { expect(widget?.dialog?.el).toBeInstanceOf(HTMLDialogElement); expect(widget?.dialog?.el?.open).toBe(true); - expect(onDialogOpen).toHaveBeenCalledTimes(1); + expect(onFormOpen).toHaveBeenCalledTimes(1); // This is all we do with `attachTo` (open dialog) }); diff --git a/packages/feedback/test/widget/createWidget.test.ts b/packages/feedback/test/widget/createWidget.test.ts index 1776e14f5e21..c2a5f50daebc 100644 --- a/packages/feedback/test/widget/createWidget.test.ts +++ b/packages/feedback/test/widget/createWidget.test.ts @@ -49,9 +49,8 @@ const DEFAULT_OPTIONS = { nameLabel: NAME_LABEL, successMessageText: SUCCESS_MESSAGE_TEXT, - onActorClick: jest.fn(), - onDialogClose: jest.fn(), - onDialogOpen: jest.fn(), + onFormClose: jest.fn(), + onFormOpen: jest.fn(), onSubmitError: jest.fn(), onSubmitSuccess: jest.fn(), }; @@ -111,8 +110,8 @@ describe('createWidget', () => { }); it('clicking on actor opens dialog and hides the actor', () => { - const onDialogOpen = jest.fn(); - const { widget } = createShadowAndWidget({ onDialogOpen }); + const onFormOpen = jest.fn(); + const { widget } = createShadowAndWidget({ onFormOpen }); widget.actor?.el?.dispatchEvent(new Event('click')); // Dialog is now visible @@ -121,7 +120,7 @@ describe('createWidget', () => { // Actor should be hidden expect(widget.actor?.el?.getAttribute('aria-hidden')).toBe('true'); - expect(onDialogOpen).toHaveBeenCalledTimes(1); + expect(onFormOpen).toHaveBeenCalledTimes(1); }); it('submits feedback successfully', async () => { @@ -282,8 +281,8 @@ describe('createWidget', () => { }); it('closes when Cancel button is clicked', () => { - const onDialogClose = jest.fn(); - const { widget } = createShadowAndWidget({ onDialogClose }); + const onFormClose = jest.fn(); + const { widget } = createShadowAndWidget({ onFormClose }); widget.actor?.el?.dispatchEvent(new Event('click')); expect(widget.dialog?.el).toBeInstanceOf(HTMLDialogElement); @@ -296,7 +295,7 @@ describe('createWidget', () => { // Element/component should still exist, but it will be in a closed state expect(widget.dialog?.el).toBeInstanceOf(HTMLDialogElement); expect(widget.dialog?.el?.open).toBe(false); - expect(onDialogClose).toHaveBeenCalledTimes(1); + expect(onFormClose).toHaveBeenCalledTimes(1); // Actor should now be visible too expect(widget.actor?.el?.getAttribute('aria-hidden')).toBe('false'); @@ -309,8 +308,8 @@ describe('createWidget', () => { }); it('closes when dialog (background)) is clicked', () => { - const onDialogClose = jest.fn(); - const { widget } = createShadowAndWidget({ onDialogClose }); + const onFormClose = jest.fn(); + const { widget } = createShadowAndWidget({ onFormClose }); widget.actor?.el?.dispatchEvent(new Event('click')); expect(widget.dialog?.el).toBeInstanceOf(HTMLDialogElement); @@ -323,7 +322,7 @@ describe('createWidget', () => { // Element/component should still exist, but it will be in a closed state expect(widget.dialog?.el).toBeInstanceOf(HTMLDialogElement); expect(widget.dialog?.el?.open).toBe(false); - expect(onDialogClose).toHaveBeenCalledTimes(1); + expect(onFormClose).toHaveBeenCalledTimes(1); // Actor should now be visible too expect(widget.actor?.el?.getAttribute('aria-hidden')).toBe('false'); @@ -336,7 +335,7 @@ describe('createWidget', () => { }); it('attaches to a custom actor element', () => { - const onDialogOpen = jest.fn(); + const onFormOpen = jest.fn(); // This element is in the normal DOM const myActor = document.createElement('div'); myActor.textContent = 'my button'; @@ -344,7 +343,7 @@ describe('createWidget', () => { const { widget } = createShadowAndWidget( { autoInject: false, - onDialogOpen, + onFormOpen, }, { attachTo: myActor, @@ -354,7 +353,7 @@ describe('createWidget', () => { myActor.dispatchEvent(new Event('click')); expect(widget.dialog?.el).toBeInstanceOf(HTMLDialogElement); expect(widget.dialog?.el?.open).toBe(true); - expect(onDialogOpen).toHaveBeenCalledTimes(1); + expect(onFormOpen).toHaveBeenCalledTimes(1); // This is all we do with `attachTo` (open dialog) }); }); From 392110c261e0d8bea43eb441c390a9c76a248eab Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 09:39:58 +0100 Subject: [PATCH 05/21] ref(replay): Remove unused method (#9613) We aren't actually using this anymore anywhere. --- .../replay/src/coreHandlers/util/networkUtils.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index be7b0d193d3d..761cb0c8316b 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -110,21 +110,6 @@ export function makeNetworkReplayBreadcrumb( return result; } -/** Get either a JSON network body, or a text representation. */ -export function getNetworkBody(bodyText: string | undefined): NetworkBody | undefined { - if (!bodyText) { - return; - } - - try { - return JSON.parse(bodyText); - } catch { - // return text - } - - return bodyText; -} - /** Build the request or response part of a replay network breadcrumb that was skipped. */ export function buildSkippedNetworkRequestOrResponse(bodySize: number | undefined): ReplayNetworkRequestOrResponse { return { From 68bb8206d385b857a9c068ef9d26bc5f4e4c4af7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 09:43:02 +0100 Subject: [PATCH 06/21] fix(replay): Add `BODY_PARSE_ERROR` warning & time out fetch response load (#9622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a tricky one 😬 Basically, it is possible that fetch returns a readable stream that is ongoing. So if we do `await response.text()` this will be pending forever, if a stream is ongoing. I haven't found a way to really check that is the case and avoid parsing this at all 🤔 So the best I could come up with was to instead add a timeout of 500ms when we stop waiting for the fetch body. This should at least unblock waiting on this, but it will still mean that the response continues to be parsed client-side - I don't think there is a way to abort this 🤔 Additionally, this also refactors this a bit so we add a new `BODY_PARSE_ERROR` meta warning if the parsing of the body fails, for whatever reason. we may also use this in the replay UI cc @ryan953 somehow? "fixes" https://github.com/getsentry/sentry-javascript/issues/9616 --- .../src/coreHandlers/util/fetchUtils.ts | 106 ++++++++++++-- .../src/coreHandlers/util/networkUtils.ts | 34 ++++- .../replay/src/coreHandlers/util/xhrUtils.ts | 23 ++- packages/replay/src/types/request.ts | 2 +- .../handleNetworkBreadcrumbs.test.ts | 2 + .../unit/coreHandlers/util/fetchUtils.test.ts | 137 ++++++++++++++++++ 6 files changed, 277 insertions(+), 27 deletions(-) create mode 100644 packages/replay/test/unit/coreHandlers/util/fetchUtils.test.ts diff --git a/packages/replay/src/coreHandlers/util/fetchUtils.ts b/packages/replay/src/coreHandlers/util/fetchUtils.ts index 80c6b4686a70..9e0ac27c7d38 100644 --- a/packages/replay/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay/src/coreHandlers/util/fetchUtils.ts @@ -3,6 +3,7 @@ import { logger } from '@sentry/utils'; import type { FetchHint, + NetworkMetaWarning, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, @@ -16,6 +17,7 @@ import { getBodySize, getBodyString, makeNetworkReplayBreadcrumb, + mergeWarning, parseContentLengthHeader, urlMatches, } from './networkUtils'; @@ -118,17 +120,24 @@ function _getRequestInfo( // We only want to transmit string or string-like bodies const requestBody = _getFetchRequestArgBody(input); - const bodyStr = getBodyString(requestBody); - return buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); + const [bodyStr, warning] = getBodyString(requestBody); + const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); + + if (warning) { + return mergeWarning(data, warning); + } + + return data; } -async function _getResponseInfo( +/** Exported only for tests. */ +export async function _getResponseInfo( captureDetails: boolean, { networkCaptureBodies, textEncoder, networkResponseHeaders, - }: ReplayNetworkOptions & { + }: Pick & { textEncoder: TextEncoderInternal; }, response: Response | undefined, @@ -144,12 +153,39 @@ async function _getResponseInfo( return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } - // Only clone the response if we need to - try { - // We have to clone this, as the body can only be read once - const res = response.clone(); - const bodyText = await _parseFetchBody(res); + const [bodyText, warning] = await _parseFetchResponseBody(response); + const result = getResponseData(bodyText, { + networkCaptureBodies, + textEncoder, + responseBodySize, + captureDetails, + headers, + }); + + if (warning) { + return mergeWarning(result, warning); + } + return result; +} + +function getResponseData( + bodyText: string | undefined, + { + networkCaptureBodies, + textEncoder, + responseBodySize, + captureDetails, + headers, + }: { + captureDetails: boolean; + networkCaptureBodies: boolean; + responseBodySize: number | undefined; + headers: Record; + textEncoder: TextEncoderInternal; + }, +): ReplayNetworkRequestOrResponse | undefined { + try { const size = bodyText && bodyText.length && responseBodySize === undefined ? getBodySize(bodyText, textEncoder) @@ -171,11 +207,19 @@ async function _getResponseInfo( } } -async function _parseFetchBody(response: Response): Promise { +async function _parseFetchResponseBody(response: Response): Promise<[string | undefined, NetworkMetaWarning?]> { + const res = _tryCloneResponse(response); + + if (!res) { + return [undefined, 'BODY_PARSE_ERROR']; + } + try { - return await response.text(); - } catch { - return undefined; + const text = await _tryGetResponseText(res); + return [text]; + } catch (error) { + __DEBUG_BUILD__ && logger.warn('[Replay] Failed to get text body from response', error); + return [undefined, 'BODY_PARSE_ERROR']; } } @@ -237,3 +281,39 @@ function getHeadersFromOptions( return getAllowedHeaders(headers, allowedHeaders); } + +function _tryCloneResponse(response: Response): Response | void { + try { + // We have to clone this, as the body can only be read once + return response.clone(); + } catch (error) { + // this can throw if the response was already consumed before + __DEBUG_BUILD__ && logger.warn('[Replay] Failed to clone response body', error); + } +} + +/** + * Get the response body of a fetch request, or timeout after 500ms. + * Fetch can return a streaming body, that may not resolve (or not for a long time). + * If that happens, we rather abort after a short time than keep waiting for this. + */ +function _tryGetResponseText(response: Response): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout while trying to read response body')), 500); + + _getResponseText(response) + .then( + txt => resolve(txt), + reason => reject(reason), + ) + .finally(() => clearTimeout(timeout)); + }); + + return _getResponseText(response); +} + +async function _getResponseText(response: Response): Promise { + // Force this to be a promise, just to be safe + // eslint-disable-next-line no-return-await + return await response.text(); +} diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index 761cb0c8316b..ac11f652fb40 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -61,26 +61,50 @@ export function parseContentLengthHeader(header: string | null | undefined): num } /** Get the string representation of a body. */ -export function getBodyString(body: unknown): string | undefined { +export function getBodyString(body: unknown): [string | undefined, NetworkMetaWarning?] { try { if (typeof body === 'string') { - return body; + return [body]; } if (body instanceof URLSearchParams) { - return body.toString(); + return [body.toString()]; } if (body instanceof FormData) { - return _serializeFormData(body); + return [_serializeFormData(body)]; } } catch { __DEBUG_BUILD__ && logger.warn('[Replay] Failed to serialize body', body); + return [undefined, 'BODY_PARSE_ERROR']; } __DEBUG_BUILD__ && logger.info('[Replay] Skipping network body because of body type', body); - return undefined; + return [undefined]; +} + +/** Merge a warning into an existing network request/response. */ +export function mergeWarning( + info: ReplayNetworkRequestOrResponse | undefined, + warning: NetworkMetaWarning, +): ReplayNetworkRequestOrResponse { + if (!info) { + return { + headers: {}, + size: undefined, + _meta: { + warnings: [warning], + }, + }; + } + + const newMeta = { ...info._meta }; + const existingWarnings = newMeta.warnings || []; + newMeta.warnings = [...existingWarnings, warning]; + + info._meta = newMeta; + return info; } /** Convert ReplayNetworkRequestData to a PerformanceEntry. */ diff --git a/packages/replay/src/coreHandlers/util/xhrUtils.ts b/packages/replay/src/coreHandlers/util/xhrUtils.ts index 264090084cd9..38195f045ed4 100644 --- a/packages/replay/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay/src/coreHandlers/util/xhrUtils.ts @@ -1,7 +1,13 @@ import type { Breadcrumb, TextEncoderInternal, XhrBreadcrumbData } from '@sentry/types'; import { logger, SENTRY_XHR_DATA_KEY } from '@sentry/utils'; -import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; +import type { + NetworkMetaWarning, + ReplayContainer, + ReplayNetworkOptions, + ReplayNetworkRequestData, + XhrHint, +} from '../../types'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, @@ -10,6 +16,7 @@ import { getBodySize, getBodyString, makeNetworkReplayBreadcrumb, + mergeWarning, parseContentLengthHeader, urlMatches, } from './networkUtils'; @@ -103,8 +110,8 @@ function _prepareXhrData( : {}; const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); - const requestBody = options.networkCaptureBodies ? getBodyString(input) : undefined; - const responseBody = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : undefined; + const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input) : [undefined]; + const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody); const response = buildNetworkRequestOrResponse(networkResponseHeaders, responseBodySize, responseBody); @@ -115,8 +122,8 @@ function _prepareXhrData( url, method, statusCode, - request, - response, + request: requestWarning ? mergeWarning(request, requestWarning) : request, + response: responseWarning ? mergeWarning(response, responseWarning) : response, }; } @@ -134,12 +141,12 @@ function getResponseHeaders(xhr: XMLHttpRequest): Record { }, {}); } -function _getXhrResponseBody(xhr: XMLHttpRequest): string | undefined { +function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] { // We collect errors that happen, but only log them if we can't get any response body const errors: unknown[] = []; try { - return xhr.responseText; + return [xhr.responseText]; } catch (e) { errors.push(e); } @@ -154,5 +161,5 @@ function _getXhrResponseBody(xhr: XMLHttpRequest): string | undefined { __DEBUG_BUILD__ && logger.warn('[Replay] Failed to get xhr response body', ...errors); - return undefined; + return [undefined]; } diff --git a/packages/replay/src/types/request.ts b/packages/replay/src/types/request.ts index 1e5692f901e9..03067596fb36 100644 --- a/packages/replay/src/types/request.ts +++ b/packages/replay/src/types/request.ts @@ -3,7 +3,7 @@ type JsonArray = unknown[]; export type NetworkBody = JsonObject | JsonArray | string; -export type NetworkMetaWarning = 'MAYBE_JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'URL_SKIPPED'; +export type NetworkMetaWarning = 'MAYBE_JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'URL_SKIPPED' | 'BODY_PARSE_ERROR'; interface NetworkMeta { warnings?: NetworkMetaWarning[]; diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 24b7aa6414e9..99b377ba7d17 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -23,6 +23,8 @@ async function waitForReplayEventBuffer() { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); } const LARGE_BODY = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 1); diff --git a/packages/replay/test/unit/coreHandlers/util/fetchUtils.test.ts b/packages/replay/test/unit/coreHandlers/util/fetchUtils.test.ts new file mode 100644 index 000000000000..82a6b66eeb7d --- /dev/null +++ b/packages/replay/test/unit/coreHandlers/util/fetchUtils.test.ts @@ -0,0 +1,137 @@ +import { TextEncoder } from 'util'; + +import { _getResponseInfo } from '../../../../src/coreHandlers/util/fetchUtils'; + +describe('_getResponseInfo', () => { + it('works with captureDetails: false', async () => { + const res = await _getResponseInfo( + false, + { + networkCaptureBodies: true, + textEncoder: new TextEncoder(), + networkResponseHeaders: [], + }, + undefined, + undefined, + ); + + expect(res).toEqual(undefined); + }); + + it('works with captureDetails: false & responseBodySize', async () => { + const res = await _getResponseInfo( + false, + { + networkCaptureBodies: true, + textEncoder: new TextEncoder(), + networkResponseHeaders: [], + }, + undefined, + 123, + ); + + expect(res).toEqual({ + headers: {}, + size: 123, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }); + }); + + it('works with text body', async () => { + const response = { + headers: { + has: () => { + return false; + }, + get: () => { + return undefined; + }, + }, + clone: () => response, + text: () => Promise.resolve('text body'), + } as unknown as Response; + + const res = await _getResponseInfo( + true, + { + networkCaptureBodies: true, + textEncoder: new TextEncoder(), + networkResponseHeaders: [], + }, + response, + undefined, + ); + + expect(res).toEqual({ + headers: {}, + size: 9, + body: 'text body', + }); + }); + + it('works with body that fails', async () => { + const response = { + headers: { + has: () => { + return false; + }, + get: () => { + return undefined; + }, + }, + clone: () => response, + text: () => Promise.reject('cannot read'), + } as unknown as Response; + + const res = await _getResponseInfo( + true, + { + networkCaptureBodies: true, + textEncoder: new TextEncoder(), + networkResponseHeaders: [], + }, + response, + undefined, + ); + + expect(res).toEqual({ + _meta: { warnings: ['BODY_PARSE_ERROR'] }, + headers: {}, + size: undefined, + }); + }); + + it('works with body that times out', async () => { + const response = { + headers: { + has: () => { + return false; + }, + get: () => { + return undefined; + }, + }, + clone: () => response, + text: () => new Promise(resolve => setTimeout(() => resolve('text body'), 1000)), + } as unknown as Response; + + const res = await _getResponseInfo( + true, + { + networkCaptureBodies: true, + textEncoder: new TextEncoder(), + networkResponseHeaders: [], + }, + response, + undefined, + ); + + expect(res).toEqual({ + _meta: { warnings: ['BODY_PARSE_ERROR'] }, + headers: {}, + size: undefined, + }); + }); +}); From 13b4b4721b3b3a0feefc80e3a1f760f3be7c8b87 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 10:21:33 +0100 Subject: [PATCH 07/21] feat(core): Allow to use `continueTrace` without callback (#9615) Inspired by recent comments about the continueTrace method, I figured we can actually update this in a backwards-compatible way to _also_ allow to use it without a callback. --- packages/core/src/tracing/trace.ts | 25 +++++++++++- packages/core/test/lib/tracing/trace.test.ts | 41 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 4572eed79ee9..33e19ec7ecb4 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -203,6 +203,23 @@ export function getActiveSpan(): Span | undefined { return getCurrentHub().getScope().getSpan(); } +export function continueTrace({ + sentryTrace, + baggage, +}: { + sentryTrace: Parameters[0]; + baggage: Parameters[1]; +}): Partial; +export function continueTrace( + { + sentryTrace, + baggage, + }: { + sentryTrace: Parameters[0]; + baggage: Parameters[1]; + }, + callback: (transactionContext: Partial) => V, +): V; /** * Continue a trace from `sentry-trace` and `baggage` values. * These values can be obtained from incoming request headers, @@ -219,8 +236,8 @@ export function continueTrace( sentryTrace: Parameters[0]; baggage: Parameters[1]; }, - callback: (transactionContext: Partial) => V, -): V { + callback?: (transactionContext: Partial) => V, +): V | Partial { const hub = getCurrentHub(); const currentScope = hub.getScope(); @@ -242,6 +259,10 @@ export function continueTrace( }), }; + if (!callback) { + return transactionContext; + } + return callback(transactionContext); } diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 144ec35f1f0e..b8f2d584434e 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -320,4 +320,45 @@ describe('continueTrace', () => { expect(scope['_sdkProcessingMetadata']).toEqual({}); }); + + it('returns response of callback', () => { + const expectedContext = { + metadata: { + dynamicSamplingContext: {}, + }, + parentSampled: false, + parentSpanId: '1121201211212012', + traceId: '12312012123120121231201212312012', + }; + + const result = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }, + ctx => { + return { ctx }; + }, + ); + + expect(result).toEqual({ ctx: expectedContext }); + }); + + it('works without a callback', () => { + const expectedContext = { + metadata: { + dynamicSamplingContext: {}, + }, + parentSampled: false, + parentSpanId: '1121201211212012', + traceId: '12312012123120121231201212312012', + }; + + const ctx = continueTrace({ + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }); + + expect(ctx).toEqual(expectedContext); + }); }); From f2518332448241b8935862e48cc531bd7d468b0d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 11:48:32 +0100 Subject: [PATCH 08/21] feat(core): Allow to pass `mechanism` as event hint (#9590) This allows to pass `mechanism` as an event hint. Also, we now allow to pass `hint` as an alternative to `CaptureContext` to `captureException` as second argument. We have quite a lot of code where we fork a scope and add an event processor, only to add a mechanism to the event. Since this is a quite common pattern, I figured it makes more sense to allow to pass a mechanism as an EventHint. In addition, I noticed that while `hub.captureException()` takes an event hint as second argument, in the core exported `captureException()` we take a `CaptureContext` as second argument instead (for legacy reasons). In order to be able to pass a mechanism there as well, I updated the method signature to allow _either_ a CaptureContext _or_ an EventHint. I wrote some tests covering this to make sure that works - it's a bit tricky since both can be POJOs, but no fields overlap so we are able to parse this together. --- packages/angular/src/errorhandler.ts | 17 +-- packages/angular/test/errorhandler.test.ts | 113 +++++++----------- packages/astro/src/server/middleware.ts | 23 ++-- packages/astro/test/server/middleware.test.ts | 21 +--- packages/browser/src/helpers.ts | 6 +- .../src/integrations/globalhandlers.ts | 39 +++--- packages/bun/src/integrations/bunserver.ts | 31 ++--- packages/core/src/exports.ts | 17 +-- packages/core/src/utils/prepareEvent.ts | 82 ++++++++++++- packages/core/test/lib/prepareEvent.test.ts | 72 ++++++++++- .../deno/src/integrations/globalhandlers.ts | 30 ++--- packages/hub/test/exports.test.ts | 2 +- packages/nextjs/src/common/_error.ts | 22 ++-- .../nextjs/src/common/utils/wrapperUtils.ts | 24 +--- .../common/withServerActionInstrumentation.ts | 13 +- .../src/common/wrapApiHandlerWithSentry.ts | 18 +-- .../src/common/wrapPageComponentWithSentry.ts | 28 ++--- .../src/common/wrapRouteHandlerWithSentry.ts | 15 +-- .../common/wrapServerComponentWithSentry.ts | 15 +-- packages/node/src/handlers.ts | 19 +-- packages/react/src/errorboundary.tsx | 14 +-- packages/react/test/errorboundary.test.tsx | 28 +++-- packages/remix/src/client/errors.tsx | 44 ++++--- packages/sveltekit/src/client/handleError.ts | 15 +-- packages/sveltekit/src/client/load.ts | 23 ++-- packages/sveltekit/src/server/handle.ts | 23 ++-- packages/sveltekit/src/server/handleError.ts | 15 +-- packages/sveltekit/src/server/load.ts | 23 ++-- .../sveltekit/test/client/handleError.test.ts | 52 ++------ packages/sveltekit/test/client/load.test.ts | 46 ++----- packages/sveltekit/test/server/handle.test.ts | 44 +------ .../sveltekit/test/server/handleError.test.ts | 51 ++------ packages/sveltekit/test/server/load.test.ts | 46 +------ packages/types/src/event.ts | 2 + packages/vue/src/errorhandler.ts | 15 +-- packages/vue/src/router.ts | 12 +- packages/vue/test/errorHandler.test.ts | 2 +- packages/vue/test/router.test.ts | 3 +- 38 files changed, 434 insertions(+), 631 deletions(-) diff --git a/packages/angular/src/errorhandler.ts b/packages/angular/src/errorhandler.ts index ea6dea85d04c..2cc6550c63cb 100644 --- a/packages/angular/src/errorhandler.ts +++ b/packages/angular/src/errorhandler.ts @@ -2,8 +2,8 @@ import { HttpErrorResponse } from '@angular/common/http'; import type { ErrorHandler as AngularErrorHandler } from '@angular/core'; import { Inject, Injectable } from '@angular/core'; import * as Sentry from '@sentry/browser'; -import type { Event, Scope } from '@sentry/types'; -import { addExceptionMechanism, isString } from '@sentry/utils'; +import type { Event } from '@sentry/types'; +import { isString } from '@sentry/utils'; import { runOutsideAngular } from './zone'; @@ -102,17 +102,8 @@ class SentryErrorHandler implements AngularErrorHandler { // Capture handled exception and send it to Sentry. const eventId = runOutsideAngular(() => - Sentry.captureException(extractedError, (scope: Scope) => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'angular', - handled: false, - }); - - return event; - }); - - return scope; + Sentry.captureException(extractedError, { + mechanism: { type: 'angular', handled: false }, }), ); diff --git a/packages/angular/test/errorhandler.test.ts b/packages/angular/test/errorhandler.test.ts index 3a78d715de27..d298fd72cdee 100644 --- a/packages/angular/test/errorhandler.test.ts +++ b/packages/angular/test/errorhandler.test.ts @@ -1,28 +1,17 @@ import { HttpErrorResponse } from '@angular/common/http'; import * as SentryBrowser from '@sentry/browser'; -import { Scope } from '@sentry/browser'; import type { Event } from '@sentry/types'; -import * as SentryUtils from '@sentry/utils'; import { createErrorHandler, SentryErrorHandler } from '../src/errorhandler'; -const FakeScope = new Scope(); - -jest.mock('@sentry/browser', () => { - const original = jest.requireActual('@sentry/browser'); - return { - ...original, - captureException: (err: unknown, cb: (arg0?: unknown) => unknown) => { - cb(FakeScope); - return original.captureException(err, cb); - }, - }; -}); - const captureExceptionSpy = jest.spyOn(SentryBrowser, 'captureException'); jest.spyOn(console, 'error').mockImplementation(); +const captureExceptionEventHint = { + mechanism: { handled: false, type: 'angular' }, +}; + class CustomError extends Error { public name: string; @@ -55,34 +44,18 @@ describe('SentryErrorHandler', () => { }); describe('handleError method', () => { - it('handleError method assigns the correct mechanism', () => { - const addEventProcessorSpy = jest.spyOn(FakeScope, 'addEventProcessor').mockImplementationOnce(callback => { - void (callback as (event: any, hint: any) => void)({}, { event_id: 'fake-event-id' }); - return FakeScope; - }); - - const addExceptionMechanismSpy = jest.spyOn(SentryUtils, 'addExceptionMechanism'); - - const errorHandler = createErrorHandler(); - errorHandler.handleError(new Error('test')); - - expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(addExceptionMechanismSpy).toBeCalledTimes(1); - expect(addExceptionMechanismSpy).toBeCalledWith({}, { handled: false, type: 'angular' }); - }); - it('extracts `null` error', () => { createErrorHandler().handleError(null); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts `undefined` error', () => { createErrorHandler().handleError(undefined); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts a string', () => { @@ -90,7 +63,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(str); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(str, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(str, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an empty Error', () => { @@ -98,7 +71,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(err, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(err, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts a non-empty Error', () => { @@ -107,7 +80,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(err, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(err, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an error-like object without stack', () => { @@ -119,7 +92,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(errorLikeWithoutStack); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithoutStack, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithoutStack, captureExceptionEventHint); }); it('extracts an error-like object with a stack', () => { @@ -132,7 +105,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(errorLikeWithStack); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithStack, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithStack, captureExceptionEventHint); }); it('extracts an object that could look like an error but is not (does not have a message)', () => { @@ -144,7 +117,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(notErr); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts an object that could look like an error but is not (does not have an explicit name)', () => { @@ -155,7 +128,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(notErr); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts an object that could look like an error but is not: the name is of the wrong type', () => { @@ -167,7 +140,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(notErr); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts an object that could look like an error but is not: the message is of the wrong type', () => { @@ -179,7 +152,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(notErr); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts an instance of a class extending Error', () => { @@ -188,7 +161,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(err, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(err, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an instance of class not extending Error but that has an error-like shape', () => { @@ -197,7 +170,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(err, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(err, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an instance of a class that does not extend Error and does not have an error-like shape', () => { @@ -206,7 +179,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(notErr); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts ErrorEvent which has a string as an error', () => { @@ -215,7 +188,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts ErrorEvent which has an error as an error', () => { @@ -224,7 +197,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts ErrorEvent which has an error-like object as an error', () => { @@ -237,7 +210,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts ErrorEvent which has a non-error-like object as an error', () => { @@ -246,7 +219,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('Handled unknown error', captureExceptionEventHint); }); it('extracts an Error with `ngOriginalError`', () => { @@ -258,7 +231,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(ngErr, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(ngErr, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an `HttpErrorResponse` with `Error`', () => { @@ -268,7 +241,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(httpErr, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(httpErr, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an `HttpErrorResponse` with `ErrorEvent`', () => { @@ -278,7 +251,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('sentry-http-test', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('sentry-http-test', captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with string', () => { @@ -288,7 +261,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Server returned code 0 with body "sentry-http-test"', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -302,7 +275,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithoutStack, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithoutStack, captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with error-like object with a stack', () => { @@ -316,7 +289,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithStack, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(errorLikeWithStack, captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with an object that could look like an error but is not (does not have a message)', () => { @@ -331,7 +304,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -346,7 +319,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -362,7 +335,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -378,7 +351,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -395,7 +368,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -412,7 +385,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -428,7 +401,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -438,7 +411,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(err, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(err, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an `HttpErrorResponse` with an instance of class not extending Error but that has an error-like shape', () => { @@ -448,7 +421,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(innerErr, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(innerErr, { mechanism: { handled: false, type: 'angular' } }); }); it('extracts an `HttpErrorResponse` with an instance of a class that does not extend Error and does not have an error-like shape', () => { @@ -460,7 +433,7 @@ describe('SentryErrorHandler', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith( 'Http failure response for (unknown url): undefined undefined', - expect.any(Function), + captureExceptionEventHint, ); }); @@ -471,7 +444,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with an ErrorEvent which has an error as an error', () => { @@ -481,7 +454,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with an ErrorEvent which has an error-like object as an error', () => { @@ -495,7 +468,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', captureExceptionEventHint); }); it('extracts an `HttpErrorResponse` with an ErrorEvent which has a non-error-like object as an error', () => { @@ -505,7 +478,7 @@ describe('SentryErrorHandler', () => { createErrorHandler().handleError(err); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith('something happened', captureExceptionEventHint); }); it('extracts error with a custom extractor', () => { @@ -520,7 +493,7 @@ describe('SentryErrorHandler', () => { errorHandler.handleError('error'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenCalledWith(new Error('custom error'), expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(new Error('custom error'), captureExceptionEventHint); }); describe('opens the report dialog if `showDialog` is true', () => { diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index b5b7aa7d8c71..22823f1e6130 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,6 +1,6 @@ import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node'; import type { Hub, Span } from '@sentry/types'; -import { addExceptionMechanism, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; +import { objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; import type { APIContext, MiddlewareResponseHandler } from 'astro'; import { getTracingMetaTags } from './meta'; @@ -34,19 +34,14 @@ function sendErrorToSentry(e: unknown): unknown { // store a seen flag on it. const objectifiedErr = objectify(e); - captureException(objectifiedErr, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'astro', - handled: false, - data: { - function: 'astroMiddleware', - }, - }); - return event; - }); - - return scope; + captureException(objectifiedErr, { + mechanism: { + type: 'astro', + handled: false, + data: { + function: 'astroMiddleware', + }, + }, }); return objectifiedErr; diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 59ab8c18a3c4..39146cdaa1d7 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -1,5 +1,4 @@ import * as SentryNode from '@sentry/node'; -import * as SentryUtils from '@sentry/utils'; import { vi } from 'vitest'; import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; @@ -59,12 +58,7 @@ describe('sentryMiddleware', () => { }); it('throws and sends an error to sentry if `next()` throws', async () => { - const scope = { - addEventProcessor: vi.fn().mockImplementation(cb => cb({})), - }; - // @ts-expect-error, just testing the callback, this is okay for this test - const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException').mockImplementation((ex, cb) => cb(scope)); - const addExMechanismSpy = vi.spyOn(SentryUtils, 'addExceptionMechanism'); + const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException'); const middleware = handleRequest(); const ctx = { @@ -86,16 +80,9 @@ describe('sentryMiddleware', () => { // @ts-expect-error, a partial ctx object is fine here await expect(async () => middleware(ctx, next)).rejects.toThrowError(); - expect(captureExceptionSpy).toHaveBeenCalledWith(error, expect.any(Function)); - expect(scope.addEventProcessor).toHaveBeenCalledTimes(1); - expect(addExMechanismSpy).toHaveBeenCalledWith( - {}, // the mocked event - { - handled: false, - type: 'astro', - data: { function: 'astroMiddleware' }, - }, - ); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'astro', data: { function: 'astroMiddleware' } }, + }); }); it('attaches tracing headers', async () => { diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 5f7b6cee7df3..faa0762c8163 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -1,5 +1,5 @@ import { captureException, withScope } from '@sentry/core'; -import type { DsnLike, Event as SentryEvent, Mechanism, Scope, WrappedFunction } from '@sentry/types'; +import type { DsnLike, Mechanism, WrappedFunction } from '@sentry/types'; import { addExceptionMechanism, addExceptionTypeValue, @@ -99,8 +99,8 @@ export function wrap( } catch (ex) { ignoreNextOnError(); - withScope((scope: Scope) => { - scope.addEventProcessor((event: SentryEvent) => { + withScope(scope => { + scope.addEventProcessor(event => { if (options.mechanism) { addExceptionTypeValue(event, undefined, undefined); addExceptionMechanism(event, options.mechanism); diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index 3a6ca996ef01..af2f917daf96 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -1,15 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { getCurrentHub } from '@sentry/core'; -import type { Event, EventHint, Hub, Integration, Primitive, StackParser } from '@sentry/types'; -import { - addExceptionMechanism, - addInstrumentationHandler, - getLocationHref, - isErrorEvent, - isPrimitive, - isString, - logger, -} from '@sentry/utils'; +import type { Event, Hub, Integration, Primitive, StackParser } from '@sentry/types'; +import { addInstrumentationHandler, getLocationHref, isErrorEvent, isPrimitive, isString, logger } from '@sentry/utils'; import type { BrowserClient } from '../client'; import { eventFromUnknownInput } from '../eventbuilder'; @@ -103,7 +95,13 @@ function _installGlobalOnErrorHandler(): void { event.level = 'error'; - addMechanismAndCapture(hub, error, event, 'onerror'); + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'onerror', + }, + }); }, ); } @@ -149,7 +147,14 @@ function _installGlobalOnUnhandledRejectionHandler(): void { event.level = 'error'; - addMechanismAndCapture(hub, error, event, 'onunhandledrejection'); + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }); + return; }, ); @@ -243,16 +248,6 @@ function globalHandlerLog(type: string): void { __DEBUG_BUILD__ && logger.log(`Global Handler attached: ${type}`); } -function addMechanismAndCapture(hub: Hub, error: EventHint['originalException'], event: Event, type: string): void { - addExceptionMechanism(event, { - handled: false, - type, - }); - hub.captureEvent(event, { - originalException: error, - }); -} - function getHubAndOptions(): [Hub, StackParser, boolean | undefined] { const hub = getCurrentHub(); const client = hub.getClient(); diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index d20953628544..f7cd8cd8cbb9 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,25 +1,6 @@ import { captureException, continueTrace, runWithAsyncContext, startSpan, Transaction } from '@sentry/core'; import type { Integration } from '@sentry/types'; -import { addExceptionMechanism, getSanitizedUrlString, parseUrl } from '@sentry/utils'; - -function sendErrorToSentry(e: unknown): unknown { - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'bun', - handled: false, - data: { - function: 'serve', - }, - }); - return event; - }); - - return scope; - }); - - return e; -} +import { getSanitizedUrlString, parseUrl } from '@sentry/utils'; /** * Instruments `Bun.serve` to automatically create transactions and capture errors. @@ -115,7 +96,15 @@ function instrumentBunServeOptions(serveOptions: Parameters[0] } return response; } catch (e) { - sendErrorToSentry(e); + captureException(e, { + mechanism: { + type: 'bun', + handled: false, + data: { + function: 'serve', + }, + }, + }); throw e; } }, diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 6569bc4e4c25..2be61f2fa940 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -20,6 +20,8 @@ import { isThenable, logger, timestampInSeconds, uuid4 } from '@sentry/utils'; import type { Hub } from './hub'; import { getCurrentHub } from './hub'; import type { Scope } from './scope'; +import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; +import { parseEventHintOrCaptureContext } from './utils/prepareEvent'; // Note: All functions in this file are typed with a return value of `ReturnType`, // where HUB_FUNCTION is some method on the Hub class. @@ -30,14 +32,15 @@ import type { Scope } from './scope'; /** * Captures an exception event and sends it to Sentry. - * - * @param exception An exception-like object. - * @param captureContext Additional scope data to apply to exception event. - * @returns The generated eventId. + * This accepts an event hint as optional second parameter. + * Alternatively, you can also pass a CaptureContext directly as second parameter. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export function captureException(exception: any, captureContext?: CaptureContext): ReturnType { - return getCurrentHub().captureException(exception, { captureContext }); +export function captureException( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exception: any, + hint?: ExclusiveEventHintOrCaptureContext, +): ReturnType { + return getCurrentHub().captureException(exception, parseEventHintOrCaptureContext(hint)); } /** diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 6e473a9b1cb1..33e9eca33ef1 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -1,10 +1,37 @@ -import type { Client, ClientOptions, Event, EventHint, StackFrame, StackParser } from '@sentry/types'; -import { dateTimestampInSeconds, GLOBAL_OBJ, normalize, resolvedSyncPromise, truncate, uuid4 } from '@sentry/utils'; +import type { + CaptureContext, + Client, + ClientOptions, + Event, + EventHint, + Scope as ScopeInterface, + ScopeContext, + StackFrame, + StackParser, +} from '@sentry/types'; +import { + addExceptionMechanism, + dateTimestampInSeconds, + GLOBAL_OBJ, + normalize, + resolvedSyncPromise, + truncate, + uuid4, +} from '@sentry/utils'; import { DEFAULT_ENVIRONMENT } from '../constants'; import { getGlobalEventProcessors, notifyEventProcessors } from '../eventProcessors'; import { Scope } from '../scope'; +/** + * This type makes sure that we get either a CaptureContext, OR an EventHint. + * It does not allow mixing them, which could lead to unexpected outcomes, e.g. this is disallowed: + * { user: { id: '123' }, mechanism: { handled: false } } + */ +export type ExclusiveEventHintOrCaptureContext = + | (CaptureContext & Partial<{ [key in keyof EventHint]: never }>) + | (EventHint & Partial<{ [key in keyof ScopeContext]: never }>); + /** * Adds common information to events. * @@ -52,6 +79,10 @@ export function prepareEvent( finalScope = Scope.clone(finalScope).update(hint.captureContext); } + if (hint.mechanism) { + addExceptionMechanism(prepared, hint.mechanism); + } + // We prepare the result here with a resolved Event. let result = resolvedSyncPromise(prepared); @@ -309,3 +340,50 @@ function normalizeEvent(event: Event | null, depth: number, maxBreadth: number): return normalized; } + +/** + * Parse either an `EventHint` directly, or convert a `CaptureContext` to an `EventHint`. + * This is used to allow to update method signatures that used to accept a `CaptureContext` but should now accept an `EventHint`. + */ +export function parseEventHintOrCaptureContext( + hint: ExclusiveEventHintOrCaptureContext | undefined, +): EventHint | undefined { + if (!hint) { + return undefined; + } + + // If you pass a Scope or `() => Scope` as CaptureContext, we just return this as captureContext + if (hintIsScopeOrFunction(hint)) { + return { captureContext: hint }; + } + + if (hintIsScopeContext(hint)) { + return { + captureContext: hint, + }; + } + + return hint; +} + +function hintIsScopeOrFunction( + hint: CaptureContext | EventHint, +): hint is ScopeInterface | ((scope: ScopeInterface) => ScopeInterface) { + return hint instanceof Scope || typeof hint === 'function'; +} + +type ScopeContextProperty = keyof ScopeContext; +const captureContextKeys: readonly ScopeContextProperty[] = [ + 'user', + 'level', + 'extra', + 'contexts', + 'tags', + 'fingerprint', + 'requestSession', + 'propagationContext', +] as const; + +function hintIsScopeContext(hint: Partial | EventHint): hint is Partial { + return Object.keys(hint).some(key => captureContextKeys.includes(key as ScopeContextProperty)); +} diff --git a/packages/core/test/lib/prepareEvent.test.ts b/packages/core/test/lib/prepareEvent.test.ts index 1e8b53e60f8c..9d00df469112 100644 --- a/packages/core/test/lib/prepareEvent.test.ts +++ b/packages/core/test/lib/prepareEvent.test.ts @@ -1,7 +1,8 @@ -import type { Event } from '@sentry/types'; +import type { Event, EventHint, ScopeContext } from '@sentry/types'; import { createStackParser, GLOBAL_OBJ } from '@sentry/utils'; -import { applyDebugIds, applyDebugMeta } from '../../src/utils/prepareEvent'; +import { Scope } from '../../src/scope'; +import { applyDebugIds, applyDebugMeta, parseEventHintOrCaptureContext } from '../../src/utils/prepareEvent'; describe('applyDebugIds', () => { afterEach(() => { @@ -105,3 +106,70 @@ describe('applyDebugMeta', () => { }); }); }); + +describe('parseEventHintOrCaptureContext', () => { + it('works with undefined', () => { + const actual = parseEventHintOrCaptureContext(undefined); + expect(actual).toEqual(undefined); + }); + + it('works with an empty object', () => { + const actual = parseEventHintOrCaptureContext({}); + expect(actual).toEqual({}); + }); + + it('works with a Scope', () => { + const scope = new Scope(); + const actual = parseEventHintOrCaptureContext(scope); + expect(actual).toEqual({ captureContext: scope }); + }); + + it('works with a function', () => { + const scope = () => new Scope(); + const actual = parseEventHintOrCaptureContext(scope); + expect(actual).toEqual({ captureContext: scope }); + }); + + it('works with an EventHint', () => { + const hint: EventHint = { + mechanism: { handled: false }, + }; + const actual = parseEventHintOrCaptureContext(hint); + expect(actual).toEqual(hint); + }); + + it('works with a ScopeContext', () => { + const scopeContext: ScopeContext = { + user: { id: 'xxx' }, + level: 'debug', + extra: { foo: 'bar' }, + contexts: { os: { name: 'linux' } }, + tags: { foo: 'bar' }, + fingerprint: ['xx', 'yy'], + requestSession: { status: 'ok' }, + propagationContext: { + traceId: 'xxx', + spanId: 'yyy', + }, + }; + + const actual = parseEventHintOrCaptureContext(scopeContext); + expect(actual).toEqual({ captureContext: scopeContext }); + }); + + it('triggers a TS error if trying to mix ScopeContext & EventHint', () => { + const actual = parseEventHintOrCaptureContext({ + // @ts-expect-error We are specifically testing that this errors! + user: { id: 'xxx' }, + mechanism: { handled: false }, + }); + + // ScopeContext takes presedence in this case, but this is actually not supported + expect(actual).toEqual({ + captureContext: { + user: { id: 'xxx' }, + mechanism: { handled: false }, + }, + }); + }); +}); diff --git a/packages/deno/src/integrations/globalhandlers.ts b/packages/deno/src/integrations/globalhandlers.ts index 7e4d2e003673..d173780cfa50 100644 --- a/packages/deno/src/integrations/globalhandlers.ts +++ b/packages/deno/src/integrations/globalhandlers.ts @@ -1,7 +1,7 @@ import type { ServerRuntimeClient } from '@sentry/core'; import { flush, getCurrentHub } from '@sentry/core'; -import type { Event, EventHint, Hub, Integration, Primitive, StackParser } from '@sentry/types'; -import { addExceptionMechanism, eventFromUnknownInput, isPrimitive } from '@sentry/utils'; +import type { Event, Hub, Integration, Primitive, StackParser } from '@sentry/types'; +import { eventFromUnknownInput, isPrimitive } from '@sentry/utils'; type GlobalHandlersIntegrationsOptionKeys = 'error' | 'unhandledrejection'; @@ -74,7 +74,13 @@ function installGlobalErrorHandler(): void { event.level = 'fatal'; - addMechanismAndCapture(hub, error, event, 'error'); + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'error', + }, + }); // Stop the app from exiting for now data.preventDefault(); @@ -111,7 +117,13 @@ function installGlobalUnhandledRejectionHandler(): void { event.level = 'fatal'; - addMechanismAndCapture(hub, error as unknown as Error, event, 'unhandledrejection'); + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'unhandledrejection', + }, + }); // Stop the app from exiting for now e.preventDefault(); @@ -144,16 +156,6 @@ function eventFromRejectionWithPrimitive(reason: Primitive): Event { }; } -function addMechanismAndCapture(hub: Hub, error: EventHint['originalException'], event: Event, type: string): void { - addExceptionMechanism(event, { - handled: false, - type, - }); - hub.captureEvent(event, { - originalException: error, - }); -} - function getHubAndOptions(): [Hub, StackParser] { const hub = getCurrentHub(); const client = hub.getClient(); diff --git a/packages/hub/test/exports.test.ts b/packages/hub/test/exports.test.ts index 967448a05d4a..c2b71f84ca47 100644 --- a/packages/hub/test/exports.test.ts +++ b/packages/hub/test/exports.test.ts @@ -80,7 +80,7 @@ describe('Top Level API', () => { const captureContext = { extra: { foo: 'wat' } }; captureException(e, captureContext); expect(client.captureException.mock.calls[0][0]).toBe(e); - expect(client.captureException.mock.calls[0][1].captureContext).toBe(captureContext); + expect(client.captureException.mock.calls[0][1].captureContext).toEqual(captureContext); }); }); diff --git a/packages/nextjs/src/common/_error.ts b/packages/nextjs/src/common/_error.ts index 1ad27cc0b67f..c69c725c3137 100644 --- a/packages/nextjs/src/common/_error.ts +++ b/packages/nextjs/src/common/_error.ts @@ -1,5 +1,4 @@ import { captureException, getCurrentHub, withScope } from '@sentry/core'; -import { addExceptionMechanism } from '@sentry/utils'; import type { NextPageContext } from 'next'; type ContextOrProps = { @@ -42,24 +41,21 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP } withScope(scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'instrument', - handled: false, - data: { - function: '_error.getInitialProps', - }, - }); - return event; - }); - if (req) { scope.setSDKProcessingMetadata({ request: req }); } // If third-party libraries (or users themselves) throw something falsy, we want to capture it as a message (which // is what passing a string to `captureException` will wind up doing) - captureException(err || `_error.js called with falsy error (${err})`); + captureException(err || `_error.js called with falsy error (${err})`, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: '_error.getInitialProps', + }, + }, + }); }); // In case this is being run as part of a serverless function (as is the case with the server half of nextjs apps diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index 9ae2cde39f83..44487608e251 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -6,7 +6,7 @@ import { startTransaction, } from '@sentry/core'; import type { Span, Transaction } from '@sentry/types'; -import { addExceptionMechanism, isString, tracingContextFromHeaders } from '@sentry/utils'; +import { isString, tracingContextFromHeaders } from '@sentry/utils'; import type { IncomingMessage, ServerResponse } from 'http'; import { platformSupportsStreaming } from './platformSupportsStreaming'; @@ -47,16 +47,7 @@ export function withErrorInstrumentation any>( return await origFunction.apply(this, origFunctionArguments); } catch (e) { // TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; - }); + captureException(e, { mechanism: { handled: false } }); throw e; } @@ -232,16 +223,7 @@ export async function callDataFetcherTraced Promis span.finish(); // TODO Copy more robust error handling over from `withSentry` - captureException(err, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; - }); + captureException(err, { mechanism: { handled: false } }); throw err; } diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 1ec28f372cab..8f458fc728a6 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; -import { addExceptionMechanism, logger, tracingContextFromHeaders } from '@sentry/utils'; +import { logger, tracingContextFromHeaders } from '@sentry/utils'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; @@ -111,16 +111,7 @@ async function withServerActionInstrumentationImplementation { - captureException(error, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; - }); + captureException(error, { mechanism: { handled: false } }); }, ); } finally { diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts index 5a93a257a209..0c94a3ce4aa1 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts @@ -6,14 +6,7 @@ import { startTransaction, } from '@sentry/core'; import type { Transaction } from '@sentry/types'; -import { - addExceptionMechanism, - isString, - logger, - objectify, - stripUrlQueryAndFragment, - tracingContextFromHeaders, -} from '@sentry/utils'; +import { isString, logger, objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler } from './types'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; @@ -187,20 +180,17 @@ export function withSentry(apiHandler: NextApiHandler, parameterizedRoute?: stri // way to prevent it from actually being reported twice.) const objectifiedErr = objectify(e); - currentScope.addEventProcessor(event => { - addExceptionMechanism(event, { + captureException(objectifiedErr, { + mechanism: { type: 'instrument', handled: false, data: { wrapped_handler: wrappingTarget.name, function: 'withSentry', }, - }); - return event; + }, }); - captureException(objectifiedErr); - // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that // the transaction was error-free diff --git a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts index 8d7dc3a2e8d1..ece566bc2e5a 100644 --- a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, captureException, configureScope, runWithAsyncContext } from '@sentry/core'; -import { addExceptionMechanism, extractTraceparentData } from '@sentry/utils'; +import { extractTraceparentData } from '@sentry/utils'; interface FunctionComponent { (...args: unknown[]): unknown; @@ -48,15 +48,10 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C try { return super.render(...args); } catch (e) { - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; + captureException(e, { + mechanism: { + handled: false, + }, }); throw e; } @@ -82,15 +77,10 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C try { return target.apply(thisArg, argArray); } catch (e) { - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; + captureException(e, { + mechanism: { + handled: false, + }, }); throw e; } diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index a111bbe0666d..d407a2578b5b 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; -import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils'; +import { tracingContextFromHeaders } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; @@ -54,15 +54,10 @@ export function wrapRouteHandlerWithSentry any>( error => { // Next.js throws errors when calling `redirect()`. We don't wanna report these. if (!isRedirectNavigationError(error)) { - captureException(error, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; + captureException(error, { + mechanism: { + handled: false, + }, }); } }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index e909bd114c7c..fc215e495b58 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -6,7 +6,7 @@ import { runWithAsyncContext, startTransaction, } from '@sentry/core'; -import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils'; +import { tracingContextFromHeaders } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; @@ -62,15 +62,10 @@ export function wrapServerComponentWithSentry any> } else { transaction.setStatus('internal_error'); - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; + captureException(e, { + mechanism: { + handled: false, + }, }); } diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 9ba531dff428..df438276231e 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -12,7 +12,6 @@ import { import type { Span } from '@sentry/types'; import type { AddRequestDataToEventOptions } from '@sentry/utils'; import { - addExceptionMechanism, addRequestDataToTransaction, dropUndefinedKeys, extractPathForTransaction, @@ -298,12 +297,7 @@ export function errorHandler(options?: { } } - _scope.addEventProcessor(event => { - addExceptionMechanism(event, { type: 'middleware', handled: false }); - return event; - }); - - const eventId = captureException(error); + const eventId = captureException(error, { mechanism: { type: 'middleware', handled: false } }); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access (res as any).sentry = eventId; next(error); @@ -367,16 +361,7 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { function handleErrorCase(e: unknown): void { if (shouldCaptureError(e)) { - captureException(e, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - return scope; - }); + captureException(e, { mechanism: { handled: false } }); } } diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 66006b06d7b6..075b5f8b00bf 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -1,6 +1,6 @@ import type { ReportDialogOptions, Scope } from '@sentry/browser'; import { captureException, getCurrentHub, showReportDialog, withScope } from '@sentry/browser'; -import { addExceptionMechanism, isError, logger } from '@sentry/utils'; +import { isError, logger } from '@sentry/utils'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; @@ -139,12 +139,12 @@ class ErrorBoundary extends React.Component { - addExceptionMechanism(event, { handled: false }) - return event; - }) - - const eventId = captureException(error, { contexts: { react: { componentStack } } }); + const eventId = captureException(error, { + captureContext: { + contexts: { react: { componentStack } }, + }, + mechanism: { handled: false }, + }); if (onError) { onError(error, componentStack, eventId); diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index 33486043f675..52f71552f703 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -238,7 +238,10 @@ describe('ErrorBoundary', () => { expect(mockCaptureException).toHaveBeenCalledTimes(1); expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), { - contexts: { react: { componentStack: expect.any(String) } }, + captureContext: { + contexts: { react: { componentStack: expect.any(String) } }, + }, + mechanism: { handled: false }, }); expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]); @@ -246,7 +249,7 @@ describe('ErrorBoundary', () => { // Check if error.cause -> react component stack const error = mockCaptureException.mock.calls[0][0]; const cause = error.cause; - expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack); + expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].captureContext.contexts.react.componentStack); expect(cause.name).toContain('React ErrorBoundary'); expect(cause.message).toEqual(error.message); }); @@ -293,7 +296,10 @@ describe('ErrorBoundary', () => { expect(mockCaptureException).toHaveBeenCalledTimes(1); expect(mockCaptureException).toHaveBeenLastCalledWith('bam', { - contexts: { react: { componentStack: expect.any(String) } }, + captureContext: { + contexts: { react: { componentStack: expect.any(String) } }, + }, + mechanism: { handled: false }, }); // Check if error.cause -> react component stack @@ -329,7 +335,10 @@ describe('ErrorBoundary', () => { expect(mockCaptureException).toHaveBeenCalledTimes(1); expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), { - contexts: { react: { componentStack: expect.any(String) } }, + captureContext: { + contexts: { react: { componentStack: expect.any(String) } }, + }, + mechanism: { handled: false }, }); expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]); @@ -338,7 +347,7 @@ describe('ErrorBoundary', () => { const secondError = thirdError.cause; const firstError = secondError.cause; const cause = firstError.cause; - expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack); + expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].captureContext.contexts.react.componentStack); expect(cause.name).toContain('React ErrorBoundary'); expect(cause.message).toEqual(thirdError.message); }); @@ -370,7 +379,10 @@ describe('ErrorBoundary', () => { expect(mockCaptureException).toHaveBeenCalledTimes(1); expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), { - contexts: { react: { componentStack: expect.any(String) } }, + captureContext: { + contexts: { react: { componentStack: expect.any(String) } }, + }, + mechanism: { handled: false }, }); expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]); @@ -378,7 +390,9 @@ describe('ErrorBoundary', () => { const error = mockCaptureException.mock.calls[0][0]; const cause = error.cause; // We need to make sure that recursive error.cause does not cause infinite loop - expect(cause.stack).not.toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack); + expect(cause.stack).not.toEqual( + mockCaptureException.mock.calls[0][1].captureContext.contexts.react.componentStack, + ); expect(cause.name).not.toContain('React ErrorBoundary'); }); diff --git a/packages/remix/src/client/errors.tsx b/packages/remix/src/client/errors.tsx index 6732ed5cdc91..a6afefbc0ef7 100644 --- a/packages/remix/src/client/errors.tsx +++ b/packages/remix/src/client/errors.tsx @@ -1,7 +1,8 @@ -import { captureException, withScope } from '@sentry/core'; -import { addExceptionMechanism, isNodeEnv, isString } from '@sentry/utils'; +import { captureException } from '@sentry/core'; +import { isNodeEnv, isString } from '@sentry/utils'; import { isRouteErrorResponse } from '../utils/vendor/response'; +import type { ErrorResponse } from '../utils/vendor/types'; /** * Captures an error that is thrown inside a Remix ErrorBoundary. @@ -28,29 +29,26 @@ export function captureRemixErrorBoundaryError(error: unknown): string | undefin function: 'ReactError', }; - withScope(scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'instrument', - handled: false, - data: eventData, - }); - return event; - }); - - if (isRemixErrorResponse) { - if (isString(error.data)) { - eventId = captureException(error.data); - } else if (error.statusText) { - eventId = captureException(error.statusText); - } else { - eventId = captureException(error); - } - } else { - eventId = captureException(error); - } + const actualError = isRemixErrorResponse ? getExceptionToCapture(error) : error; + eventId = captureException(actualError, { + mechanism: { + type: 'instrument', + handled: false, + data: eventData, + }, }); } return eventId; } + +function getExceptionToCapture(error: ErrorResponse): string | ErrorResponse { + if (isString(error.data)) { + return error.data; + } + if (error.statusText) { + return error.statusText; + } + + return error; +} diff --git a/packages/sveltekit/src/client/handleError.ts b/packages/sveltekit/src/client/handleError.ts index 5b24c2ef9357..6c8a30fb1e03 100644 --- a/packages/sveltekit/src/client/handleError.ts +++ b/packages/sveltekit/src/client/handleError.ts @@ -1,5 +1,4 @@ import { captureException } from '@sentry/svelte'; -import { addExceptionMechanism } from '@sentry/utils'; // For now disable the import/no-unresolved rule, because we don't have a way to // tell eslint that we are only importing types from the @sveltejs/kit package without // adding a custom resolver, which will take too much time. @@ -20,15 +19,11 @@ function defaultErrorHandler({ error }: Parameters[0]): Retur */ export function handleErrorWithSentry(handleError: HandleClientError = defaultErrorHandler): HandleClientError { return (input: { error: unknown; event: NavigationEvent }): ReturnType => { - captureException(input.error, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'sveltekit', - handled: false, - }); - return event; - }); - return scope; + captureException(input.error, { + mechanism: { + type: 'sveltekit', + handled: false, + }, }); return handleError(input); diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index 93d835dd72d1..ebc21c35eaf0 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -1,6 +1,6 @@ import { trace } from '@sentry/core'; import { captureException } from '@sentry/svelte'; -import { addExceptionMechanism, addNonEnumerableProperty, objectify } from '@sentry/utils'; +import { addNonEnumerableProperty, objectify } from '@sentry/utils'; import type { LoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; @@ -18,19 +18,14 @@ function sendErrorToSentry(e: unknown): unknown { return objectifiedErr; } - captureException(objectifiedErr, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'sveltekit', - handled: false, - data: { - function: 'load', - }, - }); - return event; - }); - - return scope; + captureException(objectifiedErr, { + mechanism: { + type: 'sveltekit', + handled: false, + data: { + function: 'load', + }, + }, }); return objectifiedErr; diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 5076710970a8..3b16f659f6e0 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -2,7 +2,7 @@ import type { Span } from '@sentry/core'; import { getActiveTransaction, getCurrentHub, runWithAsyncContext, startSpan } from '@sentry/core'; import { captureException } from '@sentry/node'; -import { addExceptionMechanism, dynamicSamplingContextToSentryBaggageHeader, objectify } from '@sentry/utils'; +import { dynamicSamplingContextToSentryBaggageHeader, objectify } from '@sentry/utils'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { isHttpError, isRedirect } from '../common/utils'; @@ -39,19 +39,14 @@ function sendErrorToSentry(e: unknown): unknown { return objectifiedErr; } - captureException(objectifiedErr, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'sveltekit', - handled: false, - data: { - function: 'handle', - }, - }); - return event; - }); - - return scope; + captureException(objectifiedErr, { + mechanism: { + type: 'sveltekit', + handled: false, + data: { + function: 'handle', + }, + }, }); return objectifiedErr; diff --git a/packages/sveltekit/src/server/handleError.ts b/packages/sveltekit/src/server/handleError.ts index 938cbf612e2f..c0f27d181928 100644 --- a/packages/sveltekit/src/server/handleError.ts +++ b/packages/sveltekit/src/server/handleError.ts @@ -1,5 +1,4 @@ import { captureException } from '@sentry/node'; -import { addExceptionMechanism } from '@sentry/utils'; // For now disable the import/no-unresolved rule, because we don't have a way to // tell eslint that we are only importing types from the @sveltejs/kit package without // adding a custom resolver, which will take too much time. @@ -27,15 +26,11 @@ export function handleErrorWithSentry(handleError: HandleServerError = defaultEr return handleError(input); } - captureException(input.error, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'sveltekit', - handled: false, - }); - return event; - }); - return scope; + captureException(input.error, { + mechanism: { + type: 'sveltekit', + handled: false, + }, }); await flushIfServerless(); diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts index e819c434e81b..c902fe4376d6 100644 --- a/packages/sveltekit/src/server/load.ts +++ b/packages/sveltekit/src/server/load.ts @@ -2,7 +2,7 @@ import { getCurrentHub, startSpan } from '@sentry/core'; import { captureException } from '@sentry/node'; import type { TransactionContext } from '@sentry/types'; -import { addExceptionMechanism, addNonEnumerableProperty, objectify } from '@sentry/utils'; +import { addNonEnumerableProperty, objectify } from '@sentry/utils'; import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; @@ -29,19 +29,14 @@ function sendErrorToSentry(e: unknown): unknown { return objectifiedErr; } - captureException(objectifiedErr, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'sveltekit', - handled: false, - data: { - function: 'load', - }, - }); - return event; - }); - - return scope; + captureException(objectifiedErr, { + mechanism: { + type: 'sveltekit', + handled: false, + data: { + function: 'load', + }, + }, }); return objectifiedErr; diff --git a/packages/sveltekit/test/client/handleError.test.ts b/packages/sveltekit/test/client/handleError.test.ts index 4dc2e6658af5..0262f0b1b1cc 100644 --- a/packages/sveltekit/test/client/handleError.test.ts +++ b/packages/sveltekit/test/client/handleError.test.ts @@ -1,33 +1,10 @@ -import { Scope } from '@sentry/svelte'; +import * as SentrySvelte from '@sentry/svelte'; import type { HandleClientError, NavigationEvent } from '@sveltejs/kit'; import { vi } from 'vitest'; import { handleErrorWithSentry } from '../../src/client/handleError'; -const mockCaptureException = vi.fn(); -let mockScope = new Scope(); - -vi.mock('@sentry/svelte', async () => { - const original = (await vi.importActual('@sentry/core')) as any; - return { - ...original, - captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { - cb(mockScope); - mockCaptureException(err, cb); - return original.captureException(err, cb); - }, - }; -}); - -const mockAddExceptionMechanism = vi.fn(); - -vi.mock('@sentry/utils', async () => { - const original = (await vi.importActual('@sentry/utils')) as any; - return { - ...original, - addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), - }; -}); +const mockCaptureException = vi.spyOn(SentrySvelte, 'captureException').mockImplementation(() => 'xx'); function handleError(_input: { error: unknown; event: NavigationEvent }): ReturnType { return { @@ -45,14 +22,16 @@ const navigationEvent: NavigationEvent = { url: new URL('http://example.org/users/123'), }; +const captureExceptionEventHint = { + mechanism: { handled: false, type: 'sveltekit' }, +}; + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(_ => {}); describe('handleError', () => { beforeEach(() => { mockCaptureException.mockClear(); - mockAddExceptionMechanism.mockClear(); consoleErrorSpy.mockClear(); - mockScope = new Scope(); }); describe('calls captureException', () => { @@ -63,7 +42,7 @@ describe('handleError', () => { expect(returnVal).not.toBeDefined(); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, expect.any(Function)); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); // The default handler logs the error to the console expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); @@ -75,24 +54,9 @@ describe('handleError', () => { expect(returnVal.message).toEqual('Whoops!'); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, expect.any(Function)); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); // Check that the default handler wasn't invoked expect(consoleErrorSpy).toHaveBeenCalledTimes(0); }); }); - - it('adds an exception mechanism', async () => { - const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); - return mockScope; - }); - - const wrappedHandleError = handleErrorWithSentry(handleError); - const mockError = new Error('test'); - await wrappedHandleError({ error: mockError, event: navigationEvent }); - - expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledWith({}, { handled: false, type: 'sveltekit' }); - }); }); diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index 64b5ef70fe3b..bd6e38fa7a2f 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -1,24 +1,11 @@ -import { addTracingExtensions, Scope } from '@sentry/svelte'; +import * as SentrySvelte from '@sentry/svelte'; import type { Load } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { vi } from 'vitest'; import { wrapLoadWithSentry } from '../../src/client/load'; -const mockCaptureException = vi.fn(); -let mockScope = new Scope(); - -vi.mock('@sentry/svelte', async () => { - const original = (await vi.importActual('@sentry/svelte')) as any; - return { - ...original, - captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { - cb(mockScope); - mockCaptureException(err, cb); - return original.captureException(err, cb); - }, - }; -}); +const mockCaptureException = vi.spyOn(SentrySvelte, 'captureException').mockImplementation(() => 'xx'); const mockTrace = vi.fn(); @@ -33,16 +20,6 @@ vi.mock('@sentry/core', async () => { }; }); -const mockAddExceptionMechanism = vi.fn(); - -vi.mock('@sentry/utils', async () => { - const original = (await vi.importActual('@sentry/utils')) as any; - return { - ...original, - addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), - }; -}); - function getById(_id?: string) { throw new Error('error'); } @@ -56,15 +33,13 @@ const MOCK_LOAD_ARGS: any = { }; beforeAll(() => { - addTracingExtensions(); + SentrySvelte.addTracingExtensions(); }); describe('wrapLoadWithSentry', () => { beforeEach(() => { mockCaptureException.mockClear(); - mockAddExceptionMechanism.mockClear(); mockTrace.mockClear(); - mockScope = new Scope(); }); it('calls captureException', async () => { @@ -151,11 +126,6 @@ describe('wrapLoadWithSentry', () => { }); it('adds an exception mechanism', async () => { - const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); - return mockScope; - }); - async function load({ params }: Parameters[0]): Promise> { return { post: getById(params.id), @@ -166,12 +136,10 @@ describe('wrapLoadWithSentry', () => { const res = wrappedLoad(MOCK_LOAD_ARGS); await expect(res).rejects.toThrow(); - expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledWith( - {}, - { handled: false, type: 'sveltekit', data: { function: 'load' } }, - ); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'sveltekit', data: { function: 'load' } }, + }); }); it("doesn't wrap load more than once if the wrapper was applied multiple times", async () => { diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index 23528dcf6870..2c726127a43b 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -1,5 +1,6 @@ -import { addTracingExtensions, Hub, makeMain, Scope } from '@sentry/core'; +import { addTracingExtensions, Hub, makeMain } from '@sentry/core'; import { NodeClient } from '@sentry/node'; +import * as SentryNode from '@sentry/node'; import type { Transaction } from '@sentry/types'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; @@ -8,30 +9,7 @@ import { vi } from 'vitest'; import { sentryHandle, transformPageChunk } from '../../src/server/handle'; import { getDefaultNodeClientOptions } from '../utils'; -const mockCaptureException = vi.fn(); -let mockScope = new Scope(); - -vi.mock('@sentry/node', async () => { - const original = (await vi.importActual('@sentry/node')) as any; - return { - ...original, - captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { - cb(mockScope); - mockCaptureException(err, cb); - return original.captureException(err, cb); - }, - }; -}); - -const mockAddExceptionMechanism = vi.fn(); - -vi.mock('@sentry/utils', async () => { - const original = (await vi.importActual('@sentry/utils')) as any; - return { - ...original, - addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), - }; -}); +const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx'); function mockEvent(override: Record = {}): Parameters[0]['event'] { const event: Parameters[0]['event'] = { @@ -111,14 +89,12 @@ beforeAll(() => { }); beforeEach(() => { - mockScope = new Scope(); const options = getDefaultNodeClientOptions({ tracesSampleRate: 1.0 }); client = new NodeClient(options); hub = new Hub(client); makeMain(hub); mockCaptureException.mockClear(); - mockAddExceptionMechanism.mockClear(); }); describe('handleSentry', () => { @@ -286,21 +262,13 @@ describe('handleSentry', () => { }); it('send errors to Sentry', async () => { - const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); - return mockScope; - }); - try { await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); } catch (e) { expect(mockCaptureException).toBeCalledTimes(1); - expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(2); - expect(mockAddExceptionMechanism).toBeCalledWith( - {}, - { handled: false, type: 'sveltekit', data: { function: 'handle' } }, - ); + expect(mockCaptureException).toBeCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'sveltekit', data: { function: 'handle' } }, + }); } }); diff --git a/packages/sveltekit/test/server/handleError.test.ts b/packages/sveltekit/test/server/handleError.test.ts index 12ecb83b44e6..157108a8b68a 100644 --- a/packages/sveltekit/test/server/handleError.test.ts +++ b/packages/sveltekit/test/server/handleError.test.ts @@ -1,33 +1,14 @@ -import { Scope } from '@sentry/node'; +import * as SentryNode from '@sentry/node'; import type { HandleServerError, RequestEvent } from '@sveltejs/kit'; import { vi } from 'vitest'; import { handleErrorWithSentry } from '../../src/server/handleError'; -const mockCaptureException = vi.fn(); -let mockScope = new Scope(); +const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx'); -vi.mock('@sentry/node', async () => { - const original = (await vi.importActual('@sentry/node')) as any; - return { - ...original, - captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { - cb(mockScope); - mockCaptureException(err, cb); - return original.captureException(err, cb); - }, - }; -}); - -const mockAddExceptionMechanism = vi.fn(); - -vi.mock('@sentry/utils', async () => { - const original = (await vi.importActual('@sentry/utils')) as any; - return { - ...original, - addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), - }; -}); +const captureExceptionEventHint = { + mechanism: { handled: false, type: 'sveltekit' }, +}; function handleError(_input: { error: unknown; event: RequestEvent }): ReturnType { return { @@ -42,9 +23,7 @@ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(_ => {}); describe('handleError', () => { beforeEach(() => { mockCaptureException.mockClear(); - mockAddExceptionMechanism.mockClear(); consoleErrorSpy.mockClear(); - mockScope = new Scope(); }); it('doesn\'t capture "Not found" errors for incorrect navigations', async () => { @@ -60,7 +39,6 @@ describe('handleError', () => { expect(returnVal).not.toBeDefined(); expect(mockCaptureException).toHaveBeenCalledTimes(0); - expect(mockAddExceptionMechanism).toHaveBeenCalledTimes(0); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); @@ -72,7 +50,7 @@ describe('handleError', () => { expect(returnVal).not.toBeDefined(); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, expect.any(Function)); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); // The default handler logs the error to the console expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); @@ -84,24 +62,9 @@ describe('handleError', () => { expect(returnVal.message).toEqual('Whoops!'); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, expect.any(Function)); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); // Check that the default handler wasn't invoked expect(consoleErrorSpy).toHaveBeenCalledTimes(0); }); }); - - it('adds an exception mechanism', async () => { - const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); - return mockScope; - }); - - const wrappedHandleError = handleErrorWithSentry(handleError); - const mockError = new Error('test'); - await wrappedHandleError({ error: mockError, event: requestEvent }); - - expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledWith({}, { handled: false, type: 'sveltekit' }); - }); }); diff --git a/packages/sveltekit/test/server/load.test.ts b/packages/sveltekit/test/server/load.test.ts index e68e075c7ebd..6b86ca6b32f6 100644 --- a/packages/sveltekit/test/server/load.test.ts +++ b/packages/sveltekit/test/server/load.test.ts @@ -1,25 +1,12 @@ import { addTracingExtensions } from '@sentry/core'; -import { Scope } from '@sentry/node'; +import * as SentryNode from '@sentry/node'; import type { Load, ServerLoad } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit'; import { vi } from 'vitest'; import { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../../src/server/load'; -const mockCaptureException = vi.fn(); -let mockScope = new Scope(); - -vi.mock('@sentry/node', async () => { - const original = (await vi.importActual('@sentry/node')) as any; - return { - ...original, - captureException: (err: unknown, cb: (arg0: unknown) => unknown) => { - cb(mockScope); - mockCaptureException(err, cb); - return original.captureException(err, cb); - }, - }; -}); +const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx'); const mockStartSpan = vi.fn(); @@ -34,18 +21,6 @@ vi.mock('@sentry/core', async () => { }; }); -const mockAddExceptionMechanism = vi.fn((_e, _m) => {}); - -vi.mock('@sentry/utils', async () => { - const original = (await vi.importActual('@sentry/utils')) as any; - return { - ...original, - addExceptionMechanism: (...args: unknown[]) => { - return mockAddExceptionMechanism(args[0], args[1]); - }, - }; -}); - function getById(_id?: string) { throw new Error('error'); } @@ -131,9 +106,7 @@ beforeAll(() => { afterEach(() => { mockCaptureException.mockClear(); - mockAddExceptionMechanism.mockClear(); mockStartSpan.mockClear(); - mockScope = new Scope(); }); describe.each([ @@ -194,11 +167,6 @@ describe.each([ }); it('adds an exception mechanism', async () => { - const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); - return mockScope; - }); - async function load({ params }) { return { post: getById(params.id), @@ -209,12 +177,10 @@ describe.each([ const res = wrappedLoad(getServerOnlyArgs()); await expect(res).rejects.toThrow(); - expect(addEventProcessorSpy).toHaveBeenCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledWith( - {}, - { handled: false, type: 'sveltekit', data: { function: 'load' } }, - ); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'sveltekit', data: { function: 'load' } }, + }); }); }); describe('wrapLoadWithSentry calls trace', () => { diff --git a/packages/types/src/event.ts b/packages/types/src/event.ts index 55207f89337d..f04386968280 100644 --- a/packages/types/src/event.ts +++ b/packages/types/src/event.ts @@ -5,6 +5,7 @@ import type { DebugMeta } from './debugMeta'; import type { Exception } from './exception'; import type { Extras } from './extra'; import type { Measurements } from './measurement'; +import type { Mechanism } from './mechanism'; import type { Primitive } from './misc'; import type { Request } from './request'; import type { CaptureContext } from './scope'; @@ -74,6 +75,7 @@ export interface TransactionEvent extends Event { export interface EventHint { event_id?: string; captureContext?: CaptureContext; + mechanism?: Partial; syntheticException?: Error | null; originalException?: unknown; attachments?: Attachment[]; diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 900bed5a5074..40a950ae9374 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -1,5 +1,4 @@ import { getCurrentHub } from '@sentry/browser'; -import { addExceptionMechanism } from '@sentry/utils'; import type { ViewModel, Vue, VueOptions } from './types'; import { formatComponentName, generateComponentTrace } from './vendor/components'; @@ -30,17 +29,9 @@ export const attachErrorHandler = (app: Vue, options: VueOptions): void => { // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time. setTimeout(() => { - getCurrentHub().withScope(scope => { - scope.setContext('vue', metadata); - - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - handled: false, - }); - return event; - }); - - getCurrentHub().captureException(error); + getCurrentHub().captureException(error, { + captureContext: { contexts: { vue: metadata } }, + mechanism: { handled: false }, }); }); diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 2e3fb3476eb1..75f2c573cdda 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,6 +1,5 @@ import { captureException, WINDOW } from '@sentry/browser'; import type { Transaction, TransactionContext, TransactionSource } from '@sentry/types'; -import { addExceptionMechanism } from '@sentry/utils'; import { getActiveTransaction } from './tracing'; @@ -79,16 +78,7 @@ export function vueRouterInstrumentation( }); } - router.onError(error => - captureException(error, scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { handled: false }); - return event; - }); - - return scope; - }), - ); + router.onError(error => captureException(error, { mechanism: { handled: false } })); router.beforeEach((to, from, next) => { // According to docs we could use `from === VueRouter.START_LOCATION` but I couldnt get it working for Vue 2 diff --git a/packages/vue/test/errorHandler.test.ts b/packages/vue/test/errorHandler.test.ts index 9098e7307f28..05727f561297 100644 --- a/packages/vue/test/errorHandler.test.ts +++ b/packages/vue/test/errorHandler.test.ts @@ -390,7 +390,7 @@ const testHarness = ({ const captureExceptionSpy = client.captureException; expect(captureExceptionSpy).toHaveBeenCalledTimes(1); const error = captureExceptionSpy.mock.calls[0][0]; - const contexts = captureExceptionSpy.mock.calls[0][2]._contexts; + const contexts = captureExceptionSpy.mock.calls[0][1].captureContext.contexts; expect(error).toBeInstanceOf(DummyError); diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 6e74c7c51251..da1e962c9645 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -72,8 +72,7 @@ describe('vueRouterInstrumentation()', () => { onErrorCallback(testError); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - // second function is the scope callback - expect(captureExceptionSpy).toHaveBeenCalledWith(testError, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(testError, { mechanism: { handled: false } }); }); it.each([ From f7257a15b3f63544b9af0c9e94c64755920bc57a Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 22 Nov 2023 17:10:13 +0100 Subject: [PATCH 09/21] ref: Hoist `RequestData` integration to `@sentry/core` (#9597) --- packages/core/src/index.ts | 2 + .../src/integrations/requestdata.ts | 12 +- .../test/lib/integrations/requestdata.test.ts | 102 ++++++ packages/node/src/handlers.ts | 2 +- packages/node/src/index.ts | 5 +- packages/node/src/integrations/index.ts | 2 +- packages/node/src/requestDataDeprecated.ts | 5 +- packages/node/src/requestdata.ts | 326 ------------------ .../test/integrations/requestdata.test.ts | 53 +-- packages/node/test/requestdata.test.ts | 10 +- packages/{node => utils}/src/cookie.ts | 0 packages/utils/src/requestdata.ts | 98 +++--- packages/{node => utils}/test/cookie.test.ts | 0 13 files changed, 168 insertions(+), 449 deletions(-) rename packages/{node => core}/src/integrations/requestdata.ts (94%) create mode 100644 packages/core/test/lib/integrations/requestdata.test.ts delete mode 100644 packages/node/src/requestdata.ts rename packages/{node => utils}/src/cookie.ts (100%) rename packages/{node => utils}/test/cookie.test.ts (100%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d2b856a7cb37..e5b271a71fff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export type { ClientClass } from './sdk'; export type { AsyncContextStrategy, Carrier, Layer, RunWithAsyncContextOptions } from './hub'; export type { OfflineStore, OfflineTransportOptions } from './transports/offline'; export type { ServerRuntimeClientOptions } from './server-runtime-client'; +export type { RequestDataIntegrationOptions } from './integrations/requestdata'; export * from './tracing'; export { createEventEnvelope } from './envelope'; @@ -56,6 +57,7 @@ export { hasTracingEnabled } from './utils/hasTracingEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { DEFAULT_ENVIRONMENT } from './constants'; export { ModuleMetadata } from './integrations/metadata'; +export { RequestData } from './integrations/requestdata'; import * as Integrations from './integrations'; export { Integrations }; diff --git a/packages/node/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts similarity index 94% rename from packages/node/src/integrations/requestdata.ts rename to packages/core/src/integrations/requestdata.ts index 5521345a7b98..4481501d8b8c 100644 --- a/packages/node/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,11 +1,6 @@ -// TODO (v8 or v9): Whenever this becomes a default integration for `@sentry/browser`, move this to `@sentry/core`. For -// now, we leave it in `@sentry/integrations` so that it doesn't contribute bytes to our CDN bundles. - import type { Event, EventProcessor, Hub, Integration, PolymorphicRequest, Transaction } from '@sentry/types'; -import { extractPathForTransaction } from '@sentry/utils'; - -import type { AddRequestDataToEventOptions, TransactionNamingScheme } from '../requestdata'; -import { addRequestDataToEvent } from '../requestdata'; +import type { AddRequestDataToEventOptions, TransactionNamingScheme } from '@sentry/utils'; +import { addRequestDataToEvent, extractPathForTransaction } from '@sentry/utils'; export type RequestDataIntegrationOptions = { /** @@ -59,7 +54,7 @@ export class RequestData implements Integration { /** * @inheritDoc */ - public name: string = RequestData.id; + public name: string; /** * Function for adding request data to event. Defaults to `addRequestDataToEvent` from `@sentry/node` for now, but @@ -74,6 +69,7 @@ export class RequestData implements Integration { * @inheritDoc */ public constructor(options: RequestDataIntegrationOptions = {}) { + this.name = RequestData.id; this._addRequestData = addRequestDataToEvent; this._options = { ...DEFAULT_OPTIONS, diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts new file mode 100644 index 000000000000..bfa6a3caf62d --- /dev/null +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -0,0 +1,102 @@ +import type { RequestDataIntegrationOptions } from '@sentry/core'; +import { getCurrentHub, Hub, makeMain, RequestData } from '@sentry/core'; +import type { Event, EventProcessor } from '@sentry/types'; +import * as sentryUtils from '@sentry/utils'; +import type { IncomingMessage } from 'http'; + +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const addRequestDataToEventSpy = jest.spyOn(sentryUtils, 'addRequestDataToEvent'); +const requestDataEventProcessor = jest.fn(); + +const headers = { ears: 'furry', nose: 'wet', tongue: 'spotted', cookie: 'favorite=zukes' }; +const method = 'wagging'; +const protocol = 'mutualsniffing'; +const hostname = 'the.dog.park'; +const path = '/by/the/trees/'; +const queryString = 'chase=me&please=thankyou'; + +function initWithRequestDataIntegrationOptions(integrationOptions: RequestDataIntegrationOptions): void { + const setMockEventProcessor = (eventProcessor: EventProcessor) => + requestDataEventProcessor.mockImplementationOnce(eventProcessor); + + const requestDataIntegration = new RequestData({ + ...integrationOptions, + }); + + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', + integrations: [requestDataIntegration], + }), + ); + client.setupIntegrations = () => requestDataIntegration.setupOnce(setMockEventProcessor, getCurrentHub); + client.getIntegration = () => requestDataIntegration as any; + + const hub = new Hub(client); + + makeMain(hub); +} + +describe('`RequestData` integration', () => { + let req: IncomingMessage, event: Event; + + beforeEach(() => { + req = { + headers, + method, + protocol, + hostname, + originalUrl: `${path}?${queryString}`, + } as unknown as IncomingMessage; + event = { sdkProcessingMetadata: { request: req } }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('option conversion', () => { + it('leaves `ip` and `user` at top level of `include`', () => { + initWithRequestDataIntegrationOptions({ include: { ip: false, user: true } }); + + requestDataEventProcessor(event); + + const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; + + expect(passedOptions?.include).toEqual(expect.objectContaining({ ip: false, user: true })); + }); + + it('moves `transactionNamingScheme` to `transaction` include', () => { + initWithRequestDataIntegrationOptions({ transactionNamingScheme: 'path' }); + + requestDataEventProcessor(event); + + const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; + + expect(passedOptions?.include).toEqual(expect.objectContaining({ transaction: 'path' })); + }); + + it('moves `true` request keys into `request` include, but omits `false` ones', async () => { + initWithRequestDataIntegrationOptions({ include: { data: true, cookies: false } }); + + requestDataEventProcessor(event); + + const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; + + expect(passedOptions?.include?.request).toEqual(expect.arrayContaining(['data'])); + expect(passedOptions?.include?.request).not.toEqual(expect.arrayContaining(['cookies'])); + }); + + it('moves `true` user keys into `user` include, but omits `false` ones', async () => { + initWithRequestDataIntegrationOptions({ include: { user: { id: true, email: false } } }); + + requestDataEventProcessor(event); + + const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; + + expect(passedOptions?.include?.user).toEqual(expect.arrayContaining(['id'])); + expect(passedOptions?.include?.user).not.toEqual(expect.arrayContaining(['email'])); + }); + }); +}); diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index df438276231e..66ecf304b9a6 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -15,6 +15,7 @@ import { addRequestDataToTransaction, dropUndefinedKeys, extractPathForTransaction, + extractRequestData, isString, isThenable, logger, @@ -23,7 +24,6 @@ import { import type * as http from 'http'; import type { NodeClient } from './client'; -import { extractRequestData } from './requestdata'; // TODO (v8 / XXX) Remove this import import type { ParseRequestOptions } from './requestDataDeprecated'; import { isAutoSessionTrackingEnabled } from './sdk'; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3c4a28489aa8..04aba567cf2c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -18,9 +18,8 @@ export type { Transaction, User, } from '@sentry/types'; -export type { AddRequestDataToEventOptions } from '@sentry/utils'; +export type { AddRequestDataToEventOptions, TransactionNamingScheme } from '@sentry/utils'; -export type { TransactionNamingScheme } from './requestdata'; export type { NodeOptions } from './types'; export { @@ -72,7 +71,7 @@ export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing'; export { NodeClient } from './client'; export { makeNodeTransport } from './transports'; export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from './sdk'; -export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from './requestdata'; +export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; export { enableAnrDetection } from './anr'; diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 62e7b58e85b2..8597ffc00a4a 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -5,6 +5,6 @@ export { OnUnhandledRejection } from './onunhandledrejection'; export { Modules } from './modules'; export { ContextLines } from './contextlines'; export { Context } from './context'; -export { RequestData } from './requestdata'; +export { RequestData } from '@sentry/core'; export { LocalVariables } from './localvariables'; export { Undici } from './undici'; diff --git a/packages/node/src/requestDataDeprecated.ts b/packages/node/src/requestDataDeprecated.ts index 2a45e71f8e47..74e0a9c98666 100644 --- a/packages/node/src/requestDataDeprecated.ts +++ b/packages/node/src/requestDataDeprecated.ts @@ -7,9 +7,8 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Event, ExtractedNodeRequestData, PolymorphicRequest } from '@sentry/types'; - -import type { AddRequestDataToEventOptions } from './requestdata'; -import { addRequestDataToEvent, extractRequestData as _extractRequestData } from './requestdata'; +import type { AddRequestDataToEventOptions } from '@sentry/utils'; +import { addRequestDataToEvent, extractRequestData as _extractRequestData } from '@sentry/utils'; /** * @deprecated `Handlers.ExpressRequest` is deprecated and will be removed in v8. Use `PolymorphicRequest` instead. diff --git a/packages/node/src/requestdata.ts b/packages/node/src/requestdata.ts deleted file mode 100644 index 4d464ba13825..000000000000 --- a/packages/node/src/requestdata.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { - Event, - ExtractedNodeRequestData, - PolymorphicRequest, - Transaction, - TransactionSource, -} from '@sentry/types'; -import { isPlainObject, isString, normalize, stripUrlQueryAndFragment } from '@sentry/utils'; -import * as url from 'url'; - -import { parseCookie } from './cookie'; - -const DEFAULT_INCLUDES = { - ip: false, - request: true, - transaction: true, - user: true, -}; -const DEFAULT_REQUEST_INCLUDES = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; -export const DEFAULT_USER_INCLUDES = ['id', 'username', 'email']; - -/** - * Options deciding what parts of the request to use when enhancing an event - */ -export type AddRequestDataToEventOptions = { - /** Flags controlling whether each type of data should be added to the event */ - include?: { - ip?: boolean; - request?: boolean | Array<(typeof DEFAULT_REQUEST_INCLUDES)[number]>; - transaction?: boolean | TransactionNamingScheme; - user?: boolean | Array<(typeof DEFAULT_USER_INCLUDES)[number]>; - }; -}; - -export type TransactionNamingScheme = 'path' | 'methodPath' | 'handler'; - -/** - * Sets parameterized route as transaction name e.g.: `GET /users/:id` - * Also adds more context data on the transaction from the request - */ -export function addRequestDataToTransaction(transaction: Transaction | undefined, req: PolymorphicRequest): void { - if (!transaction) return; - if (!transaction.metadata.source || transaction.metadata.source === 'url') { - // Attempt to grab a parameterized route off of the request - transaction.setName(...extractPathForTransaction(req, { path: true, method: true })); - } - transaction.setData('url', req.originalUrl || req.url); - if (req.baseUrl) { - transaction.setData('baseUrl', req.baseUrl); - } - transaction.setData('query', extractQueryParams(req)); -} - -/** - * Extracts a complete and parameterized path from the request object and uses it to construct transaction name. - * If the parameterized transaction name cannot be extracted, we fall back to the raw URL. - * - * Additionally, this function determines and returns the transaction name source - * - * eg. GET /mountpoint/user/:id - * - * @param req A request object - * @param options What to include in the transaction name (method, path, or a custom route name to be - * used instead of the request's route) - * - * @returns A tuple of the fully constructed transaction name [0] and its source [1] (can be either 'route' or 'url') - */ -export function extractPathForTransaction( - req: PolymorphicRequest, - options: { path?: boolean; method?: boolean; customRoute?: string } = {}, -): [string, TransactionSource] { - const method = req.method && req.method.toUpperCase(); - - let path = ''; - let source: TransactionSource = 'url'; - - // Check to see if there's a parameterized route we can use (as there is in Express) - if (options.customRoute || req.route) { - path = options.customRoute || `${req.baseUrl || ''}${req.route && req.route.path}`; - source = 'route'; - } - - // Otherwise, just take the original URL - else if (req.originalUrl || req.url) { - path = stripUrlQueryAndFragment(req.originalUrl || req.url || ''); - } - - let name = ''; - if (options.method && method) { - name += method; - } - if (options.method && options.path) { - name += ' '; - } - if (options.path && path) { - name += path; - } - - return [name, source]; -} - -/** JSDoc */ -function extractTransaction(req: PolymorphicRequest, type: boolean | TransactionNamingScheme): string { - switch (type) { - case 'path': { - return extractPathForTransaction(req, { path: true })[0]; - } - case 'handler': { - return (req.route && req.route.stack && req.route.stack[0] && req.route.stack[0].name) || ''; - } - case 'methodPath': - default: { - // if exist _reconstructedRoute return that path instead of route.path - const customRoute = req._reconstructedRoute ? req._reconstructedRoute : undefined; - return extractPathForTransaction(req, { path: true, method: true, customRoute })[0]; - } - } -} - -/** JSDoc */ -function extractUserData( - user: { - [key: string]: unknown; - }, - keys: boolean | string[], -): { [key: string]: unknown } { - const extractedUser: { [key: string]: unknown } = {}; - const attributes = Array.isArray(keys) ? keys : DEFAULT_USER_INCLUDES; - - attributes.forEach(key => { - if (user && key in user) { - extractedUser[key] = user[key]; - } - }); - - return extractedUser; -} - -/** - * Normalize data from the request object - * - * @param req The request object from which to extract data - * @param options.include An optional array of keys to include in the normalized data. Defaults to - * DEFAULT_REQUEST_INCLUDES if not provided. - * @param options.deps Injected, platform-specific dependencies - * - * @returns An object containing normalized request data - */ -export function extractRequestData( - req: PolymorphicRequest, - options?: { - include?: string[]; - }, -): ExtractedNodeRequestData { - const { include = DEFAULT_REQUEST_INCLUDES } = options || {}; - const requestData: { [key: string]: unknown } = {}; - - // headers: - // node, express, koa, nextjs: req.headers - const headers = (req.headers || {}) as { - host?: string; - cookie?: string; - }; - // method: - // node, express, koa, nextjs: req.method - const method = req.method; - // host: - // express: req.hostname in > 4 and req.host in < 4 - // koa: req.host - // node, nextjs: req.headers.host - const host = req.hostname || req.host || headers.host || ''; - // protocol: - // node, nextjs: - // express, koa: req.protocol - const protocol = req.protocol === 'https' || (req.socket && req.socket.encrypted) ? 'https' : 'http'; - // url (including path and query string): - // node, express: req.originalUrl - // koa, nextjs: req.url - const originalUrl = req.originalUrl || req.url || ''; - // absolute url - const absoluteUrl = originalUrl.startsWith(protocol) ? originalUrl : `${protocol}://${host}${originalUrl}`; - include.forEach(key => { - switch (key) { - case 'headers': { - requestData.headers = headers; - - // Remove the Cookie header in case cookie data should not be included in the event - if (!include.includes('cookies')) { - delete (requestData.headers as { cookie?: string }).cookie; - } - - break; - } - case 'method': { - requestData.method = method; - break; - } - case 'url': { - requestData.url = absoluteUrl; - break; - } - case 'cookies': { - // cookies: - // node, express, koa: req.headers.cookie - // vercel, sails.js, express (w/ cookie middleware), nextjs: req.cookies - requestData.cookies = - // TODO (v8 / #5257): We're only sending the empty object for backwards compatibility, so the last bit can - // come off in v8 - req.cookies || (headers.cookie && parseCookie(headers.cookie)) || {}; - break; - } - case 'query_string': { - // query string: - // node: req.url (raw) - // express, koa, nextjs: req.query - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - requestData.query_string = extractQueryParams(req); - break; - } - case 'data': { - if (method === 'GET' || method === 'HEAD') { - break; - } - // body data: - // express, koa, nextjs: req.body - // - // when using node by itself, you have to read the incoming stream(see - // https://nodejs.dev/learn/get-http-request-body-data-using-nodejs); if a user is doing that, we can't know - // where they're going to store the final result, so they'll have to capture this data themselves - if (req.body !== undefined) { - requestData.data = isString(req.body) ? req.body : JSON.stringify(normalize(req.body)); - } - break; - } - default: { - if ({}.hasOwnProperty.call(req, key)) { - requestData[key] = (req as { [key: string]: unknown })[key]; - } - } - } - }); - - return requestData; -} - -/** - * Add data from the given request to the given event - * - * @param event The event to which the request data will be added - * @param req Request object - * @param options.include Flags to control what data is included - * - * @returns The mutated `Event` object - */ -export function addRequestDataToEvent( - event: Event, - req: PolymorphicRequest, - options?: AddRequestDataToEventOptions, -): Event { - const include = { - ...DEFAULT_INCLUDES, - ...options?.include, - }; - - if (include.request) { - const extractedRequestData = Array.isArray(include.request) - ? extractRequestData(req, { include: include.request }) - : extractRequestData(req); - - event.request = { - ...event.request, - ...extractedRequestData, - }; - } - - if (include.user) { - const extractedUser = req.user && isPlainObject(req.user) ? extractUserData(req.user, include.user) : {}; - - if (Object.keys(extractedUser).length) { - event.user = { - ...event.user, - ...extractedUser, - }; - } - } - - // client ip: - // node, nextjs: req.socket.remoteAddress - // express, koa: req.ip - if (include.ip) { - const ip = req.ip || (req.socket && req.socket.remoteAddress); - if (ip) { - event.user = { - ...event.user, - ip_address: ip, - }; - } - } - - if (include.transaction && !event.transaction) { - // TODO do we even need this anymore? - // TODO make this work for nextjs - event.transaction = extractTransaction(req, include.transaction); - } - - return event; -} - -function extractQueryParams(req: PolymorphicRequest): string | Record | undefined { - // url (including path and query string): - // node, express: req.originalUrl - // koa, nextjs: req.url - let originalUrl = req.originalUrl || req.url || ''; - - if (!originalUrl) { - return; - } - - // The `URL` constructor can't handle internal URLs of the form `/some/path/here`, so stick a dummy protocol and - // hostname on the beginning. Since the point here is just to grab the query string, it doesn't matter what we use. - if (originalUrl.startsWith('/')) { - originalUrl = `http://dogs.are.great${originalUrl}`; - } - - return req.query || new url.URL(originalUrl).search.replace('?', '') || undefined; -} diff --git a/packages/node/test/integrations/requestdata.test.ts b/packages/node/test/integrations/requestdata.test.ts index 52e20c9d6e4b..4a9adc729488 100644 --- a/packages/node/test/integrations/requestdata.test.ts +++ b/packages/node/test/integrations/requestdata.test.ts @@ -1,15 +1,14 @@ -import { getCurrentHub, Hub, makeMain } from '@sentry/core'; +import type { RequestDataIntegrationOptions } from '@sentry/core'; +import { getCurrentHub, Hub, makeMain, RequestData } from '@sentry/core'; import type { Event, EventProcessor, PolymorphicRequest } from '@sentry/types'; +import * as sentryUtils from '@sentry/utils'; import * as http from 'http'; import { NodeClient } from '../../src/client'; import { requestHandler } from '../../src/handlers'; -import type { RequestDataIntegrationOptions } from '../../src/integrations/requestdata'; -import { RequestData } from '../../src/integrations/requestdata'; -import * as requestDataModule from '../../src/requestdata'; import { getDefaultNodeClientOptions } from '../helper/node-client-options'; -const addRequestDataToEventSpy = jest.spyOn(requestDataModule, 'addRequestDataToEvent'); +const addRequestDataToEventSpy = jest.spyOn(sentryUtils, 'addRequestDataToEvent'); const requestDataEventProcessor = jest.fn(); const headers = { ears: 'furry', nose: 'wet', tongue: 'spotted', cookie: 'favorite=zukes' }; @@ -59,50 +58,6 @@ describe('`RequestData` integration', () => { jest.clearAllMocks(); }); - describe('option conversion', () => { - it('leaves `ip` and `user` at top level of `include`', () => { - initWithRequestDataIntegrationOptions({ include: { ip: false, user: true } }); - - requestDataEventProcessor(event); - - const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; - - expect(passedOptions?.include).toEqual(expect.objectContaining({ ip: false, user: true })); - }); - - it('moves `transactionNamingScheme` to `transaction` include', () => { - initWithRequestDataIntegrationOptions({ transactionNamingScheme: 'path' }); - - requestDataEventProcessor(event); - - const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; - - expect(passedOptions?.include).toEqual(expect.objectContaining({ transaction: 'path' })); - }); - - it('moves `true` request keys into `request` include, but omits `false` ones', async () => { - initWithRequestDataIntegrationOptions({ include: { data: true, cookies: false } }); - - requestDataEventProcessor(event); - - const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; - - expect(passedOptions?.include?.request).toEqual(expect.arrayContaining(['data'])); - expect(passedOptions?.include?.request).not.toEqual(expect.arrayContaining(['cookies'])); - }); - - it('moves `true` user keys into `user` include, but omits `false` ones', async () => { - initWithRequestDataIntegrationOptions({ include: { user: { id: true, email: false } } }); - - requestDataEventProcessor(event); - - const passedOptions = addRequestDataToEventSpy.mock.calls[0][2]; - - expect(passedOptions?.include?.user).toEqual(expect.arrayContaining(['id'])); - expect(passedOptions?.include?.user).not.toEqual(expect.arrayContaining(['email'])); - }); - }); - describe('usage with express request handler and GCP wrapper', () => { it('uses options from Express request handler', async () => { const sentryRequestMiddleware = requestHandler({ include: { transaction: 'methodPath' } }); diff --git a/packages/node/test/requestdata.test.ts b/packages/node/test/requestdata.test.ts index b73b5de2d985..989aa680e2d9 100644 --- a/packages/node/test/requestdata.test.ts +++ b/packages/node/test/requestdata.test.ts @@ -1,16 +1,16 @@ /* eslint-disable deprecation/deprecation */ -// TODO (v8 / #5257): Remove everything related to the deprecated functions +// TODO (v8 / #5257): Remove everything related to the deprecated functions and move tests into `@sentry/utils` import type { Event, PolymorphicRequest, TransactionSource, User } from '@sentry/types'; -import type * as net from 'net'; - -import type { AddRequestDataToEventOptions } from '../src/requestdata'; +import type { AddRequestDataToEventOptions } from '@sentry/utils'; import { addRequestDataToEvent, extractPathForTransaction, extractRequestData as newExtractRequestData, -} from '../src/requestdata'; +} from '@sentry/utils'; +import type * as net from 'net'; + import type { ExpressRequest } from '../src/requestDataDeprecated'; import { extractRequestData as oldExtractRequestData, parseRequest } from '../src/requestDataDeprecated'; diff --git a/packages/node/src/cookie.ts b/packages/utils/src/cookie.ts similarity index 100% rename from packages/node/src/cookie.ts rename to packages/utils/src/cookie.ts diff --git a/packages/utils/src/requestdata.ts b/packages/utils/src/requestdata.ts index 5c8216759025..f5c39292bd54 100644 --- a/packages/utils/src/requestdata.ts +++ b/packages/utils/src/requestdata.ts @@ -1,19 +1,3 @@ -// TODO: Remove this file once equivalent integration is used everywhere - -/* eslint-disable complexity */ -/** - * The functions here, which enrich an event with request data, are mostly for use in Node, but are safe for use in a - * browser context. They live here in `@sentry/utils` rather than in `@sentry/node` so that they can be used in - * frameworks (like nextjs), which, because of SSR, run the same code in both Node and browser contexts. - * - * TODO (v8 / #5257): Remove the note below - * Note that for now, the tests for this code have to live in `@sentry/node`, since they test both these functions and - * the backwards-compatibility-preserving wrappers which still live in `handlers.ts` there. - */ - -/* eslint-disable max-lines */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import type { Event, ExtractedNodeRequestData, @@ -22,6 +6,7 @@ import type { TransactionSource, } from '@sentry/types'; +import { parseCookie } from './cookie'; import { isPlainObject, isString } from './is'; import { normalize } from './normalize'; import { stripUrlQueryAndFragment } from './url'; @@ -33,7 +18,7 @@ const DEFAULT_INCLUDES = { user: true, }; const DEFAULT_REQUEST_INCLUDES = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; -const DEFAULT_USER_INCLUDES = ['id', 'username', 'email']; +export const DEFAULT_USER_INCLUDES = ['id', 'username', 'email']; type InjectedNodeDeps = { cookie: { @@ -46,6 +31,33 @@ type InjectedNodeDeps = { }; }; +/** + * Options deciding what parts of the request to use when enhancing an event + */ +export type AddRequestDataToEventOptions = { + /** Flags controlling whether each type of data should be added to the event */ + include?: { + ip?: boolean; + request?: boolean | Array<(typeof DEFAULT_REQUEST_INCLUDES)[number]>; + transaction?: boolean | TransactionNamingScheme; + user?: boolean | Array<(typeof DEFAULT_USER_INCLUDES)[number]>; + }; + + /** Injected platform-specific dependencies */ + deps?: { + cookie: { + parse: (cookieStr: string) => Record; + }; + url: { + parse: (urlStr: string) => { + query: string | null; + }; + }; + }; +}; + +export type TransactionNamingScheme = 'path' | 'methodPath' | 'handler'; + /** * Sets parameterized route as transaction name e.g.: `GET /users/:id` * Also adds more context data on the transaction from the request @@ -115,8 +127,6 @@ export function extractPathForTransaction( return [name, source]; } -type TransactionNamingScheme = 'path' | 'methodPath' | 'handler'; - /** JSDoc */ function extractTransaction(req: PolymorphicRequest, type: boolean | TransactionNamingScheme): string { switch (type) { @@ -128,7 +138,9 @@ function extractTransaction(req: PolymorphicRequest, type: boolean | Transaction } case 'methodPath': default: { - return extractPathForTransaction(req, { path: true, method: true })[0]; + // if exist _reconstructedRoute return that path instead of route.path + const customRoute = req._reconstructedRoute ? req._reconstructedRoute : undefined; + return extractPathForTransaction(req, { path: true, method: true, customRoute })[0]; } } } @@ -136,11 +148,11 @@ function extractTransaction(req: PolymorphicRequest, type: boolean | Transaction /** JSDoc */ function extractUserData( user: { - [key: string]: any; + [key: string]: unknown; }, keys: boolean | string[], -): { [key: string]: any } { - const extractedUser: { [key: string]: any } = {}; +): { [key: string]: unknown } { + const extractedUser: { [key: string]: unknown } = {}; const attributes = Array.isArray(keys) ? keys : DEFAULT_USER_INCLUDES; attributes.forEach(key => { @@ -194,11 +206,17 @@ export function extractRequestData( // koa, nextjs: req.url const originalUrl = req.originalUrl || req.url || ''; // absolute url - const absoluteUrl = `${protocol}://${host}${originalUrl}`; + const absoluteUrl = originalUrl.startsWith(protocol) ? originalUrl : `${protocol}://${host}${originalUrl}`; include.forEach(key => { switch (key) { case 'headers': { requestData.headers = headers; + + // Remove the Cookie header in case cookie data should not be included in the event + if (!include.includes('cookies')) { + delete (requestData.headers as { cookie?: string }).cookie; + } + break; } case 'method': { @@ -213,11 +231,10 @@ export function extractRequestData( // cookies: // node, express, koa: req.headers.cookie // vercel, sails.js, express (w/ cookie middleware), nextjs: req.cookies - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access requestData.cookies = // TODO (v8 / #5257): We're only sending the empty object for backwards compatibility, so the last bit can // come off in v8 - req.cookies || (headers.cookie && deps && deps.cookie && deps.cookie.parse(headers.cookie)) || {}; + req.cookies || (headers.cookie && parseCookie(headers.cookie)) || {}; break; } case 'query_string': { @@ -245,7 +262,7 @@ export function extractRequestData( } default: { if ({}.hasOwnProperty.call(req, key)) { - requestData[key] = (req as { [key: string]: any })[key]; + requestData[key] = (req as { [key: string]: unknown })[key]; } } } @@ -254,31 +271,6 @@ export function extractRequestData( return requestData; } -/** - * Options deciding what parts of the request to use when enhancing an event - */ -export interface AddRequestDataToEventOptions { - /** Flags controlling whether each type of data should be added to the event */ - include?: { - ip?: boolean; - request?: boolean | string[]; - transaction?: boolean | TransactionNamingScheme; - user?: boolean | string[]; - }; - - /** Injected platform-specific dependencies */ - deps?: { - cookie: { - parse: (cookieStr: string) => Record; - }; - url: { - parse: (urlStr: string) => { - query: string | null; - }; - }; - }; -} - /** * Add data from the given request to the given event * @@ -286,7 +278,7 @@ export interface AddRequestDataToEventOptions { * @param req Request object * @param options.include Flags to control what data is included * @param options.deps Injected platform-specific dependencies - * @hidden + * @returns The mutated `Event` object */ export function addRequestDataToEvent( event: Event, diff --git a/packages/node/test/cookie.test.ts b/packages/utils/test/cookie.test.ts similarity index 100% rename from packages/node/test/cookie.test.ts rename to packages/utils/test/cookie.test.ts From 92c9fbb1dd366e25f5d01768b5da338d44cc9e4d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 22 Nov 2023 17:10:32 +0100 Subject: [PATCH 10/21] feat(node): Add Spotlight option to Node SDK (#9629) This PR adds a new top level option called `spotlight` to Node init options. Under the hood, if this option is true, * all integrations will be forcefully initialized . This ensures that without a DSN, we still capture and process events (but simply don't send them to Sentry) * a new `Spotlight` integration is added. This integration will make a `http` post request to the sidecar URL. Either we take the default sidecar URL or users provide their own URL: ```js // enable/disable Sentry.init({ spotlight: process.env.NODE_ENV === "development" }); // enbale by setting a custom URL Sentry.init({ spotlight: process.env.NODE_ENV === "development" ? 'http://localhost:7777' : false }); ``` This option should also work in Node Experimental, given that Node experimental just calls the node init function. --------- Co-authored-by: Lukas Stracke --- packages/node/src/integrations/index.ts | 1 + packages/node/src/integrations/spotlight.ts | 110 ++++++++++++++++ packages/node/src/sdk.ts | 12 ++ packages/node/src/types.ts | 12 ++ .../node/test/integrations/spotlight.test.ts | 121 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 packages/node/src/integrations/spotlight.ts create mode 100644 packages/node/test/integrations/spotlight.test.ts diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 8597ffc00a4a..49820882fdc6 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -8,3 +8,4 @@ export { Context } from './context'; export { RequestData } from '@sentry/core'; export { LocalVariables } from './localvariables'; export { Undici } from './undici'; +export { Spotlight } from './spotlight'; diff --git a/packages/node/src/integrations/spotlight.ts b/packages/node/src/integrations/spotlight.ts new file mode 100644 index 000000000000..4b4b9d907721 --- /dev/null +++ b/packages/node/src/integrations/spotlight.ts @@ -0,0 +1,110 @@ +import type { Client, Integration } from '@sentry/types'; +import { logger, serializeEnvelope } from '@sentry/utils'; +import * as http from 'http'; +import { URL } from 'url'; + +type SpotlightConnectionOptions = { + /** + * Set this if the Spotlight Sidecar is not running on localhost:8969 + * By default, the Url is set to http://localhost:8969 + */ + sidecarUrl?: string; +}; + +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + * + * Important: This integration only works with Node 18 or newer + */ +export class Spotlight implements Integration { + public static id = 'Spotlight'; + public name = Spotlight.id; + + private readonly _options: Required; + + public constructor(options?: SpotlightConnectionOptions) { + this._options = { + sidecarUrl: options?.sidecarUrl || 'http://localhost:8969', + }; + } + + /** + * JSDoc + */ + public setupOnce(): void { + // empty but otherwise TS complains + } + + /** + * Sets up forwarding envelopes to the Spotlight Sidecar + */ + public setup(client: Client): void { + if (process.env.NODE_ENV !== 'development') { + logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spoltight enabled?"); + } + connectToSpotlight(client, this._options); + } +} + +function connectToSpotlight(client: Client, options: Required): void { + const spotlightUrl = parseSidecarUrl(options.sidecarUrl); + if (!spotlightUrl) { + return; + } + + let failedRequests = 0; + + if (typeof client.on !== 'function') { + logger.warn('[Spotlight] Cannot connect to spotlight due to missing method on SDK client (`client.on`)'); + return; + } + + client.on('beforeEnvelope', envelope => { + if (failedRequests > 3) { + logger.warn('[Spotlight] Disabled Sentry -> Spotlight integration due to too many failed requests'); + return; + } + + const serializedEnvelope = serializeEnvelope(envelope); + + const req = http.request( + { + method: 'POST', + path: '/stream', + hostname: spotlightUrl.hostname, + port: spotlightUrl.port, + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + res.setEncoding('utf8'); + }, + ); + + req.on('error', () => { + failedRequests++; + logger.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar'); + }); + req.write(serializedEnvelope); + req.end(); + }); +} + +function parseSidecarUrl(url: string): URL | undefined { + try { + return new URL(`${url}/stream`); + } catch { + logger.warn(`[Spotlight] Invalid sidecar URL: ${url}`); + return undefined; + } +} diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index a01cdfbcae23..af4ce7905fda 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -28,6 +28,7 @@ import { OnUncaughtException, OnUnhandledRejection, RequestData, + Spotlight, Undici, } from './integrations'; import { getModuleFromFilename } from './module'; @@ -179,6 +180,17 @@ export function init(options: NodeOptions = {}): void { } updateScopeFromEnvVariables(); + + if (options.spotlight) { + const client = getCurrentHub().getClient(); + if (client && client.addIntegration) { + // force integrations to be setup even if no DSN was set + client.setupIntegrations(true); + client.addIntegration( + new Spotlight({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined }), + ); + } + } } /** diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index b63c49e440e7..01f91fb46cbe 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -59,6 +59,18 @@ export interface BaseNodeOptions { * */ clientClass?: typeof NodeClient; + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; + // TODO (v8): Remove this in v8 /** * @deprecated Moved to constructor options of the `Http` and `Undici` integration. diff --git a/packages/node/test/integrations/spotlight.test.ts b/packages/node/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..f03b3fb31713 --- /dev/null +++ b/packages/node/test/integrations/spotlight.test.ts @@ -0,0 +1,121 @@ +import type { Envelope, EventEnvelope } from '@sentry/types'; +import { createEnvelope, logger } from '@sentry/utils'; +import * as http from 'http'; + +import { NodeClient } from '../../src'; +import { Spotlight } from '../../src/integrations'; +import { getDefaultNodeClientOptions } from '../helper/node-client-options'; + +describe('Spotlight', () => { + const loggerSpy = jest.spyOn(logger, 'warn'); + + afterEach(() => { + loggerSpy.mockClear(); + jest.clearAllMocks(); + }); + + const options = getDefaultNodeClientOptions(); + const client = new NodeClient(options); + + it('has a name and id', () => { + const integration = new Spotlight(); + expect(integration.name).toEqual('Spotlight'); + expect(Spotlight.id).toEqual('Spotlight'); + }); + + it('registers a callback on the `beforeEnvelope` hook', () => { + const clientWithSpy = { + ...client, + on: jest.fn(), + }; + const integration = new Spotlight(); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + expect(clientWithSpy.on).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); + }); + + it('sends an envelope POST request to the sidecar url', () => { + const httpSpy = jest.spyOn(http, 'request').mockImplementationOnce(() => { + return { + on: jest.fn(), + write: jest.fn(), + end: jest.fn(), + } as any; + }); + + let callback: (envelope: Envelope) => void = () => {}; + const clientWithSpy = { + ...client, + on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)), + }; + + const integration = new Spotlight(); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + + const envelope = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); + + callback(envelope); + + expect(httpSpy).toHaveBeenCalledWith( + { + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + hostname: 'localhost', + method: 'POST', + path: '/stream', + port: '8969', + }, + expect.any(Function), + ); + }); + + describe('no-ops if', () => { + it('an invalid URL is passed', () => { + const integration = new Spotlight({ sidecarUrl: 'invalid-url' }); + integration.setup(client); + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: invalid-url')); + }); + + it("the client doesn't support life cycle hooks", () => { + const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8969' }); + const clientWithoutHooks = { ...client }; + // @ts-expect-error - this is fine in tests + delete client.on; + // @ts-expect-error - this is fine in tests + integration.setup(clientWithoutHooks); + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining(' missing method on SDK client (`client.on`)')); + }); + }); + + it('warns if the NODE_ENV variable doesn\'t equal "development"', () => { + const oldEnvValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); + integration.setup(client); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"), + ); + + process.env.NODE_ENV = oldEnvValue; + }); + + it('doesn\'t warn if the NODE_ENV variable equals "development"', () => { + const oldEnvValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); + integration.setup(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"), + ); + + process.env.NODE_ENV = oldEnvValue; + }); +}); From 692e9c6296cf3f6cb2d5416d8a7e5a41db54a106 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 17:16:39 +0100 Subject: [PATCH 11/21] feat(core): Add top level `getClient()` method (#9638) This can be used instead of `getCurrentHub().getClient()`. This prepares us also for a post-hub time, but makes sense generally IMHO, also simplifies user-facing interaction with the client (e.g. to lazy-add integrations etc). This is a 1-1 replacement, so should be pretty straightforward. --- packages/angular/src/errorhandler.ts | 2 +- packages/angular/test/errorhandler.test.ts | 7 ++----- packages/astro/src/index.server.ts | 1 + packages/astro/test/client/sdk.test.ts | 12 +++++------ packages/browser/src/exports.ts | 1 + .../browser/src/integrations/breadcrumbs.ts | 3 ++- packages/browser/src/profiling/utils.ts | 3 ++- packages/browser/src/sdk.ts | 3 ++- .../browser/test/unit/eventbuilder.test.ts | 13 +++++++----- packages/browser/test/unit/index.test.ts | 21 ++++++++++--------- .../unit/integrations/breadcrumbs.test.ts | 5 ++++- .../test/unit/profiling/integration.test.ts | 4 ++-- packages/bun/src/index.ts | 1 + packages/core/src/baseclient.ts | 2 +- packages/core/src/exports.ts | 12 +++++++++-- packages/core/src/index.ts | 1 + packages/core/src/integration.ts | 3 ++- packages/core/src/utils/hasTracingEnabled.ts | 4 ++-- packages/deno/src/index.ts | 1 + packages/feedback/test/sendFeedback.test.ts | 4 ++-- .../pagesRouterRoutingInstrumentation.ts | 4 ++-- packages/nextjs/src/common/_error.ts | 4 ++-- .../wrapDocumentGetInitialPropsWithSentry.ts | 4 ++-- .../common/wrapGetStaticPropsWithSentry.ts | 4 ++-- packages/node-experimental/src/index.ts | 2 +- .../src/integrations/http.ts | 4 ++-- .../src/integrations/node-fetch.ts | 4 ++-- .../node-experimental/src/sdk/initOtel.ts | 4 ++-- .../src/sdk/spanProcessor.ts | 4 ++-- packages/node/src/anr/index.ts | 4 ++-- packages/node/src/handlers.ts | 3 ++- packages/node/src/index.ts | 1 + .../src/integrations/onuncaughtexception.ts | 4 ++-- .../src/integrations/utils/errorhandling.ts | 4 ++-- packages/node/test/index.test.ts | 16 +++++++------- .../aggregates-disable-single-session.js | 2 +- .../opentelemetry-node/src/spanprocessor.ts | 6 +++--- packages/opentelemetry/src/custom/hub.ts | 5 +++++ packages/opentelemetry/src/index.ts | 2 +- packages/opentelemetry/src/propagator.ts | 4 ++-- packages/opentelemetry/src/trace.ts | 4 ++-- packages/react/src/errorboundary.tsx | 4 ++-- packages/react/src/redux.ts | 4 ++-- packages/react/test/errorboundary.test.tsx | 4 ++-- .../src/coreHandlers/handleAfterSendEvent.ts | 4 ++-- .../coreHandlers/handleNetworkBreadcrumbs.ts | 4 ++-- packages/replay/src/integration.ts | 4 ++-- packages/replay/src/replay.ts | 4 ++-- packages/replay/src/util/addEvent.ts | 4 ++-- .../replay/src/util/addGlobalListeners.ts | 4 ++-- .../coreHandlers/handleAfterSendEvent.test.ts | 20 +++++++++--------- .../test/integration/errorSampleRate.test.ts | 6 +++--- .../replay/test/integration/events.test.ts | 4 ++-- packages/serverless/src/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + packages/sveltekit/test/client/sdk.test.ts | 12 +++++------ packages/vercel-edge/src/index.ts | 1 + 57 files changed, 150 insertions(+), 123 deletions(-) diff --git a/packages/angular/src/errorhandler.ts b/packages/angular/src/errorhandler.ts index 2cc6550c63cb..b776cc4701a3 100644 --- a/packages/angular/src/errorhandler.ts +++ b/packages/angular/src/errorhandler.ts @@ -115,7 +115,7 @@ class SentryErrorHandler implements AngularErrorHandler { // Optionally show user dialog to provide details on what happened. if (this._options.showDialog) { - const client = Sentry.getCurrentHub().getClient(); + const client = Sentry.getClient(); if (client && client.on && !this._registeredAfterSendEventHandler) { client.on('afterSendEvent', (event: Event) => { diff --git a/packages/angular/test/errorhandler.test.ts b/packages/angular/test/errorhandler.test.ts index d298fd72cdee..a4e16a4e9c66 100644 --- a/packages/angular/test/errorhandler.test.ts +++ b/packages/angular/test/errorhandler.test.ts @@ -1,6 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import * as SentryBrowser from '@sentry/browser'; -import type { Event } from '@sentry/types'; +import type { Client, Event } from '@sentry/types'; import { createErrorHandler, SentryErrorHandler } from '../src/errorhandler'; @@ -505,10 +505,7 @@ describe('SentryErrorHandler', () => { }), }; - // @ts-expect-error this is a minmal hub, we're missing a few props but that's ok - jest.spyOn(SentryBrowser, 'getCurrentHub').mockImplementationOnce(() => { - return { getClient: () => client }; - }); + jest.spyOn(SentryBrowser, 'getClient').mockImplementationOnce(() => client as unknown as Client); const showReportDialogSpy = jest.spyOn(SentryBrowser, 'showReportDialog'); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 9dce9689a7eb..94f19936dd54 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -21,6 +21,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, makeMain, Scope, diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts index 74a4dc4562ef..d35c501415c7 100644 --- a/packages/astro/test/client/sdk.test.ts +++ b/packages/astro/test/client/sdk.test.ts @@ -1,6 +1,6 @@ import type { BrowserClient } from '@sentry/browser'; import * as SentryBrowser from '@sentry/browser'; -import { BrowserTracing, getCurrentHub, SDK_VERSION, WINDOW } from '@sentry/browser'; +import { BrowserTracing, getClient, getCurrentHub, SDK_VERSION, WINDOW } from '@sentry/browser'; import { vi } from 'vitest'; import { init } from '../../../astro/src/client/sdk'; @@ -60,7 +60,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = browserInit.mock.calls[0][0]?.integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeDefined(); @@ -76,7 +76,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = browserInit.mock.calls[0][0]?.integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeUndefined(); @@ -91,7 +91,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = browserInit.mock.calls[0][0]?.integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeUndefined(); @@ -108,9 +108,7 @@ describe('Sentry client SDK', () => { const integrationsToInit = browserInit.mock.calls[0][0]?.integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById( - 'BrowserTracing', - ) as BrowserTracing; + const browserTracing = getClient()?.getIntegrationById('BrowserTracing') as BrowserTracing; const options = browserTracing.options; expect(integrationsToInit).toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 3d3aec477731..18b9320eafbd 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -33,6 +33,7 @@ export { flush, getHubFromCarrier, getCurrentHub, + getClient, Hub, lastEventId, makeMain, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index f71361b7d96e..c278240c5786 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -19,6 +19,7 @@ import { severityLevelFromString, } from '@sentry/utils'; +import { getClient } from '../exports'; import { WINDOW } from '../helpers'; type HandlerData = Record; @@ -103,7 +104,7 @@ export class Breadcrumbs implements Integration { addInstrumentationHandler('history', _historyBreadcrumb); } if (this.options.sentry) { - const client = getCurrentHub().getClient(); + const client = getClient(); client && client.on && client.on('beforeSendEvent', addSentryBreadcrumb); } } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 33f6652b2310..4c8a74adf247 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -5,6 +5,7 @@ import type { DebugImage, Envelope, Event, StackFrame, StackParser, Transaction import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; import { browserPerformanceTimeOrigin, forEachEnvelopeItem, GLOBAL_OBJ, logger, uuid4 } from '@sentry/utils'; +import { getClient } from '../exports'; import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; @@ -532,7 +533,7 @@ export function shouldProfileTransaction(transaction: Transaction): boolean { return false; } - const client = getCurrentHub().getClient(); + const client = getClient(); const options = client && client.getOptions(); if (!options) { __DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.'); diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index de352d9db154..e7689f3521b1 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -1,5 +1,6 @@ import type { Hub } from '@sentry/core'; import { + getClient, getCurrentHub, getIntegrationsToSetup, getReportDialogEndpoint, @@ -252,7 +253,7 @@ function startSessionTracking(): void { * Captures user feedback and sends it to Sentry. */ export function captureUserFeedback(feedback: UserFeedback): void { - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { client.captureUserFeedback(feedback); } diff --git a/packages/browser/test/unit/eventbuilder.test.ts b/packages/browser/test/unit/eventbuilder.test.ts index d7a2ab712959..0a5a7911ea08 100644 --- a/packages/browser/test/unit/eventbuilder.test.ts +++ b/packages/browser/test/unit/eventbuilder.test.ts @@ -1,5 +1,3 @@ -import type { Client } from '@sentry/types'; - import { defaultStackParser } from '../../src'; import { eventFromPlainObject } from '../../src/eventbuilder'; @@ -7,9 +5,14 @@ jest.mock('@sentry/core', () => { const original = jest.requireActual('@sentry/core'); return { ...original, - getCurrentHub(): { - getClient(): Client; - } { + getClient() { + return { + getOptions(): any { + return { normalizeDepth: 6 }; + }, + }; + }, + getCurrentHub() { return { getClient(): any { return { diff --git a/packages/browser/test/unit/index.test.ts b/packages/browser/test/unit/index.test.ts index 0b0df3c4300f..fca76fb33b79 100644 --- a/packages/browser/test/unit/index.test.ts +++ b/packages/browser/test/unit/index.test.ts @@ -11,6 +11,7 @@ import { captureMessage, configureScope, flush, + getClient, getCurrentHub, init, Integrations, @@ -271,11 +272,11 @@ describe('SentryBrowser initialization', () => { it('should set SDK data when Sentry.init() is called', () => { init({ dsn }); - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; expect(sdkData?.name).toBe('sentry.javascript.browser'); - expect(sdkData?.packages[0].name).toBe('npm:@sentry/browser'); - expect(sdkData?.packages[0].version).toBe(SDK_VERSION); + expect(sdkData?.packages?.[0].name).toBe('npm:@sentry/browser'); + expect(sdkData?.packages?.[0].version).toBe(SDK_VERSION); expect(sdkData?.version).toBe(SDK_VERSION); }); @@ -283,9 +284,9 @@ describe('SentryBrowser initialization', () => { global.SENTRY_SDK_SOURCE = 'loader'; init({ dsn }); - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; - expect(sdkData?.packages[0].name).toBe('loader:@sentry/browser'); + expect(sdkData.packages?.[0].name).toBe('loader:@sentry/browser'); delete global.SENTRY_SDK_SOURCE; }); @@ -293,9 +294,9 @@ describe('SentryBrowser initialization', () => { const spy = jest.spyOn(utils, 'getSDKSource').mockReturnValue('cdn'); init({ dsn }); - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; - expect(sdkData?.packages[0].name).toBe('cdn:@sentry/browser'); + expect(sdkData.packages?.[0].name).toBe('cdn:@sentry/browser'); expect(utils.getSDKSource).toBeCalledTimes(1); spy.mockRestore(); }); @@ -332,11 +333,11 @@ describe('SentryBrowser initialization', () => { }, }); - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata?.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; expect(sdkData.name).toBe('sentry.javascript.angular'); - expect(sdkData.packages[0].name).toBe('npm:@sentry/angular'); - expect(sdkData.packages[0].version).toBe(SDK_VERSION); + expect(sdkData.packages?.[0].name).toBe('npm:@sentry/angular'); + expect(sdkData.packages?.[0].version).toBe(SDK_VERSION); expect(sdkData.version).toBe(SDK_VERSION); }); }); diff --git a/packages/browser/test/unit/integrations/breadcrumbs.test.ts b/packages/browser/test/unit/integrations/breadcrumbs.test.ts index f2deb2302c3c..04025e472ecc 100644 --- a/packages/browser/test/unit/integrations/breadcrumbs.test.ts +++ b/packages/browser/test/unit/integrations/breadcrumbs.test.ts @@ -1,15 +1,18 @@ import { getCurrentHub } from '@sentry/core'; +import type { Client } from '@sentry/types'; import { Breadcrumbs, BrowserClient, flush, Hub } from '../../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; const hub = new Hub(); +let client: Client | undefined; jest.mock('@sentry/core', () => { const original = jest.requireActual('@sentry/core'); return { ...original, getCurrentHub: () => hub, + getClient: () => client, }; }); @@ -18,7 +21,7 @@ describe('Breadcrumbs', () => { const addBreadcrumb = jest.fn(); hub.addBreadcrumb = addBreadcrumb; - const client = new BrowserClient({ + client = new BrowserClient({ ...getDefaultBrowserClientOptions(), dsn: 'https://username@domain/123', integrations: [new Breadcrumbs()], diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts index 0060a4e6b000..515398638048 100644 --- a/packages/browser/test/unit/profiling/integration.test.ts +++ b/packages/browser/test/unit/profiling/integration.test.ts @@ -48,12 +48,12 @@ describe('BrowserProfilingIntegration', () => { integrations: [new Sentry.BrowserTracing(), new BrowserProfilingIntegration()], }); - const client = Sentry.getCurrentHub().getClient() as BrowserClient; + const client = Sentry.getClient(); const currentTransaction = Sentry.getCurrentHub().getScope().getTransaction(); expect(currentTransaction?.op).toBe('pageload'); currentTransaction?.finish(); - await client.flush(1000); + await client?.flush(1000); expect(send).toHaveBeenCalledTimes(1); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 9750a2ba7815..baae8ff4fa32 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -39,6 +39,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, lastEventId, makeMain, diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index cd440b376655..915bafffa9bc 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -13,6 +13,7 @@ import type { EventDropReason, EventHint, EventProcessor, + FeedbackEvent, Integration, IntegrationClass, Outcome, @@ -27,7 +28,6 @@ import type { Transport, TransportMakeRequestResponse, } from '@sentry/types'; -import type { FeedbackEvent } from '@sentry/types'; import { addItemToEnvelope, checkOrSetAlreadyCaught, diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 2be61f2fa940..6d1d52e60863 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -2,6 +2,7 @@ import type { Breadcrumb, CaptureContext, CheckIn, + Client, CustomSamplingContext, Event, EventHint, @@ -266,7 +267,7 @@ export function withMonitor( * doesn't (or if there's no client defined). */ export async function flush(timeout?: number): Promise { - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { return client.flush(timeout); } @@ -283,7 +284,7 @@ export async function flush(timeout?: number): Promise { * doesn't (or if there's no client defined). */ export async function close(timeout?: number): Promise { - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { return client.close(timeout); } @@ -299,3 +300,10 @@ export async function close(timeout?: number): Promise { export function lastEventId(): string | undefined { return getCurrentHub().lastEventId(); } + +/** + * Get the currently active client. + */ +export function getClient(): C | undefined { + return getCurrentHub().getClient(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5b271a71fff..0a1daae81450 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,7 @@ export { setTags, setUser, withScope, + getClient, } from './exports'; export { getCurrentHub, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 25e8d41225bd..8b05139af74b 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -2,6 +2,7 @@ import type { Client, Event, EventHint, Integration, Options } from '@sentry/typ import { arrayify, logger } from '@sentry/utils'; import { addGlobalEventProcessor } from './eventProcessors'; +import { getClient } from './exports'; import { getCurrentHub } from './hub'; declare module '@sentry/types' { @@ -132,7 +133,7 @@ export function setupIntegration(client: Client, integration: Integration, integ /** Add an integration to the current hub's client. */ export function addIntegration(integration: Integration): void { - const client = getCurrentHub().getClient(); + const client = getClient(); if (!client || !client.addIntegration) { __DEBUG_BUILD__ && logger.warn(`Cannot add integration "${integration.name}" because no SDK Client is available.`); diff --git a/packages/core/src/utils/hasTracingEnabled.ts b/packages/core/src/utils/hasTracingEnabled.ts index 65dad5883ba2..94c5ab13e5ae 100644 --- a/packages/core/src/utils/hasTracingEnabled.ts +++ b/packages/core/src/utils/hasTracingEnabled.ts @@ -1,6 +1,6 @@ import type { Options } from '@sentry/types'; -import { getCurrentHub } from '../hub'; +import { getClient } from '../exports'; // Treeshakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean | undefined; @@ -17,7 +17,7 @@ export function hasTracingEnabled( return false; } - const client = getCurrentHub().getClient(); + const client = getClient(); const options = maybeOptions || (client && client.getOptions()); return !!options && (options.enableTracing || 'tracesSampleRate' in options || 'tracesSampler' in options); } diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 3301e50db6c2..093ed25380de 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -38,6 +38,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, lastEventId, makeMain, diff --git a/packages/feedback/test/sendFeedback.test.ts b/packages/feedback/test/sendFeedback.test.ts index 960411d8949e..e7c836527089 100644 --- a/packages/feedback/test/sendFeedback.test.ts +++ b/packages/feedback/test/sendFeedback.test.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { sendFeedback } from '../src/sendFeedback'; import { mockSdk } from './utils/mockSdk'; @@ -6,7 +6,7 @@ import { mockSdk } from './utils/mockSdk'; describe('sendFeedback', () => { it('sends feedback', async () => { mockSdk(); - const mockTransport = jest.spyOn(getCurrentHub().getClient()!.getTransport()!, 'send'); + const mockTransport = jest.spyOn(getClient()!.getTransport()!, 'send'); await sendFeedback({ name: 'doe', diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index a633e1cec6dc..c1f8bdc5ccfe 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient, getCurrentHub } from '@sentry/core'; import { WINDOW } from '@sentry/react'; import type { Primitive, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; import { @@ -101,7 +101,7 @@ let activeTransaction: Transaction | undefined = undefined; // This is either a route or a pathname. let prevLocationName: string | undefined = undefined; -const client = getCurrentHub().getClient(); +const client = getClient(); /** * Instruments the Next.js pages router. Only supported for diff --git a/packages/nextjs/src/common/_error.ts b/packages/nextjs/src/common/_error.ts index c69c725c3137..1ddd5ea63e01 100644 --- a/packages/nextjs/src/common/_error.ts +++ b/packages/nextjs/src/common/_error.ts @@ -1,4 +1,4 @@ -import { captureException, getCurrentHub, withScope } from '@sentry/core'; +import { captureException, getClient, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; type ContextOrProps = { @@ -11,7 +11,7 @@ type ContextOrProps = { /** Platform-agnostic version of `flush` */ function flush(timeout?: number): PromiseLike { - const client = getCurrentHub().getClient(); + const client = getClient(); return client ? client.flush(timeout) : Promise.resolve(false); } diff --git a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts index 65afef8cc71f..df801a8bc027 100644 --- a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient } from '@sentry/core'; import type Document from 'next/document'; import { isBuild } from './utils/isBuild'; @@ -29,7 +29,7 @@ export function wrapDocumentGetInitialPropsWithSentry( const { req, res } = context; const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - const options = getCurrentHub().getClient()?.getOptions(); + const options = getClient()?.getOptions(); // Generally we can assume that `req` and `res` are always defined on the server: // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object diff --git a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts index 8e141e173ae6..5d2446d50769 100644 --- a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts @@ -1,4 +1,4 @@ -import { addTracingExtensions, getCurrentHub } from '@sentry/core'; +import { addTracingExtensions, getClient } from '@sentry/core'; import type { GetStaticProps } from 'next'; import { isBuild } from './utils/isBuild'; @@ -26,7 +26,7 @@ export function wrapGetStaticPropsWithSentry( addTracingExtensions(); const errorWrappedGetStaticProps = withErrorInstrumentation(wrappingTarget); - const options = getCurrentHub().getClient()?.getOptions(); + const options = getClient()?.getOptions(); if (options?.instrumenter === 'sentry') { return callDataFetcherTraced(errorWrappedGetStaticProps, args, { diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index 740c52b92bb8..e14572b14b6a 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -13,7 +13,7 @@ export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanc export * as Handlers from './sdk/handlers'; export type { Span } from './types'; -export { startSpan, startInactiveSpan, getCurrentHub, getActiveSpan } from '@sentry/opentelemetry'; +export { startSpan, startInactiveSpan, getCurrentHub, getClient, getActiveSpan } from '@sentry/opentelemetry'; export { makeNodeTransport, diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 69712e503761..4eb0308c6bd0 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -3,7 +3,7 @@ import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; -import { _INTERNAL, getCurrentHub, getSpanKind, setSpanMetadata } from '@sentry/opentelemetry'; +import { _INTERNAL, getClient, getCurrentHub, getSpanKind, setSpanMetadata } from '@sentry/opentelemetry'; import type { EventProcessor, Hub, Integration } from '@sentry/types'; import { stringMatchesSomePattern } from '@sentry/utils'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; @@ -84,7 +84,7 @@ export class Http implements Integration { return; } - const client = getCurrentHub().getClient(); + const client = getClient(); const clientOptions = client?.getOptions(); // This is used in the sampler function diff --git a/packages/node-experimental/src/integrations/node-fetch.ts b/packages/node-experimental/src/integrations/node-fetch.ts index bc1f776d950a..54d67f33f4c2 100644 --- a/packages/node-experimental/src/integrations/node-fetch.ts +++ b/packages/node-experimental/src/integrations/node-fetch.ts @@ -2,7 +2,7 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import type { Instrumentation } from '@opentelemetry/instrumentation'; import { hasTracingEnabled } from '@sentry/core'; -import { _INTERNAL, getCurrentHub, getSpanKind } from '@sentry/opentelemetry'; +import { _INTERNAL, getClient, getCurrentHub, getSpanKind } from '@sentry/opentelemetry'; import type { Integration } from '@sentry/types'; import type { NodeExperimentalClient } from '../types'; @@ -87,7 +87,7 @@ export class NodeFetch extends NodePerformanceIntegration impl public setupOnce(): void { super.setupOnce(); - const client = getCurrentHub().getClient(); + const client = getClient(); const clientOptions = client?.getOptions(); // This is used in the sampler function diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 271135728186..7b33df8431f1 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -5,7 +5,7 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; import { SDK_VERSION } from '@sentry/core'; import { - getCurrentHub, + getClient, SentryPropagator, SentrySampler, setupEventContextTrace, @@ -20,7 +20,7 @@ import { NodeExperimentalSentrySpanProcessor } from './spanProcessor'; * Initialize OpenTelemetry for Node. */ export function initOtel(): void { - const client = getCurrentHub().getClient(); + const client = getClient(); if (!client) { __DEBUG_BUILD__ && diff --git a/packages/node-experimental/src/sdk/spanProcessor.ts b/packages/node-experimental/src/sdk/spanProcessor.ts index 4d120b1e80e6..5b6870cbc9fd 100644 --- a/packages/node-experimental/src/sdk/spanProcessor.ts +++ b/packages/node-experimental/src/sdk/spanProcessor.ts @@ -1,7 +1,7 @@ import { SpanKind } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { getCurrentHub, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { getClient, SentrySpanProcessor } from '@sentry/opentelemetry'; import { Http } from '../integrations/http'; import { NodeFetch } from '../integrations/node-fetch'; @@ -13,7 +13,7 @@ import type { NodeExperimentalClient } from '../types'; export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor { /** @inheritDoc */ protected _shouldSendSpanToSentry(span: Span): boolean { - const client = getCurrentHub().getClient(); + const client = getClient(); const httpIntegration = client ? client.getIntegration(Http) : undefined; const fetchIntegration = client ? client.getIntegration(NodeFetch) : undefined; diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index 549b962d4b70..e2b5b62b1c3c 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -1,4 +1,4 @@ -import { makeSession, updateSession } from '@sentry/core'; +import { getClient, makeSession, updateSession } from '@sentry/core'; import type { Event, Session, StackFrame } from '@sentry/types'; import { logger, watchdogTimer } from '@sentry/utils'; import { spawn } from 'child_process'; @@ -173,7 +173,7 @@ function handleChildProcess(options: Options): void { if (session) { log('Sending abnormal session'); updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); - getCurrentHub().getClient()?.sendSession(session); + getClient()?.sendSession(session); try { // Notify the main process that the session has ended so the session can be cleared from the scope diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 66ecf304b9a6..1a75a76a20ab 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, flush, + getClient, getCurrentHub, hasTracingEnabled, runWithAsyncContext, @@ -278,7 +279,7 @@ export function errorHandler(options?: { _scope.setSpan(transaction); } - const client = getCurrentHub().getClient(); + const client = getClient(); if (client && isAutoSessionTrackingEnabled(client)) { // Check if the `SessionFlusher` is instantiated on the client to go into this branch that marks the // `requestSession.status` as `Crashed`, and this check is necessary because the `SessionFlusher` is only diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 04aba567cf2c..0a361f02f5d7 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -38,6 +38,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, lastEventId, makeMain, diff --git a/packages/node/src/integrations/onuncaughtexception.ts b/packages/node/src/integrations/onuncaughtexception.ts index b4f99b419fd4..b1699f464960 100644 --- a/packages/node/src/integrations/onuncaughtexception.ts +++ b/packages/node/src/integrations/onuncaughtexception.ts @@ -1,5 +1,5 @@ import type { Scope } from '@sentry/core'; -import { getCurrentHub } from '@sentry/core'; +import { getClient, getCurrentHub } from '@sentry/core'; import type { Integration } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -86,7 +86,7 @@ export class OnUncaughtException implements Integration { return (error: Error): void => { let onFatalError: OnFatalErrorHandler = logAndExitProcess; - const client = getCurrentHub().getClient(); + const client = getClient(); if (this._options.onFatalError) { onFatalError = this._options.onFatalError; diff --git a/packages/node/src/integrations/utils/errorhandling.ts b/packages/node/src/integrations/utils/errorhandling.ts index cf52929fa642..e3f03f064421 100644 --- a/packages/node/src/integrations/utils/errorhandling.ts +++ b/packages/node/src/integrations/utils/errorhandling.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { logger } from '@sentry/utils'; import type { NodeClient } from '../../client'; @@ -12,7 +12,7 @@ export function logAndExitProcess(error: Error): void { // eslint-disable-next-line no-console console.error(error); - const client = getCurrentHub().getClient(); + const client = getClient(); if (client === undefined) { __DEBUG_BUILD__ && logger.warn('No NodeClient was defined, we are exiting the process now.'); diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 24f2b95e398b..0d55b4be6da3 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -8,6 +8,7 @@ import { captureException, captureMessage, configureScope, + getClient, getCurrentHub, init, NodeClient, @@ -296,6 +297,7 @@ describe('SentryNode', () => { const hub = getCurrentHub(); hub.bindClient(client); expect(getCurrentHub().getClient()).toBe(client); + expect(getClient()).toBe(client); hub.captureEvent({ message: 'test domain' }); }); }); @@ -366,12 +368,11 @@ describe('SentryNode initialization', () => { it('should set SDK data when `Sentry.init()` is called', () => { init({ dsn }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; expect(sdkData.name).toEqual('sentry.javascript.node'); - expect(sdkData.packages[0].name).toEqual('npm:@sentry/node'); - expect(sdkData.packages[0].version).toEqual(SDK_VERSION); + expect(sdkData.packages?.[0].name).toEqual('npm:@sentry/node'); + expect(sdkData.packages?.[0].version).toEqual(SDK_VERSION); expect(sdkData.version).toEqual(SDK_VERSION); }); @@ -408,12 +409,11 @@ describe('SentryNode initialization', () => { }, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sdkData = (getCurrentHub().getClient() as any).getOptions()._metadata.sdk; + const sdkData = getClient()?.getOptions()._metadata?.sdk || {}; expect(sdkData.name).toEqual('sentry.javascript.serverless'); - expect(sdkData.packages[0].name).toEqual('npm:@sentry/serverless'); - expect(sdkData.packages[0].version).toEqual(SDK_VERSION); + expect(sdkData.packages?.[0].name).toEqual('npm:@sentry/serverless'); + expect(sdkData.packages?.[0].version).toEqual(SDK_VERSION); expect(sdkData.version).toEqual(SDK_VERSION); }); }); diff --git a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js index a9a16809ad41..8fe93e9760fd 100644 --- a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js +++ b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js @@ -65,7 +65,7 @@ Sentry.init({ app.use(Sentry.Handlers.requestHandler()); // Hack that resets the 60s default flush interval, and replaces it with just a one second interval -const flusher = Sentry.getCurrentHub().getClient()._sessionFlusher; +const flusher = Sentry.getClient()._sessionFlusher; clearInterval(flusher._intervalId); flusher._intervalId = setInterval(() => flusher.flush(), 1000); diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 671cdbb7894a..98ab46c405cb 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -2,7 +2,7 @@ import type { Context } from '@opentelemetry/api'; import { context, SpanKind, trace } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; import type { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { addGlobalEventProcessor, addTracingExtensions, getCurrentHub, Transaction } from '@sentry/core'; +import { addGlobalEventProcessor, addTracingExtensions, getClient, getCurrentHub, Transaction } from '@sentry/core'; import type { DynamicSamplingContext, Span as SentrySpan, TraceparentData, TransactionContext } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -100,7 +100,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { return; } - const client = getCurrentHub().getClient(); + const client = getClient(); const mutableOptions = { drop: false }; client && client.emit && client?.emit('otelSpanEnd', otelSpan, mutableOptions); @@ -141,7 +141,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { * @inheritDoc */ public async forceFlush(): Promise { - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { return client.flush().then(); } diff --git a/packages/opentelemetry/src/custom/hub.ts b/packages/opentelemetry/src/custom/hub.ts index 46dccd3c86e4..339cba87ce4d 100644 --- a/packages/opentelemetry/src/custom/hub.ts +++ b/packages/opentelemetry/src/custom/hub.ts @@ -28,6 +28,11 @@ export class OpenTelemetryHub extends Hub { } } +/** Custom getClient method that uses the custom hub. */ +export function getClient(): C | undefined { + return getCurrentHub().getClient(); +} + /** * ******************************************************************************* * Everything below here is a copy of the stuff from core's hub.ts, diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index eb516e03dea1..3ac617aada9d 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -24,7 +24,7 @@ export { isSentryRequestSpan } from './utils/isSentryRequest'; export { getActiveSpan, getRootSpan } from './utils/getActiveSpan'; export { startSpan, startInactiveSpan } from './trace'; -export { getCurrentHub, setupGlobalHub } from './custom/hub'; +export { getCurrentHub, setupGlobalHub, getClient } from './custom/hub'; export { addTracingExtensions } from './custom/hubextensions'; export { setupEventContextTrace } from './setupEventContextTrace'; diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index db7a78d4f8f2..d2cf6dbe5556 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -6,7 +6,7 @@ import type { DynamicSamplingContext, PropagationContext } from '@sentry/types'; import { generateSentryTraceHeader, SENTRY_BAGGAGE_KEY_PREFIX, tracingContextFromHeaders } from '@sentry/utils'; import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER } from './constants'; -import { getCurrentHub } from './custom/hub'; +import { getClient } from './custom/hub'; import { getPropagationContextFromContext, setPropagationContextOnContext } from './utils/contextData'; import { getSpanScope } from './utils/spanData'; @@ -90,7 +90,7 @@ function getDsc( } // Else, we try to generate a new one - const client = getCurrentHub().getClient(); + const client = getClient(); const activeSpan = trace.getSpan(context); const scope = activeSpan ? getSpanScope(activeSpan) : undefined; diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index e7a8fcaef8ea..3bd635a953df 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -4,7 +4,7 @@ import { SDK_VERSION } from '@sentry/core'; import type { Client } from '@sentry/types'; import { isThenable } from '@sentry/utils'; -import { getCurrentHub } from './custom/hub'; +import { getClient } from './custom/hub'; import { InternalSentrySemanticAttributes } from './semanticAttributes'; import type { OpenTelemetryClient, OpenTelemetrySpanContext } from './types'; import { setSpanMetadata } from './utils/spanData'; @@ -85,7 +85,7 @@ export function startInactiveSpan(spanContext: OpenTelemetrySpanContext): Span { } function getTracer(): Tracer { - const client = getCurrentHub().getClient(); + const client = getClient(); return (client && client.tracer) || trace.getTracer('@sentry/opentelemetry', SDK_VERSION); } diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 075b5f8b00bf..61dd976050f6 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -1,5 +1,5 @@ import type { ReportDialogOptions, Scope } from '@sentry/browser'; -import { captureException, getCurrentHub, showReportDialog, withScope } from '@sentry/browser'; +import { captureException, getClient, showReportDialog, withScope } from '@sentry/browser'; import { isError, logger } from '@sentry/utils'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; @@ -104,7 +104,7 @@ class ErrorBoundary extends React.Component { diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index cb90424ba4ad..360e6d7b1382 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { addGlobalEventProcessor, configureScope, getCurrentHub } from '@sentry/browser'; +import { addGlobalEventProcessor, configureScope, getClient } from '@sentry/browser'; import type { Scope } from '@sentry/types'; import { addNonEnumerableProperty } from '@sentry/utils'; @@ -130,7 +130,7 @@ function createReduxEnhancer(enhancerOptions?: Partial): /* Set latest state to scope */ const transformedState = options.stateTransformer(newState); if (typeof transformedState !== 'undefined' && transformedState !== null) { - const client = getCurrentHub().getClient(); + const client = getClient(); const options = client && client.getOptions(); const normalizationDepth = (options && options.normalizeDepth) || 3; // default state normalization depth to 3 diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index 52f71552f703..6d77802948eb 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -1,4 +1,4 @@ -import { getCurrentHub, Scope } from '@sentry/browser'; +import { getClient, getCurrentHub, Scope } from '@sentry/browser'; import { fireEvent, render, screen } from '@testing-library/react'; import * as React from 'react'; import { useState } from 'react'; @@ -422,7 +422,7 @@ describe('ErrorBoundary', () => { }); it('shows a Sentry Report Dialog with correct options if client does not have hooks', () => { - expect(getCurrentHub().getClient()).toBeUndefined(); + expect(getClient()).toBeUndefined(); const options = { title: 'custom title' }; render( diff --git a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts index 00e3b72d590d..5cc2adf77a59 100644 --- a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { ErrorEvent, Event, TransactionEvent, Transport, TransportMakeRequestResponse } from '@sentry/types'; import type { ReplayContainer } from '../types'; @@ -79,7 +79,7 @@ function handleErrorEvent(replay: ReplayContainer, event: ErrorEvent): void { } function isBaseTransportSend(): boolean { - const client = getCurrentHub().getClient(); + const client = getClient(); if (!client) { return false; } diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index 20fa00a633eb..a8c14e46fad5 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { Breadcrumb, BreadcrumbHint, @@ -26,7 +26,7 @@ interface ExtendedNetworkBreadcrumbsOptions extends ReplayNetworkOptions { * (enriching it with further data that is _not_ added to the regular breadcrumbs) */ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { - const client = getCurrentHub().getClient(); + const client = getClient(); try { const textEncoder = new TextEncoder(); diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index 559b80532c77..b94907d44938 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { BrowserClientReplayOptions, Integration } from '@sentry/types'; import { dropUndefinedKeys, isBrowser } from '@sentry/utils'; @@ -343,7 +343,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, /** Parse Replay-related options from SDK options */ function loadReplayOptionsFromClient(initialOptions: InitialReplayPluginOptions): ReplayPluginOptions { - const client = getCurrentHub().getClient(); + const client = getClient(); const opt = client && (client.getOptions() as BrowserClientReplayOptions); const finalOptions = { sessionSampleRate: 0, errorSampleRate: 0, ...dropUndefinedKeys(initialOptions) }; diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 8f185d5133ca..f4368c1d0930 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -1,6 +1,6 @@ /* 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 { captureException, getClient, getCurrentHub } from '@sentry/core'; import type { ReplayRecordingMode, Transaction } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -1107,7 +1107,7 @@ export class ReplayContainer implements ReplayContainerInterface { // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments void this.stop({ reason: 'sendReplay' }); - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { client.recordDroppedEvent('send_error', 'replay'); diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index b5b6287034a9..fcfd13b3a10e 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -1,5 +1,5 @@ import { EventType } from '@sentry-internal/rrweb'; -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { logger } from '@sentry/utils'; import { EventBufferSizeExceededError } from '../eventBuffer/error'; @@ -80,7 +80,7 @@ async function _addEvent( __DEBUG_BUILD__ && logger.error(error); await replay.stop({ reason }); - const client = getCurrentHub().getClient(); + const client = getClient(); if (client) { client.recordDroppedEvent('internal_sdk_error', 'replay'); diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index bf133c3e2130..cb7fbb75c00f 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -1,5 +1,5 @@ import type { BaseClient } from '@sentry/core'; -import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; +import { addGlobalEventProcessor, getClient, getCurrentHub } from '@sentry/core'; import type { Client, DynamicSamplingContext } from '@sentry/types'; import { addInstrumentationHandler } from '@sentry/utils'; @@ -17,7 +17,7 @@ import type { ReplayContainer } from '../types'; export function addGlobalListeners(replay: ReplayContainer): void { // Listeners from core SDK // const scope = getCurrentHub().getScope(); - const client = getCurrentHub().getClient(); + const client = getClient(); scope.addScopeListener(handleScopeListener(replay)); addInstrumentationHandler('dom', handleDomListener(replay)); diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts index cfb03073d12e..3cb668a83a67 100644 --- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { ErrorEvent, Event } from '@sentry/types'; import { UNABLE_TO_SEND_REPLAY } from '../../../src/constants'; @@ -152,7 +152,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const client = getCurrentHub().getClient()!; + const client = getClient()!; // @ts-expect-error make sure to remove this delete client.getTransport()!.send.__sentry__baseTransport__; @@ -186,7 +186,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1 = Error({ event_id: 'err1', tags: { replayId: 'replayid1' } }); @@ -225,7 +225,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1 = Error({ event_id: 'err1' }); @@ -259,7 +259,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const profileEvent: Event = { type: 'profile' }; const replayEvent: Event = { type: 'replay_event' }; @@ -294,7 +294,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1 = Error({ event_id: 'err1' }); @@ -328,7 +328,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1: ErrorEvent = { event_id: 'err1', type: undefined }; @@ -362,7 +362,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1 = Error({ event_id: 'err1', message: UNABLE_TO_SEND_REPLAY }); @@ -396,7 +396,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const error1 = Error({ event_id: 'err1', tags: { replayId: 'replayid1' } }); @@ -429,7 +429,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { }, })); - const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance; + const mockSend = getClient()!.getTransport()!.send as unknown as jest.SpyInstance; const handler = handleAfterSendEvent(replay); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index fadcaa568f25..f4be5232cffe 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -1,4 +1,4 @@ -import { captureException, getCurrentHub } from '@sentry/core'; +import { captureException, getClient } from '@sentry/core'; import { BUFFER_CHECKOUT_TIME, @@ -718,7 +718,7 @@ describe('Integration | errorSampleRate', () => { // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + (getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); expect(replay).not.toHaveLastSentReplay(); @@ -786,7 +786,7 @@ describe('Integration | errorSampleRate', () => { // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + (getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); jest.advanceTimersByTime(MAX_REPLAY_DURATION); await new Promise(process.nextTick); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index 1b8b4726a778..7a3d6e920a9e 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; @@ -36,7 +36,7 @@ describe('Integration | events', () => { }, })); - mockTransportSend = jest.spyOn(getCurrentHub().getClient()!.getTransport()!, 'send'); + mockTransportSend = jest.spyOn(getClient()!.getTransport()!, 'send'); // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 3fc9074b224c..403e989e6a96 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -26,6 +26,7 @@ export { createTransport, getActiveTransaction, getCurrentHub, + getClient, getHubFromCarrier, makeMain, setContext, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b595324c2e4c..efaae15cbf02 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -19,6 +19,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, makeMain, Scope, diff --git a/packages/sveltekit/test/client/sdk.test.ts b/packages/sveltekit/test/client/sdk.test.ts index b965e37538da..fafaa941f642 100644 --- a/packages/sveltekit/test/client/sdk.test.ts +++ b/packages/sveltekit/test/client/sdk.test.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getClient, getCurrentHub } from '@sentry/core'; import type { BrowserClient } from '@sentry/svelte'; import * as SentrySvelte from '@sentry/svelte'; import { SDK_VERSION, WINDOW } from '@sentry/svelte'; @@ -62,7 +62,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = svelteInit.mock.calls[0][0].integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeDefined(); @@ -78,7 +78,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = svelteInit.mock.calls[0][0].integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeUndefined(); @@ -97,7 +97,7 @@ describe('Sentry client SDK', () => { }); const integrationsToInit = svelteInit.mock.calls[0][0].integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing'); + const browserTracing = getClient()?.getIntegrationById('BrowserTracing'); expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); expect(browserTracing).toBeUndefined(); @@ -115,9 +115,7 @@ describe('Sentry client SDK', () => { const integrationsToInit = svelteInit.mock.calls[0][0].integrations; - const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById( - 'BrowserTracing', - ) as BrowserTracing; + const browserTracing = getClient()?.getIntegrationById('BrowserTracing') as BrowserTracing; const options = browserTracing.options; expect(integrationsToInit).toContainEqual(expect.objectContaining({ name: 'BrowserTracing' })); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 4f9b81345203..fcb09ebd3094 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -38,6 +38,7 @@ export { getActiveTransaction, getHubFromCarrier, getCurrentHub, + getClient, Hub, lastEventId, makeMain, From c99b2ae77cdd8d48dab691429582a76650e59869 Mon Sep 17 00:00:00 2001 From: arya-s Date: Thu, 23 Nov 2023 01:45:40 +0900 Subject: [PATCH 12/21] feat(feedback): Add onClose callback to showReportDialog (#9433) (#9550) Adds an `onClose` callback to `showReportDialog`. The callback is invoked when a `__sentry_reportdialog_closed__` MessageEvent is received. This is sent from the error page embed via `window.postMessage` and listened to in the sdk. --- packages/browser/src/helpers.ts | 2 + packages/browser/src/sdk.ts | 14 ++++++ packages/browser/test/unit/index.test.ts | 60 ++++++++++++++++++++++++ packages/core/src/api.ts | 4 ++ packages/core/test/lib/api.test.ts | 6 +++ 5 files changed, 86 insertions(+) diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index faa0762c8163..a804508a0832 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -180,4 +180,6 @@ export interface ReportDialogOptions { successMessage?: string; /** Callback after reportDialog showed up */ onLoad?(this: void): void; + /** Callback after reportDialog closed */ + onClose?(this: void): void; } diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index e7689f3521b1..7ff9cdc058df 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -166,6 +166,20 @@ export function showReportDialog(options: ReportDialogOptions = {}, hub: Hub = g script.onload = options.onLoad; } + const { onClose } = options; + if (onClose) { + const reportDialogClosedMessageHandler = (event: MessageEvent): void => { + if (event.data === '__sentry_reportdialog_closed__') { + try { + onClose(); + } finally { + WINDOW.removeEventListener('message', reportDialogClosedMessageHandler); + } + } + }; + WINDOW.addEventListener('message', reportDialogClosedMessageHandler); + } + const injectionPoint = WINDOW.document.head || WINDOW.document.body; if (injectionPoint) { injectionPoint.appendChild(script); diff --git a/packages/browser/test/unit/index.test.ts b/packages/browser/test/unit/index.test.ts index fca76fb33b79..75ab0f294f66 100644 --- a/packages/browser/test/unit/index.test.ts +++ b/packages/browser/test/unit/index.test.ts @@ -17,6 +17,7 @@ import { Integrations, Scope, showReportDialog, + WINDOW, wrap, } from '../../src'; import { getDefaultBrowserClientOptions } from './helper/browser-client-options'; @@ -124,6 +125,65 @@ describe('SentryBrowser', () => { ); }); }); + + describe('onClose', () => { + const dummyErrorHandler = jest.fn(); + beforeEach(() => { + // this prevents jest-environment-jsdom from failing the test + // when an error in `onClose` is thrown + // it does not prevent errors thrown directly inside the test, + // so we don't have to worry about tests passing that should + // otherwise fail + // see: https://github.com/jestjs/jest/blob/main/packages/jest-environment-jsdom/src/index.ts#L95-L115 + WINDOW.addEventListener('error', dummyErrorHandler); + }); + + afterEach(() => { + WINDOW.removeEventListener('error', dummyErrorHandler); + }); + + const waitForPostMessage = async (message: string) => { + WINDOW.postMessage(message, '*'); + await flush(10); + }; + + it('should call `onClose` when receiving `__sentry_reportdialog_closed__` MessageEvent', async () => { + const onClose = jest.fn(); + showReportDialog({ onClose }); + + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).toHaveBeenCalledTimes(1); + + // ensure the event handler has been removed so onClose is not called again + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call `onClose` only once even if it throws', async () => { + const onClose = jest.fn(() => { + throw new Error(); + }); + showReportDialog({ onClose }); + + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).toHaveBeenCalledTimes(1); + + // ensure the event handler has been removed so onClose is not called again + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not call `onClose` for other MessageEvents', async () => { + const onClose = jest.fn(); + showReportDialog({ onClose }); + + await waitForPostMessage('some_message'); + expect(onClose).not.toHaveBeenCalled(); + + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); }); describe('breadcrumbs', () => { diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index d6c04ea7c350..c46a09b3484d 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -70,6 +70,10 @@ export function getReportDialogEndpoint( continue; } + if (key === 'onClose') { + continue; + } + if (key === 'user') { const user = dialogOptions.user; if (!user) { diff --git a/packages/core/test/lib/api.test.ts b/packages/core/test/lib/api.test.ts index ecb92d22a064..61c7820c29de 100644 --- a/packages/core/test/lib/api.test.ts +++ b/packages/core/test/lib/api.test.ts @@ -119,6 +119,12 @@ describe('API', () => { { user: undefined }, 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123', ], + [ + 'with Public DSN and onClose callback', + dsnPublic, + { onClose: () => {} }, + 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123', + ], ])( '%s', ( From 27876437b7cf33e2a78b455d36d5b8102f17b878 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Nov 2023 17:48:23 +0100 Subject: [PATCH 13/21] feat(utils): Refactor `addInstrumentationHandler` to dedicated methods (#9542) This is mostly an internal utility, but changes a bit. The previous implementation for adding instrumentation had a few issues: * It was impossible to tree shake. So even if some instrumentation is never used, the whole code for it is always included. Splitting this up opens up future improvements there as well. * It was not type safe, and actually there _were_ some subtle issues in the code because of this - things could fail in unexpected ways, as we have been doing a lot of type casting from/to any etc. This PR splits up `addInstrumentationHandler` into e.g. `addFetchInstrumentationHandler` etc. methods, which improves the actual type safety a lot, because currently we've been duplicating the types everywhere, sometimes in slightly differing ways, leading to potential issues. **We had a bunch of issues with xhr & fetch data in Replay recently, so I figured it would finally be a good time to clean this up so we can ensure that the data we get is consistent & reliable.** Hopefully this change will prevent issues like we have/had in replay from popping up in the future. Some additional notes: * For the dom instrumentation, I picked the name `addClickKeypressInstrumentationHandler()`. I'm open for other naming ideas, but I found `addDomInstrumentation` a bit too broad, as this could mean a lot of things - and we are strictly only instrumenting clicks and keypresses here. Another name (if we want to be a bit broader) could be e.g. `addDomEventInstrumentation` or something like this? * I updated the type for the XHR data handler to make the method & url required. This was previously optional (but not in some of the code, where we expected this to always be), plus we also did not handle the case that `url` can be a `URL` instead of a string, which we now convert. * On the XHR data, we have one field `args` that 1. is not really used and 2. was actually "buggy", as we always returned the args, but for `open` and `send` the args are very different things (but we pretended it's always the "open" args). Now, this is actually always the method & url, and I also deprecated this for v8. * On the XHR data, we also had an unused/unset `data` field, which I removed (this was not set anywhere) * All old code should still work normally, but I deprecated the generic `addInstrumentationHandler()` method, for removal in v8. * I also took the opportunity and split the instrumentations into dedicated files, previously this was one mega-file which made it harder to reason about things. --- .../browser/src/integrations/breadcrumbs.ts | 50 +- .../src/integrations/globalhandlers.ts | 171 +++-- packages/browser/src/sdk.ts | 11 +- .../suites/onunhandledrejection.js | 20 - packages/core/src/tracing/errors.ts | 10 +- packages/core/test/lib/tracing/errors.test.ts | 41 +- packages/integrations/src/captureconsole.ts | 4 +- packages/integrations/src/httpclient.ts | 44 +- .../integrations/test/captureconsole.test.ts | 7 +- .../appRouterRoutingInstrumentation.ts | 6 +- .../appRouterInstrumentation.test.ts | 12 +- packages/node/src/integrations/console.ts | 4 +- packages/replay/src/coreHandlers/handleDom.ts | 14 +- .../replay/src/coreHandlers/handleFetch.ts | 2 +- .../replay/src/coreHandlers/handleHistory.ts | 15 +- .../coreHandlers/handleNetworkBreadcrumbs.ts | 6 +- packages/replay/src/coreHandlers/handleXhr.ts | 2 +- .../replay/src/coreHandlers/util/domUtils.ts | 8 +- packages/replay/src/types/performance.ts | 2 +- .../replay/src/util/addGlobalListeners.ts | 6 +- .../beforeAddRecordingEvent.test.ts | 10 +- .../test/integration/errorSampleRate.test.ts | 7 + .../replay/test/integration/flush.test.ts | 14 +- .../test/integration/sendReplayEvent.test.ts | 14 +- .../replay/test/integration/session.test.ts | 6 +- packages/replay/test/integration/stop.test.ts | 16 +- packages/replay/test/mocks/resetSdkMock.ts | 6 +- packages/replay/test/types.ts | 4 +- .../test/unit/coreHandlers/handleDom.test.ts | 13 +- .../handleNetworkBreadcrumbs.test.ts | 2 + .../tracing-internal/src/browser/request.ts | 37 +- .../tracing-internal/src/browser/router.ts | 4 +- .../test/browser/browsertracing.test.ts | 13 +- .../test/browser/request.test.ts | 37 +- .../test/browser/router.test.ts | 34 +- packages/types/src/index.ts | 13 +- packages/types/src/instrument.ts | 41 +- packages/utils/src/error.ts | 2 +- packages/utils/src/instrument.ts | 690 ------------------ packages/utils/src/instrument/_handlers.ts | 54 ++ packages/utils/src/instrument/console.ts | 44 ++ packages/utils/src/instrument/dom.ts | 260 +++++++ packages/utils/src/instrument/fetch.ts | 125 ++++ packages/utils/src/instrument/globalError.ts | 48 ++ .../instrument/globalUnhandledRejection.ts | 40 + packages/utils/src/instrument/history.ts | 71 ++ packages/utils/src/instrument/index.ts | 67 ++ packages/utils/src/instrument/xhr.ts | 158 ++++ packages/utils/src/logger.ts | 13 +- packages/utils/src/worldwide.ts | 2 +- packages/utils/test/instrument.test.ts | 51 -- packages/utils/test/instrument/dom.test.ts | 18 + packages/utils/test/instrument/fetch.test.ts | 29 + packages/utils/test/instrument/xhr.test.ts | 18 + .../src/integrations/wintercg-fetch.ts | 4 +- .../vercel-edge/test/wintercg-fetch.test.ts | 32 +- 56 files changed, 1333 insertions(+), 1099 deletions(-) delete mode 100644 packages/utils/src/instrument.ts create mode 100644 packages/utils/src/instrument/_handlers.ts create mode 100644 packages/utils/src/instrument/console.ts create mode 100644 packages/utils/src/instrument/dom.ts create mode 100644 packages/utils/src/instrument/fetch.ts create mode 100644 packages/utils/src/instrument/globalError.ts create mode 100644 packages/utils/src/instrument/globalUnhandledRejection.ts create mode 100644 packages/utils/src/instrument/history.ts create mode 100644 packages/utils/src/instrument/index.ts create mode 100644 packages/utils/src/instrument/xhr.ts delete mode 100644 packages/utils/test/instrument.test.ts create mode 100644 packages/utils/test/instrument/dom.test.ts create mode 100644 packages/utils/test/instrument/fetch.test.ts create mode 100644 packages/utils/test/instrument/xhr.test.ts diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index c278240c5786..83db8af4fc63 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,7 +1,14 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable max-lines */ import { getCurrentHub } from '@sentry/core'; -import type { Event as SentryEvent, HandlerDataFetch, HandlerDataXhr, Integration } from '@sentry/types'; +import type { + Event as SentryEvent, + HandlerDataConsole, + HandlerDataDom, + HandlerDataFetch, + HandlerDataHistory, + HandlerDataXhr, + Integration, +} from '@sentry/types'; import type { FetchBreadcrumbData, FetchBreadcrumbHint, @@ -9,7 +16,11 @@ import type { XhrBreadcrumbHint, } from '@sentry/types/build/types/breadcrumb'; import { - addInstrumentationHandler, + addClickKeypressInstrumentationHandler, + addConsoleInstrumentationHandler, + addFetchInstrumentationHandler, + addHistoryInstrumentationHandler, + addXhrInstrumentationHandler, getEventDescription, htmlTreeAsString, logger, @@ -22,8 +33,6 @@ import { import { getClient } from '../exports'; import { WINDOW } from '../helpers'; -type HandlerData = Record; - /** JSDoc */ interface BreadcrumbsOptions { console: boolean; @@ -89,19 +98,19 @@ export class Breadcrumbs implements Integration { */ public setupOnce(): void { if (this.options.console) { - addInstrumentationHandler('console', _consoleBreadcrumb); + addConsoleInstrumentationHandler(_consoleBreadcrumb); } if (this.options.dom) { - addInstrumentationHandler('dom', _domBreadcrumb(this.options.dom)); + addClickKeypressInstrumentationHandler(_domBreadcrumb(this.options.dom)); } if (this.options.xhr) { - addInstrumentationHandler('xhr', _xhrBreadcrumb); + addXhrInstrumentationHandler(_xhrBreadcrumb); } if (this.options.fetch) { - addInstrumentationHandler('fetch', _fetchBreadcrumb); + addFetchInstrumentationHandler(_fetchBreadcrumb); } if (this.options.history) { - addInstrumentationHandler('history', _historyBreadcrumb); + addHistoryInstrumentationHandler(_historyBreadcrumb); } if (this.options.sentry) { const client = getClient(); @@ -131,8 +140,8 @@ function addSentryBreadcrumb(event: SentryEvent): void { * A HOC that creaes a function that creates breadcrumbs from DOM API calls. * This is a HOC so that we get access to dom options in the closure. */ -function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerData) => void { - function _innerDomBreadcrumb(handlerData: HandlerData): void { +function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDataDom) => void { + function _innerDomBreadcrumb(handlerData: HandlerDataDom): void { let target; let keyAttrs = typeof dom === 'object' ? dom.serializeAttribute : undefined; @@ -183,7 +192,7 @@ function _domBreadcrumb(dom: BreadcrumbsOptions['dom']): (handlerData: HandlerDa /** * Creates breadcrumbs from console API calls */ -function _consoleBreadcrumb(handlerData: HandlerData & { args: unknown[]; level: string }): void { +function _consoleBreadcrumb(handlerData: HandlerDataConsole): void { const breadcrumb = { category: 'console', data: { @@ -213,7 +222,7 @@ function _consoleBreadcrumb(handlerData: HandlerData & { args: unknown[]; level: /** * Creates breadcrumbs from XHR API calls */ -function _xhrBreadcrumb(handlerData: HandlerData & HandlerDataXhr): void { +function _xhrBreadcrumb(handlerData: HandlerDataXhr): void { const { startTimestamp, endTimestamp } = handlerData; const sentryXhrData = handlerData.xhr[SENTRY_XHR_DATA_KEY]; @@ -251,7 +260,7 @@ function _xhrBreadcrumb(handlerData: HandlerData & HandlerDataXhr): void { /** * Creates breadcrumbs from fetch API calls */ -function _fetchBreadcrumb(handlerData: HandlerData & HandlerDataFetch & { response?: Response }): void { +function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { const { startTimestamp, endTimestamp } = handlerData; // We only capture complete fetch requests @@ -283,13 +292,14 @@ function _fetchBreadcrumb(handlerData: HandlerData & HandlerDataFetch & { respon hint, ); } else { + const response = handlerData.response as Response | undefined; const data: FetchBreadcrumbData = { ...handlerData.fetchData, - status_code: handlerData.response && handlerData.response.status, + status_code: response && response.status, }; const hint: FetchBreadcrumbHint = { input: handlerData.args, - response: handlerData.response, + response, startTimestamp, endTimestamp, }; @@ -307,15 +317,15 @@ function _fetchBreadcrumb(handlerData: HandlerData & HandlerDataFetch & { respon /** * Creates breadcrumbs from history API calls */ -function _historyBreadcrumb(handlerData: HandlerData & { from: string; to: string }): void { +function _historyBreadcrumb(handlerData: HandlerDataHistory): void { let from: string | undefined = handlerData.from; let to: string | undefined = handlerData.to; const parsedLoc = parseUrl(WINDOW.location.href); - let parsedFrom = parseUrl(from); + let parsedFrom = from ? parseUrl(from) : undefined; const parsedTo = parseUrl(to); // Initial pushState doesn't provide `from` information - if (!parsedFrom.path) { + if (!parsedFrom || !parsedFrom.path) { parsedFrom = parsedLoc; } diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index af2f917daf96..47acd2a75140 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -1,7 +1,15 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { getCurrentHub } from '@sentry/core'; import type { Event, Hub, Integration, Primitive, StackParser } from '@sentry/types'; -import { addInstrumentationHandler, getLocationHref, isErrorEvent, isPrimitive, isString, logger } from '@sentry/utils'; +import { + addGlobalErrorInstrumentationHandler, + addGlobalUnhandledRejectionInstrumentationHandler, + getLocationHref, + isErrorEvent, + isPrimitive, + isString, + logger, +} from '@sentry/utils'; import type { BrowserClient } from '../client'; import { eventFromUnknownInput } from '../eventbuilder'; @@ -68,96 +76,97 @@ export class GlobalHandlers implements Integration { } } -/** JSDoc */ function _installGlobalOnErrorHandler(): void { - addInstrumentationHandler( - 'error', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (data: { msg: any; url: any; line: any; column: any; error: any }) => { - const [hub, stackParser, attachStacktrace] = getHubAndOptions(); - if (!hub.getIntegration(GlobalHandlers)) { - return; - } - const { msg, url, line, column, error } = data; - if (shouldIgnoreOnError() || (error && error.__sentry_own_request__)) { - return; - } + addGlobalErrorInstrumentationHandler(data => { + const [hub, stackParser, attachStacktrace] = getHubAndOptions(); + if (!hub.getIntegration(GlobalHandlers)) { + return; + } + const { msg, url, line, column, error } = data; + if (shouldIgnoreOnError()) { + return; + } - const event = - error === undefined && isString(msg) - ? _eventFromIncompleteOnError(msg, url, line, column) - : _enhanceEventWithInitialFrame( - eventFromUnknownInput(stackParser, error || msg, undefined, attachStacktrace, false), - url, - line, - column, - ); - - event.level = 'error'; - - hub.captureEvent(event, { - originalException: error, - mechanism: { - handled: false, - type: 'onerror', - }, - }); - }, - ); + const event = + error === undefined && isString(msg) + ? _eventFromIncompleteOnError(msg, url, line, column) + : _enhanceEventWithInitialFrame( + eventFromUnknownInput(stackParser, error || msg, undefined, attachStacktrace, false), + url, + line, + column, + ); + + event.level = 'error'; + + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'onerror', + }, + }); + }); } -/** JSDoc */ function _installGlobalOnUnhandledRejectionHandler(): void { - addInstrumentationHandler( - 'unhandledrejection', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (e: any) => { - const [hub, stackParser, attachStacktrace] = getHubAndOptions(); - if (!hub.getIntegration(GlobalHandlers)) { - return; - } - let error = e; - - // dig the object of the rejection out of known event types - try { - // PromiseRejectionEvents store the object of the rejection under 'reason' - // see https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent - if ('reason' in e) { - error = e.reason; - } - // something, somewhere, (likely a browser extension) effectively casts PromiseRejectionEvents - // to CustomEvents, moving the `promise` and `reason` attributes of the PRE into - // the CustomEvent's `detail` attribute, since they're not part of CustomEvent's spec - // see https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent and - // https://github.com/getsentry/sentry-javascript/issues/2380 - else if ('detail' in e && 'reason' in e.detail) { - error = e.detail.reason; - } - } catch (_oO) { - // no-empty - } + addGlobalUnhandledRejectionInstrumentationHandler(e => { + const [hub, stackParser, attachStacktrace] = getHubAndOptions(); + if (!hub.getIntegration(GlobalHandlers)) { + return; + } - if (shouldIgnoreOnError() || (error && error.__sentry_own_request__)) { - return true; - } + if (shouldIgnoreOnError()) { + return true; + } - const event = isPrimitive(error) - ? _eventFromRejectionWithPrimitive(error) - : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true); + const error = _getUnhandledRejectionError(e as unknown); - event.level = 'error'; + const event = isPrimitive(error) + ? _eventFromRejectionWithPrimitive(error) + : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true); - hub.captureEvent(event, { - originalException: error, - mechanism: { - handled: false, - type: 'onunhandledrejection', - }, - }); + event.level = 'error'; - return; - }, - ); + hub.captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }); + + return; + }); +} + +function _getUnhandledRejectionError(error: unknown): unknown { + if (isPrimitive(error)) { + return error; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const e = error as any; + + // dig the object of the rejection out of known event types + try { + // PromiseRejectionEvents store the object of the rejection under 'reason' + // see https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent + if ('reason' in e) { + return e.reason; + } + + // something, somewhere, (likely a browser extension) effectively casts PromiseRejectionEvents + // to CustomEvents, moving the `promise` and `reason` attributes of the PRE into + // the CustomEvent's `detail` attribute, since they're not part of CustomEvent's spec + // see https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent and + // https://github.com/getsentry/sentry-javascript/issues/2380 + else if ('detail' in e && 'reason' in e.detail) { + return e.detail.reason; + } + } catch {} // eslint-disable-line no-empty + + return error; } /** diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 7ff9cdc058df..0244af0ba98e 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -8,7 +8,12 @@ import { Integrations as CoreIntegrations, } from '@sentry/core'; import type { UserFeedback } from '@sentry/types'; -import { addInstrumentationHandler, logger, stackParserFromStackParserOptions, supportsFetch } from '@sentry/utils'; +import { + addHistoryInstrumentationHandler, + logger, + stackParserFromStackParserOptions, + supportsFetch, +} from '@sentry/utils'; import type { BrowserClientOptions, BrowserOptions } from './client'; import { BrowserClient } from './client'; @@ -255,9 +260,9 @@ function startSessionTracking(): void { startSessionOnHub(hub); // We want to create a session for every navigation as well - addInstrumentationHandler('history', ({ from, to }) => { + addHistoryInstrumentationHandler(({ from, to }) => { // Don't create an additional session for the initial route or if the location did not change - if (!(from === undefined || from === to)) { + if (from !== undefined && from !== to) { startSessionOnHub(getCurrentHub()); } }); diff --git a/packages/browser/test/integration/suites/onunhandledrejection.js b/packages/browser/test/integration/suites/onunhandledrejection.js index f9095d7c7333..8099fbbaa68e 100644 --- a/packages/browser/test/integration/suites/onunhandledrejection.js +++ b/packages/browser/test/integration/suites/onunhandledrejection.js @@ -243,24 +243,4 @@ describe('window.onunhandledrejection', function () { } }); }); - - it('should skip our own failed requests that somehow bubbled-up to unhandledrejection handler', function () { - return runInSandbox(sandbox, function () { - if (supportsOnunhandledRejection()) { - Promise.reject({ - __sentry_own_request__: true, - }); - Promise.reject({ - __sentry_own_request__: false, - }); - Promise.reject({}); - } else { - window.resolveTest({ window: window }); - } - }).then(function (summary) { - if (summary.window.supportsOnunhandledRejection()) { - assert.equal(summary.events.length, 2); - } - }); - }); }); diff --git a/packages/core/src/tracing/errors.ts b/packages/core/src/tracing/errors.ts index 8785bd86d448..477629cb7f9e 100644 --- a/packages/core/src/tracing/errors.ts +++ b/packages/core/src/tracing/errors.ts @@ -1,4 +1,8 @@ -import { addInstrumentationHandler, logger } from '@sentry/utils'; +import { + addGlobalErrorInstrumentationHandler, + addGlobalUnhandledRejectionInstrumentationHandler, + logger, +} from '@sentry/utils'; import type { SpanStatusType } from './span'; import { getActiveTransaction } from './utils'; @@ -14,8 +18,8 @@ export function registerErrorInstrumentation(): void { } errorsInstrumented = true; - addInstrumentationHandler('error', errorCallback); - addInstrumentationHandler('unhandledrejection', errorCallback); + addGlobalErrorInstrumentationHandler(errorCallback); + addGlobalUnhandledRejectionInstrumentationHandler(errorCallback); } /** diff --git a/packages/core/test/lib/tracing/errors.test.ts b/packages/core/test/lib/tracing/errors.test.ts index 8bf12f675032..4f72e7f74658 100644 --- a/packages/core/test/lib/tracing/errors.test.ts +++ b/packages/core/test/lib/tracing/errors.test.ts @@ -1,27 +1,26 @@ import { BrowserClient } from '@sentry/browser'; import { addTracingExtensions, Hub, makeMain } from '@sentry/core'; -import type { InstrumentHandlerCallback, InstrumentHandlerType } from '@sentry/utils'; +import type { HandlerDataError, HandlerDataUnhandledRejection } from '@sentry/types'; import { getDefaultBrowserClientOptions } from '../../../../tracing/test/testutils'; import { registerErrorInstrumentation } from '../../../src/tracing/errors'; -const mockAddInstrumentationHandler = jest.fn(); -let mockErrorCallback: InstrumentHandlerCallback = () => undefined; -let mockUnhandledRejectionCallback: InstrumentHandlerCallback = () => undefined; +const mockAddGlobalErrorInstrumentationHandler = jest.fn(); +const mockAddGlobalUnhandledRejectionInstrumentationHandler = jest.fn(); +let mockErrorCallback: (data: HandlerDataError) => void = () => {}; +let mockUnhandledRejectionCallback: (data: HandlerDataUnhandledRejection) => void = () => {}; jest.mock('@sentry/utils', () => { const actual = jest.requireActual('@sentry/utils'); return { ...actual, - addInstrumentationHandler: (type: InstrumentHandlerType, callback: InstrumentHandlerCallback) => { - if (type === 'error') { - mockErrorCallback = callback; - } - if (type === 'unhandledrejection') { - mockUnhandledRejectionCallback = callback; - } - if (typeof mockAddInstrumentationHandler === 'function') { - return mockAddInstrumentationHandler(type, callback); - } + addGlobalErrorInstrumentationHandler: (callback: () => void) => { + mockErrorCallback = callback; + + return mockAddGlobalErrorInstrumentationHandler(callback); + }, + addGlobalUnhandledRejectionInstrumentationHandler: (callback: () => void) => { + mockUnhandledRejectionCallback = callback; + return mockAddGlobalUnhandledRejectionInstrumentationHandler(callback); }, }; }); @@ -33,7 +32,8 @@ beforeAll(() => { describe('registerErrorHandlers()', () => { let hub: Hub; beforeEach(() => { - mockAddInstrumentationHandler.mockClear(); + mockAddGlobalErrorInstrumentationHandler.mockClear(); + mockAddGlobalUnhandledRejectionInstrumentationHandler.mockClear(); const options = getDefaultBrowserClientOptions(); hub = new Hub(new BrowserClient(options)); makeMain(hub); @@ -45,9 +45,10 @@ describe('registerErrorHandlers()', () => { it('registers error instrumentation', () => { registerErrorInstrumentation(); - expect(mockAddInstrumentationHandler).toHaveBeenCalledTimes(2); - expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(1, 'error', expect.any(Function)); - expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(2, 'unhandledrejection', expect.any(Function)); + expect(mockAddGlobalErrorInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(mockAddGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(mockAddGlobalErrorInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); + expect(mockAddGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); }); it('does not set status if transaction is not on scope', () => { @@ -55,7 +56,7 @@ describe('registerErrorHandlers()', () => { const transaction = hub.startTransaction({ name: 'test' }); expect(transaction.status).toBe(undefined); - mockErrorCallback({}); + mockErrorCallback({} as HandlerDataError); expect(transaction.status).toBe(undefined); mockUnhandledRejectionCallback({}); @@ -68,7 +69,7 @@ describe('registerErrorHandlers()', () => { const transaction = hub.startTransaction({ name: 'test' }); hub.configureScope(scope => scope.setSpan(transaction)); - mockErrorCallback({}); + mockErrorCallback({} as HandlerDataError); expect(transaction.status).toBe('internal_error'); transaction.finish(); diff --git a/packages/integrations/src/captureconsole.ts b/packages/integrations/src/captureconsole.ts index 124985662dd5..82dcfcde4886 100644 --- a/packages/integrations/src/captureconsole.ts +++ b/packages/integrations/src/captureconsole.ts @@ -1,7 +1,7 @@ import type { EventProcessor, Hub, Integration } from '@sentry/types'; import { + addConsoleInstrumentationHandler, addExceptionMechanism, - addInstrumentationHandler, CONSOLE_LEVELS, GLOBAL_OBJ, safeJoin, @@ -43,7 +43,7 @@ export class CaptureConsole implements Integration { const levels = this._levels; - addInstrumentationHandler('console', ({ args, level }: { args: unknown[]; level: string }) => { + addConsoleInstrumentationHandler(({ args, level }) => { if (!levels.includes(level)) { return; } diff --git a/packages/integrations/src/httpclient.ts b/packages/integrations/src/httpclient.ts index 6ceb832fc1ff..623b216e22fa 100644 --- a/packages/integrations/src/httpclient.ts +++ b/packages/integrations/src/httpclient.ts @@ -2,15 +2,14 @@ import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; import type { Event as SentryEvent, EventProcessor, - HandlerDataFetch, - HandlerDataXhr, Hub, Integration, SentryWrappedXMLHttpRequest, } from '@sentry/types'; import { addExceptionMechanism, - addInstrumentationHandler, + addFetchInstrumentationHandler, + addXhrInstrumentationHandler, GLOBAL_OBJ, logger, SENTRY_XHR_DATA_KEY, @@ -172,7 +171,7 @@ export class HttpClient implements Integration { const event = this._createEvent({ url: xhr.responseURL, - method: method, + method, status: xhr.status, requestHeaders, // Can't access request cookies from XHR @@ -300,7 +299,7 @@ export class HttpClient implements Integration { return; } - addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch & { response?: Response }) => { + addFetchInstrumentationHandler(handlerData => { const { response, args } = handlerData; const [requestInfo, requestInit] = args as [RequestInfo, RequestInit | undefined]; @@ -308,7 +307,7 @@ export class HttpClient implements Integration { return; } - this._fetchResponseHandler(requestInfo, response, requestInit); + this._fetchResponseHandler(requestInfo, response as Response, requestInit); }); } @@ -320,30 +319,23 @@ export class HttpClient implements Integration { return; } - addInstrumentationHandler( - 'xhr', - (handlerData: HandlerDataXhr & { xhr: SentryWrappedXMLHttpRequest & XMLHttpRequest }) => { - const { xhr } = handlerData; + addXhrInstrumentationHandler(handlerData => { + const xhr = handlerData.xhr as SentryWrappedXMLHttpRequest & XMLHttpRequest; - const sentryXhrData = xhr[SENTRY_XHR_DATA_KEY]; + const sentryXhrData = xhr[SENTRY_XHR_DATA_KEY]; - if (!sentryXhrData) { - return; - } - - const { method, request_headers: headers } = sentryXhrData; + if (!sentryXhrData) { + return; + } - if (!method) { - return; - } + const { method, request_headers: headers } = sentryXhrData; - try { - this._xhrResponseHandler(xhr, method, headers); - } catch (e) { - __DEBUG_BUILD__ && logger.warn('Error while extracting response event form XHR response', e); - } - }, - ); + try { + this._xhrResponseHandler(xhr, method, headers); + } catch (e) { + __DEBUG_BUILD__ && logger.warn('Error while extracting response event form XHR response', e); + } + }); } /** diff --git a/packages/integrations/test/captureconsole.test.ts b/packages/integrations/test/captureconsole.test.ts index bddf7ef603dd..3a75547592aa 100644 --- a/packages/integrations/test/captureconsole.test.ts +++ b/packages/integrations/test/captureconsole.test.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import type { Event, Hub, Integration } from '@sentry/types'; -import type { ConsoleLevel } from '@sentry/utils'; +import type { ConsoleLevel, Event, Hub, Integration } from '@sentry/types'; import { - addInstrumentationHandler, + addConsoleInstrumentationHandler, CONSOLE_LEVELS, GLOBAL_OBJ, originalConsoleMethods, @@ -45,7 +44,7 @@ function getMockHub(integration: Integration): Hub { describe('CaptureConsole setup', () => { // Ensure we've initialized the instrumentation so we can get the original one - addInstrumentationHandler('console', () => {}); + addConsoleInstrumentationHandler(() => {}); const _originalConsoleMethods = Object.assign({}, originalConsoleMethods); beforeEach(() => { diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 5d7ab3596c8e..8d45f4b9ddb3 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,6 +1,6 @@ import { WINDOW } from '@sentry/react'; -import type { HandlerDataFetch, Primitive, Transaction, TransactionContext } from '@sentry/types'; -import { addInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils'; +import type { Primitive, Transaction, TransactionContext } from '@sentry/types'; +import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils'; type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; @@ -36,7 +36,7 @@ export function appRouterInstrumentation( } if (startTransactionOnLocationChange) { - addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => { + addFetchInstrumentationHandler(handlerData => { // The instrumentation handler is invoked twice - once for starting a request and once when the req finishes // We can use the existence of the end-timestamp to filter out "finishing"-events. if (handlerData.endTimestamp !== undefined) { diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts index bebb0cbf8ab6..3337b99ab9a9 100644 --- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts +++ b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts @@ -5,7 +5,7 @@ import { JSDOM } from 'jsdom'; import { appRouterInstrumentation } from '../../src/client/routing/appRouterRoutingInstrumentation'; -const addInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addInstrumentationHandler'); +const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); function setUpPage(url: string) { const dom = new JSDOM('

nothingness

', { url }); @@ -55,7 +55,7 @@ describe('appRouterInstrumentation', () => { setUpPage('https://example.com/some/page?someParam=foobar'); let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - addInstrumentationHandlerSpy.mockImplementationOnce((_type, callback) => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { fetchInstrumentationHandlerCallback = callback; }); @@ -138,7 +138,7 @@ describe('appRouterInstrumentation', () => { setUpPage('https://example.com/some/page?someParam=foobar'); let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - addInstrumentationHandlerSpy.mockImplementationOnce((_type, callback) => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { fetchInstrumentationHandlerCallback = callback; }); @@ -151,11 +151,11 @@ describe('appRouterInstrumentation', () => { it('should not create navigation transactions when `startTransactionOnLocationChange` is false', () => { setUpPage('https://example.com/some/page?someParam=foobar'); - const addInstrumentationHandlerImpl = jest.fn(); + const addFetchInstrumentationHandlerImpl = jest.fn(); const startTransactionCallbackFn = jest.fn(); - addInstrumentationHandlerSpy.mockImplementationOnce(addInstrumentationHandlerImpl); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(addFetchInstrumentationHandlerImpl); appRouterInstrumentation(startTransactionCallbackFn, false, false); - expect(addInstrumentationHandlerImpl).not.toHaveBeenCalled(); + expect(addFetchInstrumentationHandlerImpl).not.toHaveBeenCalled(); }); }); diff --git a/packages/node/src/integrations/console.ts b/packages/node/src/integrations/console.ts index eb8a38980a64..9f39b41fe0b1 100644 --- a/packages/node/src/integrations/console.ts +++ b/packages/node/src/integrations/console.ts @@ -1,6 +1,6 @@ import { getCurrentHub } from '@sentry/core'; import type { Integration } from '@sentry/types'; -import { addInstrumentationHandler, severityLevelFromString } from '@sentry/utils'; +import { addConsoleInstrumentationHandler, severityLevelFromString } from '@sentry/utils'; import * as util from 'util'; /** Console module integration */ @@ -19,7 +19,7 @@ export class Console implements Integration { * @inheritDoc */ public setupOnce(): void { - addInstrumentationHandler('console', ({ args, level }: { args: unknown[]; level: string }) => { + addConsoleInstrumentationHandler(({ args, level }) => { const hub = getCurrentHub(); if (!hub.getIntegration(Console)) { diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts index 60220f0a5a66..53a92575b027 100644 --- a/packages/replay/src/coreHandlers/handleDom.ts +++ b/packages/replay/src/coreHandlers/handleDom.ts @@ -1,21 +1,20 @@ import { record } from '@sentry-internal/rrweb'; import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry-internal/rrweb-snapshot'; import { NodeType } from '@sentry-internal/rrweb-snapshot'; -import type { Breadcrumb } from '@sentry/types'; +import type { Breadcrumb, HandlerDataDom } from '@sentry/types'; import { htmlTreeAsString } from '@sentry/utils'; import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { handleClick } from './handleClick'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; -import type { DomHandlerData } from './util/domUtils'; import { getClickTargetNode, getTargetNode } from './util/domUtils'; import { getAttributesToRecord } from './util/getAttributesToRecord'; -export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = ( +export const handleDomListener: (replay: ReplayContainer) => (handlerData: HandlerDataDom) => void = ( replay: ReplayContainer, ) => { - return (handlerData: DomHandlerData): void => { + return (handlerData: HandlerDataDom): void => { if (!replay.isEnabled()) { return; } @@ -27,12 +26,13 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa } const isClick = handlerData.name === 'click'; - const event = isClick && (handlerData.event as PointerEvent); + const event = isClick ? (handlerData.event as PointerEvent) : undefined; // Ignore clicks if ctrl/alt/meta/shift keys are held down as they alter behavior of clicks (e.g. open in new tab) if ( isClick && replay.clickDetector && event && + event.target && !event.altKey && !event.metaKey && !event.ctrlKey && @@ -80,7 +80,7 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea * An event handler to react to DOM events. * Exported for tests. */ -export function handleDom(handlerData: DomHandlerData): Breadcrumb | null { +export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { const { target, message } = getDomTarget(handlerData); return createBreadcrumb({ @@ -89,7 +89,7 @@ export function handleDom(handlerData: DomHandlerData): Breadcrumb | null { }); } -function getDomTarget(handlerData: DomHandlerData): { target: Node | null; message: string } { +function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; message: string } { const isClick = handlerData.name === 'click'; let message: string | undefined; diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 36ec538f1b28..54681c2871c0 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -27,7 +27,7 @@ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerform } /** - * Returns a listener to be added to `addInstrumentationHandler('fetch', listener)`. + * Returns a listener to be added to `addFetchInstrumentationHandler(listener)`. */ export function handleFetchSpanListener(replay: ReplayContainer): (handlerData: HandlerDataFetch) => void { return (handlerData: HandlerDataFetch) => { diff --git a/packages/replay/src/coreHandlers/handleHistory.ts b/packages/replay/src/coreHandlers/handleHistory.ts index a72618a2203f..79167114c135 100644 --- a/packages/replay/src/coreHandlers/handleHistory.ts +++ b/packages/replay/src/coreHandlers/handleHistory.ts @@ -1,12 +1,9 @@ +import type { HandlerDataHistory } from '@sentry/types'; + import type { HistoryData, ReplayContainer, ReplayPerformanceEntry } from '../types'; import { createPerformanceSpans } from '../util/createPerformanceSpans'; -interface HistoryHandlerData { - from: string; - to: string; -} - -function handleHistory(handlerData: HistoryHandlerData): ReplayPerformanceEntry { +function handleHistory(handlerData: HandlerDataHistory): ReplayPerformanceEntry { const { from, to } = handlerData; const now = Date.now() / 1000; @@ -23,10 +20,10 @@ function handleHistory(handlerData: HistoryHandlerData): ReplayPerformanceEntry< } /** - * Returns a listener to be added to `addInstrumentationHandler('history', listener)`. + * Returns a listener to be added to `addHistoryInstrumentationHandler(listener)`. */ -export function handleHistorySpanListener(replay: ReplayContainer): (handlerData: HistoryHandlerData) => void { - return (handlerData: HistoryHandlerData) => { +export function handleHistorySpanListener(replay: ReplayContainer): (handlerData: HandlerDataHistory) => void { + return (handlerData: HandlerDataHistory) => { if (!replay.isEnabled()) { return; } diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index a8c14e46fad5..8fa2e1c7acb1 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -6,7 +6,7 @@ import type { TextEncoderInternal, XhrBreadcrumbData, } from '@sentry/types'; -import { addInstrumentationHandler, logger } from '@sentry/utils'; +import { addFetchInstrumentationHandler, addXhrInstrumentationHandler, logger } from '@sentry/utils'; import type { FetchHint, ReplayContainer, ReplayNetworkOptions, XhrHint } from '../types'; import { handleFetchSpanListener } from './handleFetch'; @@ -53,8 +53,8 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { client.on('beforeAddBreadcrumb', (breadcrumb, hint) => beforeAddNetworkBreadcrumb(options, breadcrumb, hint)); } else { // Fallback behavior - addInstrumentationHandler('fetch', handleFetchSpanListener(replay)); - addInstrumentationHandler('xhr', handleXhrSpanListener(replay)); + addFetchInstrumentationHandler(handleFetchSpanListener(replay)); + addXhrInstrumentationHandler(handleXhrSpanListener(replay)); } } catch { // Do nothing diff --git a/packages/replay/src/coreHandlers/handleXhr.ts b/packages/replay/src/coreHandlers/handleXhr.ts index d3ec1736427a..afe76411bdcf 100644 --- a/packages/replay/src/coreHandlers/handleXhr.ts +++ b/packages/replay/src/coreHandlers/handleXhr.ts @@ -34,7 +34,7 @@ export function handleXhr(handlerData: HandlerDataXhr): ReplayPerformanceEntry void { return (handlerData: HandlerDataXhr) => { diff --git a/packages/replay/src/coreHandlers/util/domUtils.ts b/packages/replay/src/coreHandlers/util/domUtils.ts index 5f45bfbd367d..83f34dc19f31 100644 --- a/packages/replay/src/coreHandlers/util/domUtils.ts +++ b/packages/replay/src/coreHandlers/util/domUtils.ts @@ -1,9 +1,5 @@ import type { INode } from '@sentry-internal/rrweb-snapshot'; - -export interface DomHandlerData { - name: string; - event: Node | { target: EventTarget }; -} +import type { HandlerDataDom } from '@sentry/types'; const INTERACTIVE_SELECTOR = 'button,a'; @@ -19,7 +15,7 @@ export function getClosestInteractive(element: Element): Element { * This is useful because if you click on the image in , * The target will be the image, not the button, which we don't want here */ -export function getClickTargetNode(event: DomHandlerData['event'] | MouseEvent): Node | INode | null { +export function getClickTargetNode(event: HandlerDataDom['event'] | MouseEvent | Node): Node | INode | null { const target = getTargetNode(event); if (!target || !(target instanceof Element)) { diff --git a/packages/replay/src/types/performance.ts b/packages/replay/src/types/performance.ts index 49590a361cdb..2fe87d24a9c8 100644 --- a/packages/replay/src/types/performance.ts +++ b/packages/replay/src/types/performance.ts @@ -131,7 +131,7 @@ export interface NetworkRequestData { } export interface HistoryData { - previous: string; + previous: string | undefined; } export type AllEntryData = AllPerformanceEntryData | MemoryData | NetworkRequestData | HistoryData; diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index cb7fbb75c00f..fb116438003e 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -1,7 +1,7 @@ import type { BaseClient } from '@sentry/core'; import { addGlobalEventProcessor, getClient, getCurrentHub } from '@sentry/core'; import type { Client, DynamicSamplingContext } from '@sentry/types'; -import { addInstrumentationHandler } from '@sentry/utils'; +import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler } from '@sentry/utils'; import { handleAfterSendEvent } from '../coreHandlers/handleAfterSendEvent'; import { handleDomListener } from '../coreHandlers/handleDom'; @@ -20,8 +20,8 @@ export function addGlobalListeners(replay: ReplayContainer): void { const client = getClient(); scope.addScopeListener(handleScopeListener(replay)); - addInstrumentationHandler('dom', handleDomListener(replay)); - addInstrumentationHandler('history', handleHistorySpanListener(replay)); + addClickKeypressInstrumentationHandler(handleDomListener(replay)); + addHistoryInstrumentationHandler(handleHistorySpanListener(replay)); handleNetworkBreadcrumbs(replay); // Tag all (non replay) events that get sent to Sentry with the current diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts index 4059b71fe195..cfa0e80e17f6 100644 --- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -9,6 +9,7 @@ import { createPerformanceEntries } from '../../src/util/createPerformanceEntrie import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import type { DomHandler } from '../types'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); @@ -25,15 +26,13 @@ describe('Integration | beforeAddRecordingEvent', () => { let integration: Replay; let mockTransportSend: MockTransportSend; let mockSendReplayRequest: jest.SpyInstance; - let domHandler: (args: any) => any; + let domHandler: DomHandler; const { record: mockRecord } = mockRrweb(); beforeAll(async () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } + jest.spyOn(SentryUtils, 'addClickKeypressInstrumentationHandler').mockImplementation(handler => { + domHandler = handler; }); ({ replay, integration } = await mockSdk({ @@ -104,6 +103,7 @@ describe('Integration | beforeAddRecordingEvent', () => { it('changes click breadcrumbs message', async () => { domHandler({ name: 'click', + event: new Event('click'), }); await advanceTimers(5000); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index f4be5232cffe..4f83f150548f 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -70,6 +70,7 @@ describe('Integration | errorSampleRate', () => { // Does not capture on mouse click domHandler({ name: 'click', + event: new Event('click'), }); jest.runAllTimers(); await new Promise(process.nextTick); @@ -121,6 +122,7 @@ describe('Integration | errorSampleRate', () => { // Check that click will get captured domHandler({ name: 'click', + event: new Event('click'), }); await waitForFlush(); @@ -156,6 +158,7 @@ describe('Integration | errorSampleRate', () => { // Does not capture on mouse click domHandler({ name: 'click', + event: new Event('click'), }); jest.runAllTimers(); await new Promise(process.nextTick); @@ -195,6 +198,7 @@ describe('Integration | errorSampleRate', () => { // Check that click will not get captured domHandler({ name: 'click', + event: new Event('click'), }); await waitForFlush(); @@ -239,6 +243,7 @@ describe('Integration | errorSampleRate', () => { // Does not capture on mouse click domHandler({ name: 'click', + event: new Event('click'), }); jest.runAllTimers(); await new Promise(process.nextTick); @@ -279,6 +284,7 @@ describe('Integration | errorSampleRate', () => { // Check that click will not get captured domHandler({ name: 'click', + event: new Event('click'), }); await waitForFlush(); @@ -517,6 +523,7 @@ describe('Integration | errorSampleRate', () => { domHandler({ name: 'click', + event: new Event('click'), }); await waitForFlush(); diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index d7c8b0033561..b40d6c78906d 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -9,6 +9,7 @@ import { createPerformanceEntries } from '../../src/util/createPerformanceEntrie import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import * as SendReplay from '../../src/util/sendReplay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import type { DomHandler } from '../types'; import { getTestEventCheckout } from '../utils/getTestEvent'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -30,7 +31,7 @@ const prevLocation = WINDOW.location; const prevBrowserPerformanceTimeOrigin = SentryUtils.browserPerformanceTimeOrigin; describe('Integration | flush', () => { - let domHandler: (args: any) => any; + let domHandler: DomHandler; const { record: mockRecord } = mockRrweb(); @@ -43,10 +44,8 @@ describe('Integration | flush', () => { let mockAddPerformanceEntries: MockAddPerformanceEntries; beforeAll(async () => { - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } + jest.spyOn(SentryUtils, 'addClickKeypressInstrumentationHandler').mockImplementation(handler => { + domHandler = handler; }); ({ replay } = await mockSdk()); @@ -162,6 +161,7 @@ describe('Integration | flush', () => { // This will attempt to flush in 5 seconds (flushMinDelay) domHandler({ name: 'click', + event: new Event('click'), }); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); // flush #2 @ t=5s - due to click @@ -259,6 +259,7 @@ describe('Integration | flush', () => { // click happens first domHandler({ name: 'click', + event: new Event('click'), }); // checkout @@ -284,6 +285,7 @@ describe('Integration | flush', () => { // click happens first domHandler({ name: 'click', + event: new Event('click'), }); // checkout @@ -324,6 +326,7 @@ describe('Integration | flush', () => { // click happens first domHandler({ name: 'click', + event: new Event('click'), }); // checkout @@ -355,6 +358,7 @@ describe('Integration | flush', () => { // click happens first domHandler({ name: 'click', + event: new Event('click'), }); // no checkout! diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts index 4aef1ee94776..8ca71236bb41 100644 --- a/packages/replay/test/integration/sendReplayEvent.test.ts +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -8,6 +8,7 @@ import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import type { DomHandler } from '../types'; import { getTestEventCheckout, getTestEventIncremental } from '../utils/getTestEvent'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -24,15 +25,13 @@ describe('Integration | sendReplayEvent', () => { let replay: ReplayContainer; let mockTransportSend: MockTransportSend; let mockSendReplayRequest: jest.SpyInstance; - let domHandler: (args: any) => any; + let domHandler: DomHandler; const { record: mockRecord } = mockRrweb(); beforeAll(async () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } + jest.spyOn(SentryUtils, 'addClickKeypressInstrumentationHandler').mockImplementation(handler => { + domHandler = handler; }); ({ replay } = await mockSdk({ @@ -115,6 +114,7 @@ describe('Integration | sendReplayEvent', () => { domHandler({ name: 'click', + event: new Event('click'), }); expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); @@ -125,6 +125,7 @@ describe('Integration | sendReplayEvent', () => { domHandler({ name: 'click', + event: new Event('click'), }); expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED); @@ -135,6 +136,7 @@ describe('Integration | sendReplayEvent', () => { domHandler({ name: 'input', + event: new Event('keypress'), }); expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); @@ -145,6 +147,7 @@ describe('Integration | sendReplayEvent', () => { domHandler({ name: 'input', + event: new Event('keypress'), }); expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED); @@ -273,6 +276,7 @@ describe('Integration | sendReplayEvent', () => { it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => { domHandler({ name: 'click', + event: new Event('click'), }); // Pretend 5 seconds have passed diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 3716c8b33bc7..86e1fa16cc9e 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -18,6 +18,7 @@ import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; import { BASE_TIMESTAMP } from '../index'; import type { RecordMock } from '../mocks/mockRrweb'; import { resetSdkMock } from '../mocks/resetSdkMock'; +import type { DomHandler } from '../types'; import { getTestEventCheckout, getTestEventIncremental } from '../utils/getTestEvent'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -32,7 +33,7 @@ const prevLocation = WINDOW.location; describe('Integration | session', () => { let replay: ReplayContainer; - let domHandler: (args: any) => any; + let domHandler: DomHandler; let mockRecord: RecordMock; beforeEach(async () => { @@ -185,6 +186,7 @@ describe('Integration | session', () => { // Now do a click which will create a new session and start recording again domHandler({ name: 'click', + event: new Event('click'), }); const optionsEvent = createOptionsEvent(replay); @@ -294,6 +296,7 @@ describe('Integration | session', () => { // Now do a click which will create a new session and start recording again domHandler({ name: 'click', + event: new Event('click'), }); // Should be same session @@ -367,6 +370,7 @@ describe('Integration | session', () => { // Now do a click domHandler({ name: 'click', + event: new Event('click'), }); // 20 is for the process.nextTick diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index 04c85a9dedde..043dd76bcddc 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -20,18 +20,14 @@ describe('Integration | stop', () => { let integration: Replay; const prevLocation = WINDOW.location; - type MockAddInstrumentationHandler = jest.MockedFunction; const { record: mockRecord } = mockRrweb(); - let mockAddInstrumentationHandler: MockAddInstrumentationHandler; + let mockAddDomInstrumentationHandler: jest.SpyInstance; let mockRunFlush: MockRunFlush; beforeAll(async () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); - mockAddInstrumentationHandler = jest.spyOn( - SentryUtils, - 'addInstrumentationHandler', - ) as MockAddInstrumentationHandler; + mockAddDomInstrumentationHandler = jest.spyOn(SentryUtils, 'addClickKeypressInstrumentationHandler'); ({ replay, integration } = await mockSdk()); @@ -56,7 +52,7 @@ describe('Integration | stop', () => { replay['_initializeSessionForSampling'](); replay.setInitialState(); mockRecord.takeFullSnapshot.mockClear(); - mockAddInstrumentationHandler.mockClear(); + mockAddDomInstrumentationHandler.mockClear(); Object.defineProperty(WINDOW, 'location', { value: prevLocation, writable: true, @@ -166,13 +162,13 @@ describe('Integration | stop', () => { }); }); - it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () { - // NOTE: We clear addInstrumentationHandler mock after every test + it('does not call core SDK `addClickKeypressInstrumentationHandler` after initial setup', async function () { + // NOTE: We clear mockAddDomInstrumentationHandler after every test await integration.stop(); integration.start(); await integration.stop(); integration.start(); - expect(mockAddInstrumentationHandler).not.toHaveBeenCalled(); + expect(mockAddDomInstrumentationHandler).not.toHaveBeenCalled(); }); }); diff --git a/packages/replay/test/mocks/resetSdkMock.ts b/packages/replay/test/mocks/resetSdkMock.ts index aa92cfead4a8..7b3fb7a169b1 100644 --- a/packages/replay/test/mocks/resetSdkMock.ts +++ b/packages/replay/test/mocks/resetSdkMock.ts @@ -26,10 +26,8 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }: getGlobalSingleton('globalEventProcessors', () => []).length = 0; const SentryUtils = await import('@sentry/utils'); - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } + jest.spyOn(SentryUtils, 'addClickKeypressInstrumentationHandler').mockImplementation(handler => { + domHandler = handler; }); const { mockRrweb } = await import('./mockRrweb'); const { record: mockRecord } = mockRrweb(); diff --git a/packages/replay/test/types.ts b/packages/replay/test/types.ts index 80a3423dd553..336f11b7bed9 100644 --- a/packages/replay/test/types.ts +++ b/packages/replay/test/types.ts @@ -1 +1,3 @@ -export type DomHandler = (args: any) => any; +import type { HandlerDataDom } from '@sentry/types'; + +export type DomHandler = (data: HandlerDataDom) => void; diff --git a/packages/replay/test/unit/coreHandlers/handleDom.test.ts b/packages/replay/test/unit/coreHandlers/handleDom.test.ts index 99fa5dc1e367..29f39a6c0a42 100644 --- a/packages/replay/test/unit/coreHandlers/handleDom.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleDom.test.ts @@ -1,5 +1,6 @@ +import type { HandlerDataDom } from '@sentry/types'; + import { handleDom } from '../../../src/coreHandlers/handleDom'; -import type { DomHandlerData } from '../../../src/coreHandlers/util/domUtils'; describe('Unit | coreHandlers | handleDom', () => { test('it works with a basic click event on a div', () => { @@ -8,7 +9,7 @@ describe('Unit | coreHandlers | handleDom', () => { target.classList.add('my-class', 'other-class'); parent.appendChild(target); - const handlerData: DomHandlerData = { + const handlerData: HandlerDataDom = { name: 'click', event: { target, @@ -30,7 +31,7 @@ describe('Unit | coreHandlers | handleDom', () => { target.classList.add('my-class', 'other-class'); parent.appendChild(target); - const handlerData: DomHandlerData = { + const handlerData: HandlerDataDom = { name: 'click', event: { target, @@ -55,7 +56,7 @@ describe('Unit | coreHandlers | handleDom', () => { const target = document.createElement('span'); interactive.appendChild(target); - const handlerData: DomHandlerData = { + const handlerData: HandlerDataDom = { name: 'click', event: { target, @@ -80,7 +81,7 @@ describe('Unit | coreHandlers | handleDom', () => { const target = document.createElement('span'); interactive.appendChild(target); - const handlerData: DomHandlerData = { + const handlerData: HandlerDataDom = { name: 'click', event: { target, @@ -111,7 +112,7 @@ describe('Unit | coreHandlers | handleDom', () => { target.classList.add('my-class', 'other-class'); current.appendChild(target); - const handlerData: DomHandlerData = { + const handlerData: HandlerDataDom = { name: 'click', event: { target, diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 99b377ba7d17..2d012bd936de 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -118,6 +118,8 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { value: 'test response', }); xhr[SENTRY_XHR_DATA_KEY] = { + method: 'GET', + url: 'https://example.com', request_headers: { 'content-type': 'text/plain', 'other-header': 'test', diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index 06d60f219530..78e334e98549 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -1,8 +1,9 @@ /* eslint-disable max-lines */ import { getCurrentHub, getDynamicSamplingContextFromClient, hasTracingEnabled } from '@sentry/core'; -import type { HandlerDataFetch, Span } from '@sentry/types'; +import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/types'; import { - addInstrumentationHandler, + addFetchInstrumentationHandler, + addXhrInstrumentationHandler, BAGGAGE_HEADER_NAME, browserPerformanceTimeOrigin, dynamicSamplingContextToSentryBaggageHeader, @@ -66,25 +67,6 @@ export interface RequestInstrumentationOptions { shouldCreateSpanForRequest?(this: void, url: string): boolean; } -/** Data returned from XHR request */ -export interface XHRData { - xhr?: { - [SENTRY_XHR_DATA_KEY]?: { - method: string; - url: string; - status_code: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: Record; - }; - __sentry_xhr_span_id__?: string; - setRequestHeader?: (key: string, val: string) => void; - getRequestHeader?: (key: string) => string; - __sentry_own_request__?: boolean; - }; - startTimestamp: number; - endTimestamp?: number; -} - export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = { traceFetch: true, traceXHR: true, @@ -123,7 +105,7 @@ export function instrumentOutgoingRequests(_options?: Partial = {}; if (traceFetch) { - addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => { + addFetchInstrumentationHandler(handlerData => { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); if (enableHTTPTimings && createdSpan) { addHTTPTimings(createdSpan); @@ -132,7 +114,7 @@ export function instrumentOutgoingRequests(_options?: Partial { + addXhrInstrumentationHandler(handlerData => { const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); if (enableHTTPTimings && createdSpan) { addHTTPTimings(createdSpan); @@ -252,7 +234,7 @@ export function shouldAttachHeaders(url: string, tracePropagationTargets: (strin */ // eslint-disable-next-line complexity export function xhrCallback( - handlerData: XHRData, + handlerData: HandlerDataXhr, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, @@ -260,7 +242,7 @@ export function xhrCallback( const xhr = handlerData.xhr; const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY]; - if (!hasTracingEnabled() || (xhr && xhr.__sentry_own_request__) || !xhr || !sentryXhrData) { + if (!hasTracingEnabled() || !xhr || xhr.__sentry_own_request__ || !sentryXhrData) { return undefined; } @@ -272,7 +254,7 @@ export function xhrCallback( if (!spanId) return; const span = spans[spanId]; - if (span) { + if (span && sentryXhrData.status_code !== undefined) { span.setHttpStatus(sentryXhrData.status_code); span.finish(); @@ -290,7 +272,6 @@ export function xhrCallback( shouldCreateSpanResult && parentSpan ? parentSpan.startChild({ data: { - ...sentryXhrData.data, type: 'xhr', 'http.method': sentryXhrData.method, url: sentryXhrData.url, @@ -327,7 +308,7 @@ export function xhrCallback( } function setHeaderOnXhr( - xhr: NonNullable, + xhr: SentryWrappedXMLHttpRequest, sentryTraceHeader: string, sentryBaggageHeader: string | undefined, ): void { diff --git a/packages/tracing-internal/src/browser/router.ts b/packages/tracing-internal/src/browser/router.ts index c796e44d3783..98656d4d3f2a 100644 --- a/packages/tracing-internal/src/browser/router.ts +++ b/packages/tracing-internal/src/browser/router.ts @@ -1,5 +1,5 @@ import type { Transaction, TransactionContext } from '@sentry/types'; -import { addInstrumentationHandler, browserPerformanceTimeOrigin, logger } from '@sentry/utils'; +import { addHistoryInstrumentationHandler, browserPerformanceTimeOrigin, logger } from '@sentry/utils'; import { WINDOW } from './types'; @@ -31,7 +31,7 @@ export function instrumentRoutingWithDefaults( } if (startTransactionOnLocationChange) { - addInstrumentationHandler('history', ({ to, from }: { to: string; from?: string }) => { + addHistoryInstrumentationHandler(({ to, from }) => { /** * This early return is there to account for some cases where a navigation transaction starts right after * long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't diff --git a/packages/tracing-internal/test/browser/browsertracing.test.ts b/packages/tracing-internal/test/browser/browsertracing.test.ts index 8a65f6cf8fbe..d1ce55644e6c 100644 --- a/packages/tracing-internal/test/browser/browsertracing.test.ts +++ b/packages/tracing-internal/test/browser/browsertracing.test.ts @@ -1,8 +1,7 @@ /* eslint-disable deprecation/deprecation */ import { Hub, makeMain, TRACING_DEFAULTS } from '@sentry/core'; import * as hubExtensions from '@sentry/core'; -import type { BaseTransportOptions, ClientOptions, DsnComponents } from '@sentry/types'; -import type { InstrumentHandlerCallback, InstrumentHandlerType } from '@sentry/utils'; +import type { BaseTransportOptions, ClientOptions, DsnComponents, HandlerDataHistory } from '@sentry/types'; import { JSDOM } from 'jsdom'; import type { IdleTransaction } from '../../../tracing/src'; @@ -15,17 +14,15 @@ import { instrumentRoutingWithDefaults } from '../../src/browser/router'; import { WINDOW } from '../../src/browser/types'; import { TestClient } from '../utils/TestClient'; -let mockChangeHistory: ({ to, from }: { to: string; from?: string }) => void = () => undefined; +let mockChangeHistory: (data: HandlerDataHistory) => void = () => {}; jest.mock('@sentry/utils', () => { const actual = jest.requireActual('@sentry/utils'); return { ...actual, - addInstrumentationHandler: (type: InstrumentHandlerType, callback: InstrumentHandlerCallback): void => { - if (type === 'history') { - // rather than actually add the navigation-change handler, grab a reference to it, so we can trigger it manually - mockChangeHistory = callback; - } + + addHistoryInstrumentationHandler: (callback: (data: HandlerDataHistory) => void): void => { + mockChangeHistory = callback; }, }; }); diff --git a/packages/tracing-internal/test/browser/request.test.ts b/packages/tracing-internal/test/browser/request.test.ts index b1e46bd05abe..54a33396df39 100644 --- a/packages/tracing-internal/test/browser/request.test.ts +++ b/packages/tracing-internal/test/browser/request.test.ts @@ -1,13 +1,12 @@ /* eslint-disable deprecation/deprecation */ import * as sentryCore from '@sentry/core'; -import type { HandlerDataFetch } from '@sentry/types'; +import type { HandlerDataFetch, HandlerDataXhr, SentryWrappedXMLHttpRequest } from '@sentry/types'; import * as utils from '@sentry/utils'; import { SENTRY_XHR_DATA_KEY } from '@sentry/utils'; import type { Transaction } from '../../../tracing/src'; import { addExtensionMethods, Span, spanStatusfromHttpCode } from '../../../tracing/src'; import { getDefaultBrowserClientOptions } from '../../../tracing/test/testutils'; -import type { XHRData } from '../../src/browser/request'; import { extractNetworkProtocol, instrumentOutgoingRequests, @@ -25,7 +24,6 @@ beforeAll(() => { }); const hasTracingEnabled = jest.spyOn(sentryCore, 'hasTracingEnabled'); -const addInstrumentationHandler = jest.spyOn(utils, 'addInstrumentationHandler'); const setRequestHeader = jest.fn(); describe('instrumentOutgoingRequests', () => { @@ -34,22 +32,29 @@ describe('instrumentOutgoingRequests', () => { }); it('instruments fetch and xhr requests', () => { + const addFetchSpy = jest.spyOn(utils, 'addFetchInstrumentationHandler'); + const addXhrSpy = jest.spyOn(utils, 'addXhrInstrumentationHandler'); + instrumentOutgoingRequests(); - expect(addInstrumentationHandler).toHaveBeenCalledWith('fetch', expect.any(Function)); - expect(addInstrumentationHandler).toHaveBeenCalledWith('xhr', expect.any(Function)); + expect(addFetchSpy).toHaveBeenCalledWith(expect.any(Function)); + expect(addXhrSpy).toHaveBeenCalledWith(expect.any(Function)); }); it('does not instrument fetch requests if traceFetch is false', () => { + const addFetchSpy = jest.spyOn(utils, 'addFetchInstrumentationHandler'); + instrumentOutgoingRequests({ traceFetch: false }); - expect(addInstrumentationHandler).not.toHaveBeenCalledWith('fetch', expect.any(Function)); + expect(addFetchSpy).not.toHaveBeenCalled(); }); it('does not instrument xhr requests if traceXHR is false', () => { + const addXhrSpy = jest.spyOn(utils, 'addXhrInstrumentationHandler'); + instrumentOutgoingRequests({ traceXHR: false }); - expect(addInstrumentationHandler).not.toHaveBeenCalledWith('xhr', expect.any(Function)); + expect(addXhrSpy).not.toHaveBeenCalled(); }); }); @@ -155,7 +160,8 @@ describe('callbacks', () => { }); expect(newSpan.description).toBe('GET http://dogs.are.great/'); expect(newSpan.op).toBe('http.client'); - expect(fetchHandlerData.fetchData?.__span).toBeDefined(); + const spanId = fetchHandlerData.fetchData?.__span; + expect(spanId).toBeDefined(); const postRequestFetchHandlerData = { ...fetchHandlerData, @@ -261,7 +267,7 @@ describe('callbacks', () => { }); describe('xhrCallback()', () => { - let xhrHandlerData: XHRData; + let xhrHandlerData: HandlerDataXhr; const xhrSpan = { data: { @@ -279,16 +285,17 @@ describe('callbacks', () => { beforeEach(() => { xhrHandlerData = { + args: ['GET', 'http://dogs.are.great/'], xhr: { [SENTRY_XHR_DATA_KEY]: { method: 'GET', url: 'http://dogs.are.great/', status_code: 200, - data: {}, + request_headers: {}, }, __sentry_xhr_span_id__: '1231201211212012', setRequestHeader, - }, + } as SentryWrappedXMLHttpRequest, startTimestamp, }; }); @@ -344,8 +351,9 @@ describe('callbacks', () => { }); expect(newSpan.description).toBe('GET http://dogs.are.great/'); expect(newSpan.op).toBe('http.client'); - expect(xhrHandlerData.xhr?.__sentry_xhr_span_id__).toBeDefined(); - expect(xhrHandlerData.xhr?.__sentry_xhr_span_id__).toEqual(newSpan?.spanId); + const spanId = xhrHandlerData.xhr?.__sentry_xhr_span_id__; + expect(spanId).toBeDefined(); + expect(spanId).toEqual(newSpan?.spanId); const postRequestXHRHandlerData = { ...xhrHandlerData, @@ -383,12 +391,13 @@ describe('callbacks', () => { it('ignores response with no associated span', () => { // the request might be missed somehow. E.g. if it was sent before tracing gets enabled. - const postRequestXHRHandlerData = { + const postRequestXHRHandlerData: HandlerDataXhr = { ...{ xhr: { [SENTRY_XHR_DATA_KEY]: xhrHandlerData.xhr?.[SENTRY_XHR_DATA_KEY], }, }, + args: ['GET', 'http://dogs.are.great/'], startTimestamp, endTimestamp, }; diff --git a/packages/tracing-internal/test/browser/router.test.ts b/packages/tracing-internal/test/browser/router.test.ts index 2b57ad4bd57f..a78d9e5631bd 100644 --- a/packages/tracing-internal/test/browser/router.test.ts +++ b/packages/tracing-internal/test/browser/router.test.ts @@ -1,17 +1,15 @@ -import type { InstrumentHandlerCallback, InstrumentHandlerType } from '@sentry/utils'; +import type { HandlerDataHistory } from '@sentry/types'; import { JSDOM } from 'jsdom'; import { conditionalTest } from '../../../tracing/test/testutils'; import { instrumentRoutingWithDefaults } from '../../src/browser/router'; -let mockChangeHistory: ({ to, from }: { to: string; from?: string }) => void = () => undefined; -let addInstrumentationHandlerType: string = ''; +let mockChangeHistory: undefined | ((data: HandlerDataHistory) => void); jest.mock('@sentry/utils', () => { const actual = jest.requireActual('@sentry/utils'); return { ...actual, - addInstrumentationHandler: (type: InstrumentHandlerType, callback: InstrumentHandlerCallback): void => { - addInstrumentationHandlerType = type; + addHistoryInstrumentationHandler: (callback: (data: HandlerDataHistory) => void): void => { mockChangeHistory = callback; }, }; @@ -59,8 +57,7 @@ conditionalTest({ min: 16 })('instrumentRoutingWithDefaults', () => { describe('navigation transaction', () => { beforeEach(() => { - mockChangeHistory = () => undefined; - addInstrumentationHandlerType = ''; + mockChangeHistory = undefined; }); it('it is not created automatically', () => { @@ -75,8 +72,8 @@ conditionalTest({ min: 16 })('instrumentRoutingWithDefaults', () => { it('is created on location change', () => { instrumentRoutingWithDefaults(customStartTransaction); - mockChangeHistory({ to: 'here', from: 'there' }); - expect(addInstrumentationHandlerType).toBe('history'); + expect(mockChangeHistory).toBeDefined(); + mockChangeHistory!({ to: 'here', from: 'there' }); expect(customStartTransaction).toHaveBeenCalledTimes(2); expect(customStartTransaction).toHaveBeenLastCalledWith({ @@ -89,8 +86,7 @@ conditionalTest({ min: 16 })('instrumentRoutingWithDefaults', () => { it('is not created if startTransactionOnLocationChange is false', () => { instrumentRoutingWithDefaults(customStartTransaction, true, false); - mockChangeHistory({ to: 'here', from: 'there' }); - expect(addInstrumentationHandlerType).toBe(''); + expect(mockChangeHistory).toBeUndefined(); expect(customStartTransaction).toHaveBeenCalledTimes(1); }); @@ -98,27 +94,31 @@ conditionalTest({ min: 16 })('instrumentRoutingWithDefaults', () => { it('finishes the last active transaction', () => { instrumentRoutingWithDefaults(customStartTransaction); + expect(mockChangeHistory).toBeDefined(); + expect(mockFinish).toHaveBeenCalledTimes(0); - mockChangeHistory({ to: 'here', from: 'there' }); + mockChangeHistory!({ to: 'here', from: 'there' }); expect(mockFinish).toHaveBeenCalledTimes(1); }); it('will finish active transaction multiple times', () => { instrumentRoutingWithDefaults(customStartTransaction); + expect(mockChangeHistory).toBeDefined(); + expect(mockFinish).toHaveBeenCalledTimes(0); - mockChangeHistory({ to: 'here', from: 'there' }); + mockChangeHistory!({ to: 'here', from: 'there' }); expect(mockFinish).toHaveBeenCalledTimes(1); - mockChangeHistory({ to: 'over/there', from: 'here' }); + mockChangeHistory!({ to: 'over/there', from: 'here' }); expect(mockFinish).toHaveBeenCalledTimes(2); - mockChangeHistory({ to: 'nowhere', from: 'over/there' }); + mockChangeHistory!({ to: 'nowhere', from: 'over/there' }); expect(mockFinish).toHaveBeenCalledTimes(3); }); it('not created if `from` is equal to `to`', () => { instrumentRoutingWithDefaults(customStartTransaction); - mockChangeHistory({ to: 'first/path', from: 'first/path' }); - expect(addInstrumentationHandlerType).toBe('history'); + expect(mockChangeHistory).toBeDefined(); + mockChangeHistory!({ to: 'first/path', from: 'first/path' }); expect(customStartTransaction).toHaveBeenCalledTimes(1); expect(customStartTransaction).not.toHaveBeenLastCalledWith('navigation'); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 27068f8080f8..4c648f35363e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -124,7 +124,18 @@ export type { export type { User, UserFeedback } from './user'; export type { WrappedFunction } from './wrappedfunction'; export type { Instrumenter } from './instrumenter'; -export type { HandlerDataFetch, HandlerDataXhr, SentryXhrData, SentryWrappedXMLHttpRequest } from './instrument'; +export type { + HandlerDataFetch, + HandlerDataXhr, + HandlerDataDom, + HandlerDataConsole, + HandlerDataHistory, + HandlerDataError, + HandlerDataUnhandledRejection, + ConsoleLevel, + SentryXhrData, + SentryWrappedXMLHttpRequest, +} from './instrument'; export type { BrowserClientReplayOptions, BrowserClientProfilingOptions } from './browseroptions'; export type { CheckIn, MonitorConfig, FinishedCheckIn, InProgressCheckIn, SerializedCheckIn } from './checkin'; diff --git a/packages/types/src/instrument.ts b/packages/types/src/instrument.ts index d3581d694312..e51f9a3eec44 100644 --- a/packages/types/src/instrument.ts +++ b/packages/types/src/instrument.ts @@ -3,15 +3,21 @@ // Make sure to cast it where needed! type XHRSendInput = unknown; +export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace'; + export interface SentryWrappedXMLHttpRequest { - __sentry_xhr_v2__?: SentryXhrData; + __sentry_xhr_v3__?: SentryXhrData; __sentry_own_request__?: boolean; + // span id for the xhr request + __sentry_xhr_span_id__?: string; + setRequestHeader?: (key: string, val: string) => void; + getResponseHeader?: (key: string) => string | null; } // WARNING: When the shape of this type is changed bump the version in `SentryWrappedXMLHttpRequest` export interface SentryXhrData { - method?: string; - url?: string; + method: string; + url: string; status_code?: number; body?: XHRSendInput; request_body_size?: number; @@ -20,6 +26,9 @@ export interface SentryXhrData { } export interface HandlerDataXhr { + /** + * @deprecated This property will be removed in v8. + */ args: [string, string]; xhr: SentryWrappedXMLHttpRequest; startTimestamp?: number; @@ -55,3 +64,29 @@ export interface HandlerDataFetch { }; error?: unknown; } + +export interface HandlerDataDom { + event: Event | { target: EventTarget }; + name: string; + global?: boolean; +} + +export interface HandlerDataConsole { + level: ConsoleLevel; + args: any[]; +} + +export interface HandlerDataHistory { + from: string | undefined; + to: string; +} + +export interface HandlerDataError { + column?: number; + error?: Error; + line?: number; + msg: string | Event; + url?: string; +} + +export type HandlerDataUnhandledRejection = unknown; diff --git a/packages/utils/src/error.ts b/packages/utils/src/error.ts index ae64f09a3643..af0f80e9dce7 100644 --- a/packages/utils/src/error.ts +++ b/packages/utils/src/error.ts @@ -1,4 +1,4 @@ -import type { ConsoleLevel } from './logger'; +import type { ConsoleLevel } from '@sentry/types'; /** An error emitted by Sentry SDKs and related utilities. */ export class SentryError extends Error { diff --git a/packages/utils/src/instrument.ts b/packages/utils/src/instrument.ts deleted file mode 100644 index 5d5ce0f7c616..000000000000 --- a/packages/utils/src/instrument.ts +++ /dev/null @@ -1,690 +0,0 @@ -/* eslint-disable max-lines */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ -import type { - HandlerDataFetch, - HandlerDataXhr, - SentryWrappedXMLHttpRequest, - SentryXhrData, - WrappedFunction, -} from '@sentry/types'; - -import { isString } from './is'; -import type { ConsoleLevel } from './logger'; -import { CONSOLE_LEVELS, logger, originalConsoleMethods } from './logger'; -import { uuid4 } from './misc'; -import { addNonEnumerableProperty, fill } from './object'; -import { getFunctionName } from './stacktrace'; -import { supportsHistory, supportsNativeFetch } from './supports'; -import { getGlobalObject, GLOBAL_OBJ } from './worldwide'; - -// eslint-disable-next-line deprecation/deprecation -const WINDOW = getGlobalObject(); - -export const SENTRY_XHR_DATA_KEY = '__sentry_xhr_v2__'; - -export type InstrumentHandlerType = - | 'console' - | 'dom' - | 'fetch' - | 'history' - | 'sentry' - | 'xhr' - | 'error' - | 'unhandledrejection'; -export type InstrumentHandlerCallback = (data: any) => void; - -/** - * Instrument native APIs to call handlers that can be used to create breadcrumbs, APM spans etc. - * - Console API - * - Fetch API - * - XHR API - * - History API - * - DOM API (click/typing) - * - Error API - * - UnhandledRejection API - */ - -const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {}; -const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; - -/** Instruments given API */ -function instrument(type: InstrumentHandlerType): void { - if (instrumented[type]) { - return; - } - - instrumented[type] = true; - - switch (type) { - case 'console': - instrumentConsole(); - break; - case 'dom': - instrumentDOM(); - break; - case 'xhr': - instrumentXHR(); - break; - case 'fetch': - instrumentFetch(); - break; - case 'history': - instrumentHistory(); - break; - case 'error': - instrumentError(); - break; - case 'unhandledrejection': - instrumentUnhandledRejection(); - break; - default: - __DEBUG_BUILD__ && logger.warn('unknown instrumentation type:', type); - return; - } -} - -/** - * Add handler that will be called when given type of instrumentation triggers. - * Use at your own risk, this might break without changelog notice, only used internally. - * @hidden - */ -export function addInstrumentationHandler(type: InstrumentHandlerType, callback: InstrumentHandlerCallback): void { - handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(callback); - instrument(type); -} - -/** - * Reset all instrumentation handlers. - * This can be used by tests to ensure we have a clean slate of instrumentation handlers. - */ -export function resetInstrumentationHandlers(): void { - Object.keys(handlers).forEach(key => { - handlers[key as InstrumentHandlerType] = undefined; - }); -} - -/** JSDoc */ -function triggerHandlers(type: InstrumentHandlerType, data: any): void { - if (!type || !handlers[type]) { - return; - } - - for (const handler of handlers[type] || []) { - try { - handler(data); - } catch (e) { - __DEBUG_BUILD__ && - logger.error( - `Error while triggering instrumentation handler.\nType: ${type}\nName: ${getFunctionName(handler)}\nError:`, - e, - ); - } - } -} - -/** JSDoc */ -function instrumentConsole(): void { - if (!('console' in GLOBAL_OBJ)) { - return; - } - - CONSOLE_LEVELS.forEach(function (level: ConsoleLevel): void { - if (!(level in GLOBAL_OBJ.console)) { - return; - } - - fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { - originalConsoleMethods[level] = originalConsoleMethod; - - return function (...args: any[]): void { - triggerHandlers('console', { args, level }); - - const log = originalConsoleMethods[level]; - log && log.apply(GLOBAL_OBJ.console, args); - }; - }); - }); -} - -/** JSDoc */ -function instrumentFetch(): void { - if (!supportsNativeFetch()) { - return; - } - - fill(GLOBAL_OBJ, 'fetch', function (originalFetch: () => void): () => void { - return function (...args: any[]): void { - const { method, url } = parseFetchArgs(args); - - const handlerData: HandlerDataFetch = { - args, - fetchData: { - method, - url, - }, - startTimestamp: Date.now(), - }; - - triggerHandlers('fetch', { - ...handlerData, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return originalFetch.apply(GLOBAL_OBJ, args).then( - (response: Response) => { - triggerHandlers('fetch', { - ...handlerData, - endTimestamp: Date.now(), - response, - }); - return response; - }, - (error: Error) => { - triggerHandlers('fetch', { - ...handlerData, - endTimestamp: Date.now(), - error, - }); - // NOTE: If you are a Sentry user, and you are seeing this stack frame, - // it means the sentry.javascript SDK caught an error invoking your application code. - // This is expected behavior and NOT indicative of a bug with sentry.javascript. - throw error; - }, - ); - }; - }); -} - -function hasProp(obj: unknown, prop: T): obj is Record { - return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; -} - -type FetchResource = string | { toString(): string } | { url: string }; - -function getUrlFromResource(resource: FetchResource): string { - if (typeof resource === 'string') { - return resource; - } - - if (!resource) { - return ''; - } - - if (hasProp(resource, 'url')) { - return resource.url; - } - - if (resource.toString) { - return resource.toString(); - } - - return ''; -} - -/** - * Parses the fetch arguments to find the used Http method and the url of the request - */ -export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { - if (fetchArgs.length === 0) { - return { method: 'GET', url: '' }; - } - - if (fetchArgs.length === 2) { - const [url, options] = fetchArgs as [FetchResource, object]; - - return { - url: getUrlFromResource(url), - method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', - }; - } - - const arg = fetchArgs[0]; - return { - url: getUrlFromResource(arg as FetchResource), - method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', - }; -} - -/** JSDoc */ -export function instrumentXHR(): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!(WINDOW as any).XMLHttpRequest) { - return; - } - - const xhrproto = XMLHttpRequest.prototype; - - fill(xhrproto, 'open', function (originalOpen: () => void): () => void { - return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { - const startTimestamp = Date.now(); - - const url = args[1]; - const xhrInfo: SentryXhrData = (this[SENTRY_XHR_DATA_KEY] = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - method: isString(args[0]) ? args[0].toUpperCase() : args[0], - url: args[1], - request_headers: {}, - }); - - // if Sentry key appears in URL, don't capture it as a request - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (isString(url) && xhrInfo.method === 'POST' && url.match(/sentry_key/)) { - this.__sentry_own_request__ = true; - } - - const onreadystatechangeHandler: () => void = () => { - // For whatever reason, this is not the same instance here as from the outer method - const xhrInfo = this[SENTRY_XHR_DATA_KEY]; - - if (!xhrInfo) { - return; - } - - if (this.readyState === 4) { - try { - // touching statusCode in some platforms throws - // an exception - xhrInfo.status_code = this.status; - } catch (e) { - /* do nothing */ - } - - triggerHandlers('xhr', { - args: args as [string, string], - endTimestamp: Date.now(), - startTimestamp, - xhr: this, - } as HandlerDataXhr); - } - }; - - if ('onreadystatechange' in this && typeof this.onreadystatechange === 'function') { - fill(this, 'onreadystatechange', function (original: WrappedFunction): Function { - return function (this: SentryWrappedXMLHttpRequest, ...readyStateArgs: any[]): void { - onreadystatechangeHandler(); - return original.apply(this, readyStateArgs); - }; - }); - } else { - this.addEventListener('readystatechange', onreadystatechangeHandler); - } - - // Intercepting `setRequestHeader` to access the request headers of XHR instance. - // This will only work for user/library defined headers, not for the default/browser-assigned headers. - // Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`. - fill(this, 'setRequestHeader', function (original: WrappedFunction): Function { - return function (this: SentryWrappedXMLHttpRequest, ...setRequestHeaderArgs: unknown[]): void { - const [header, value] = setRequestHeaderArgs as [string, string]; - - const xhrInfo = this[SENTRY_XHR_DATA_KEY]; - - if (xhrInfo) { - xhrInfo.request_headers[header.toLowerCase()] = value; - } - - return original.apply(this, setRequestHeaderArgs); - }; - }); - - return originalOpen.apply(this, args); - }; - }); - - fill(xhrproto, 'send', function (originalSend: () => void): () => void { - return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { - const sentryXhrData = this[SENTRY_XHR_DATA_KEY]; - if (sentryXhrData && args[0] !== undefined) { - sentryXhrData.body = args[0]; - } - - triggerHandlers('xhr', { - args, - startTimestamp: Date.now(), - xhr: this, - }); - - return originalSend.apply(this, args); - }; - }); -} - -let lastHref: string; - -/** JSDoc */ -function instrumentHistory(): void { - if (!supportsHistory()) { - return; - } - - const oldOnPopState = WINDOW.onpopstate; - WINDOW.onpopstate = function (this: WindowEventHandlers, ...args: any[]): any { - const to = WINDOW.location.href; - // keep track of the current URL state, as we always receive only the updated state - const from = lastHref; - lastHref = to; - triggerHandlers('history', { - from, - to, - }); - if (oldOnPopState) { - // Apparently this can throw in Firefox when incorrectly implemented plugin is installed. - // https://github.com/getsentry/sentry-javascript/issues/3344 - // https://github.com/bugsnag/bugsnag-js/issues/469 - try { - return oldOnPopState.apply(this, args); - } catch (_oO) { - // no-empty - } - } - }; - - /** @hidden */ - function historyReplacementFunction(originalHistoryFunction: () => void): () => void { - return function (this: History, ...args: any[]): void { - const url = args.length > 2 ? args[2] : undefined; - if (url) { - // coerce to string (this is what pushState does) - const from = lastHref; - const to = String(url); - // keep track of the current URL state, as we always receive only the updated state - lastHref = to; - triggerHandlers('history', { - from, - to, - }); - } - return originalHistoryFunction.apply(this, args); - }; - } - - fill(WINDOW.history, 'pushState', historyReplacementFunction); - fill(WINDOW.history, 'replaceState', historyReplacementFunction); -} - -const DEBOUNCE_DURATION = 1000; -let debounceTimerID: number | undefined; -let lastCapturedEventType: string | undefined; -let lastCapturedEventTargetId: string | undefined; - -type SentryWrappedTarget = HTMLElement & { _sentryId?: string }; - -/** - * Check whether the event is similar to the last captured one. For example, two click events on the same button. - */ -function isSimilarToLastCapturedEvent(event: Event): boolean { - // If both events have different type, then user definitely performed two separate actions. e.g. click + keypress. - if (event.type !== lastCapturedEventType) { - return false; - } - - try { - // If both events have the same type, it's still possible that actions were performed on different targets. - // e.g. 2 clicks on different buttons. - if (!event.target || (event.target as SentryWrappedTarget)._sentryId !== lastCapturedEventTargetId) { - return false; - } - } catch (e) { - // just accessing `target` property can throw an exception in some rare circumstances - // see: https://github.com/getsentry/sentry-javascript/issues/838 - } - - // If both events have the same type _and_ same `target` (an element which triggered an event, _not necessarily_ - // to which an event listener was attached), we treat them as the same action, as we want to capture - // only one breadcrumb. e.g. multiple clicks on the same button, or typing inside a user input box. - return true; -} - -/** - * Decide whether an event should be captured. - * @param event event to be captured - */ -function shouldSkipDOMEvent(eventType: string, target: SentryWrappedTarget | null): boolean { - // We are only interested in filtering `keypress` events for now. - if (eventType !== 'keypress') { - return false; - } - - if (!target || !target.tagName) { - return true; - } - - // Only consider keypress events on actual input elements. This will disregard keypresses targeting body - // e.g.tabbing through elements, hotkeys, etc. - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return false; - } - - return true; -} - -function getEventTarget(event: Event): SentryWrappedTarget | null { - try { - return event.target as SentryWrappedTarget | null; - } catch (e) { - // just accessing `target` property can throw an exception in some rare circumstances - // see: https://github.com/getsentry/sentry-javascript/issues/838 - return null; - } -} - -/** - * Wraps addEventListener to capture UI breadcrumbs - * @param handler function that will be triggered - * @param globalListener indicates whether event was captured by the global event listener - * @returns wrapped breadcrumb events handler - * @hidden - */ -function makeDOMEventHandler(handler: Function, globalListener: boolean = false): (event: Event) => void { - return (event: Event & { _sentryCaptured?: true }): void => { - // It's possible this handler might trigger multiple times for the same - // event (e.g. event propagation through node ancestors). - // Ignore if we've already captured that event. - if (!event || event['_sentryCaptured']) { - return; - } - - const target = getEventTarget(event); - - // We always want to skip _some_ events. - if (shouldSkipDOMEvent(event.type, target)) { - return; - } - - // Mark event as "seen" - addNonEnumerableProperty(event, '_sentryCaptured', true); - - if (target && !target._sentryId) { - // Add UUID to event target so we can identify if - addNonEnumerableProperty(target, '_sentryId', uuid4()); - } - - const name = event.type === 'keypress' ? 'input' : event.type; - - // If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons. - // If there is a last captured event, see if the new event is different enough to treat it as a unique one. - // If that's the case, emit the previous event and store locally the newly-captured DOM event. - if (!isSimilarToLastCapturedEvent(event)) { - handler({ - event: event, - name, - global: globalListener, - }); - lastCapturedEventType = event.type; - lastCapturedEventTargetId = target ? target._sentryId : undefined; - } - - // Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together. - clearTimeout(debounceTimerID); - debounceTimerID = WINDOW.setTimeout(() => { - lastCapturedEventTargetId = undefined; - lastCapturedEventType = undefined; - }, DEBOUNCE_DURATION); - }; -} - -type AddEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, -) => void; -type RemoveEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, -) => void; - -type InstrumentedElement = Element & { - __sentry_instrumentation_handlers__?: { - [key in 'click' | 'keypress']?: { - handler?: Function; - /** The number of custom listeners attached to this element */ - refCount: number; - }; - }; -}; - -/** JSDoc */ -export function instrumentDOM(): void { - if (!WINDOW.document) { - return; - } - - // Make it so that any click or keypress that is unhandled / bubbled up all the way to the document triggers our dom - // handlers. (Normally we have only one, which captures a breadcrumb for each click or keypress.) Do this before - // we instrument `addEventListener` so that we don't end up attaching this handler twice. - const triggerDOMHandler = triggerHandlers.bind(null, 'dom'); - const globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true); - WINDOW.document.addEventListener('click', globalDOMEventHandler, false); - WINDOW.document.addEventListener('keypress', globalDOMEventHandler, false); - - // After hooking into click and keypress events bubbled up to `document`, we also hook into user-handled - // clicks & keypresses, by adding an event listener of our own to any element to which they add a listener. That - // way, whenever one of their handlers is triggered, ours will be, too. (This is needed because their handler - // could potentially prevent the event from bubbling up to our global listeners. This way, our handler are still - // guaranteed to fire at least once.) - ['EventTarget', 'Node'].forEach((target: string) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const proto = (WINDOW as any)[target] && (WINDOW as any)[target].prototype; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-prototype-builtins - if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) { - return; - } - - fill(proto, 'addEventListener', function (originalAddEventListener: AddEventListener): AddEventListener { - return function ( - this: Element, - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, - ): AddEventListener { - if (type === 'click' || type == 'keypress') { - try { - const el = this as InstrumentedElement; - const handlers = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {}); - const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 }); - - if (!handlerForType.handler) { - const handler = makeDOMEventHandler(triggerDOMHandler); - handlerForType.handler = handler; - originalAddEventListener.call(this, type, handler, options); - } - - handlerForType.refCount++; - } catch (e) { - // Accessing dom properties is always fragile. - // Also allows us to skip `addEventListenrs` calls with no proper `this` context. - } - } - - return originalAddEventListener.call(this, type, listener, options); - }; - }); - - fill( - proto, - 'removeEventListener', - function (originalRemoveEventListener: RemoveEventListener): RemoveEventListener { - return function ( - this: Element, - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): () => void { - if (type === 'click' || type == 'keypress') { - try { - const el = this as InstrumentedElement; - const handlers = el.__sentry_instrumentation_handlers__ || {}; - const handlerForType = handlers[type]; - - if (handlerForType) { - handlerForType.refCount--; - // If there are no longer any custom handlers of the current type on this element, we can remove ours, too. - if (handlerForType.refCount <= 0) { - originalRemoveEventListener.call(this, type, handlerForType.handler, options); - handlerForType.handler = undefined; - delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete - } - - // If there are no longer any custom handlers of any type on this element, cleanup everything. - if (Object.keys(handlers).length === 0) { - delete el.__sentry_instrumentation_handlers__; - } - } - } catch (e) { - // Accessing dom properties is always fragile. - // Also allows us to skip `addEventListenrs` calls with no proper `this` context. - } - } - - return originalRemoveEventListener.call(this, type, listener, options); - }; - }, - ); - }); -} - -let _oldOnErrorHandler: (typeof WINDOW)['onerror'] | null = null; -/** JSDoc */ -function instrumentError(): void { - _oldOnErrorHandler = WINDOW.onerror; - - WINDOW.onerror = function (msg: unknown, url: unknown, line: unknown, column: unknown, error: unknown): boolean { - triggerHandlers('error', { - column, - error, - line, - msg, - url, - }); - - if (_oldOnErrorHandler && !_oldOnErrorHandler.__SENTRY_LOADER__) { - // eslint-disable-next-line prefer-rest-params - return _oldOnErrorHandler.apply(this, arguments); - } - - return false; - }; - - WINDOW.onerror.__SENTRY_INSTRUMENTED__ = true; -} - -let _oldOnUnhandledRejectionHandler: (typeof WINDOW)['onunhandledrejection'] | null = null; -/** JSDoc */ -function instrumentUnhandledRejection(): void { - _oldOnUnhandledRejectionHandler = WINDOW.onunhandledrejection; - - WINDOW.onunhandledrejection = function (e: any): boolean { - triggerHandlers('unhandledrejection', e); - - if (_oldOnUnhandledRejectionHandler && !_oldOnUnhandledRejectionHandler.__SENTRY_LOADER__) { - // eslint-disable-next-line prefer-rest-params - return _oldOnUnhandledRejectionHandler.apply(this, arguments); - } - - return true; - }; - - WINDOW.onunhandledrejection.__SENTRY_INSTRUMENTED__ = true; -} diff --git a/packages/utils/src/instrument/_handlers.ts b/packages/utils/src/instrument/_handlers.ts new file mode 100644 index 000000000000..e86ab677ed39 --- /dev/null +++ b/packages/utils/src/instrument/_handlers.ts @@ -0,0 +1,54 @@ +import { logger } from '../logger'; +import { getFunctionName } from '../stacktrace'; + +export type InstrumentHandlerType = 'console' | 'dom' | 'fetch' | 'history' | 'xhr' | 'error' | 'unhandledrejection'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type InstrumentHandlerCallback = (data: any) => void; + +// We keep the handlers globally +const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {}; +const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; + +/** Add a handler function. */ +export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { + handlers[type] = handlers[type] || []; + (handlers[type] as InstrumentHandlerCallback[]).push(handler); +} + +/** + * Reset all instrumentation handlers. + * This can be used by tests to ensure we have a clean slate of instrumentation handlers. + */ +export function resetInstrumentationHandlers(): void { + Object.keys(handlers).forEach(key => { + handlers[key as InstrumentHandlerType] = undefined; + }); +} + +/** Maybe run an instrumentation function, unless it was already called. */ +export function maybeInstrument(type: InstrumentHandlerType, instrumentFn: () => void): void { + if (!instrumented[type]) { + instrumentFn(); + instrumented[type] = true; + } +} + +/** Trigger handlers for a given instrumentation type. */ +export function triggerHandlers(type: InstrumentHandlerType, data: unknown): void { + const typeHandlers = type && handlers[type]; + if (!typeHandlers) { + return; + } + + for (const handler of typeHandlers) { + try { + handler(data); + } catch (e) { + __DEBUG_BUILD__ && + logger.error( + `Error while triggering instrumentation handler.\nType: ${type}\nName: ${getFunctionName(handler)}\nError:`, + e, + ); + } + } +} diff --git a/packages/utils/src/instrument/console.ts b/packages/utils/src/instrument/console.ts new file mode 100644 index 000000000000..7570f58a55dc --- /dev/null +++ b/packages/utils/src/instrument/console.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { ConsoleLevel, HandlerDataConsole } from '@sentry/types'; + +import { CONSOLE_LEVELS, originalConsoleMethods } from '../logger'; +import { fill } from '../object'; +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +/** + * Add an instrumentation handler for when a console.xxx method is called. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): void { + const type = 'console'; + addHandler(type, handler); + maybeInstrument(type, instrumentConsole); +} + +function instrumentConsole(): void { + if (!('console' in GLOBAL_OBJ)) { + return; + } + + CONSOLE_LEVELS.forEach(function (level: ConsoleLevel): void { + if (!(level in GLOBAL_OBJ.console)) { + return; + } + + fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { + originalConsoleMethods[level] = originalConsoleMethod; + + return function (...args: any[]): void { + const handlerData: HandlerDataConsole = { args, level }; + triggerHandlers('console', handlerData); + + const log = originalConsoleMethods[level]; + log && log.apply(GLOBAL_OBJ.console, args); + }; + }); + }); +} diff --git a/packages/utils/src/instrument/dom.ts b/packages/utils/src/instrument/dom.ts new file mode 100644 index 000000000000..71f64e43ebc5 --- /dev/null +++ b/packages/utils/src/instrument/dom.ts @@ -0,0 +1,260 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { HandlerDataDom } from '@sentry/types'; + +import { uuid4 } from '../misc'; +import { addNonEnumerableProperty, fill } from '../object'; +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +type SentryWrappedTarget = HTMLElement & { _sentryId?: string }; + +type AddEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, +) => void; +type RemoveEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, +) => void; + +type InstrumentedElement = Element & { + __sentry_instrumentation_handlers__?: { + [key in 'click' | 'keypress']?: { + handler?: Function; + /** The number of custom listeners attached to this element */ + refCount: number; + }; + }; +}; + +const WINDOW = GLOBAL_OBJ as unknown as Window; +const DEBOUNCE_DURATION = 1000; + +let debounceTimerID: number | undefined; +let lastCapturedEventType: string | undefined; +let lastCapturedEventTargetId: string | undefined; + +/** + * Add an instrumentation handler for when a click or a keypress happens. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addClickKeypressInstrumentationHandler(handler: (data: HandlerDataDom) => void): void { + const type = 'dom'; + addHandler(type, handler); + maybeInstrument(type, instrumentDOM); +} + +/** Exported for tests only. */ +export function instrumentDOM(): void { + if (!WINDOW.document) { + return; + } + + // Make it so that any click or keypress that is unhandled / bubbled up all the way to the document triggers our dom + // handlers. (Normally we have only one, which captures a breadcrumb for each click or keypress.) Do this before + // we instrument `addEventListener` so that we don't end up attaching this handler twice. + const triggerDOMHandler = triggerHandlers.bind(null, 'dom'); + const globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true); + WINDOW.document.addEventListener('click', globalDOMEventHandler, false); + WINDOW.document.addEventListener('keypress', globalDOMEventHandler, false); + + // After hooking into click and keypress events bubbled up to `document`, we also hook into user-handled + // clicks & keypresses, by adding an event listener of our own to any element to which they add a listener. That + // way, whenever one of their handlers is triggered, ours will be, too. (This is needed because their handler + // could potentially prevent the event from bubbling up to our global listeners. This way, our handler are still + // guaranteed to fire at least once.) + ['EventTarget', 'Node'].forEach((target: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const proto = (WINDOW as any)[target] && (WINDOW as any)[target].prototype; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-prototype-builtins + if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) { + return; + } + + fill(proto, 'addEventListener', function (originalAddEventListener: AddEventListener): AddEventListener { + return function ( + this: Element, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): AddEventListener { + if (type === 'click' || type == 'keypress') { + try { + const el = this as InstrumentedElement; + const handlers = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {}); + const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 }); + + if (!handlerForType.handler) { + const handler = makeDOMEventHandler(triggerDOMHandler); + handlerForType.handler = handler; + originalAddEventListener.call(this, type, handler, options); + } + + handlerForType.refCount++; + } catch (e) { + // Accessing dom properties is always fragile. + // Also allows us to skip `addEventListenrs` calls with no proper `this` context. + } + } + + return originalAddEventListener.call(this, type, listener, options); + }; + }); + + fill( + proto, + 'removeEventListener', + function (originalRemoveEventListener: RemoveEventListener): RemoveEventListener { + return function ( + this: Element, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): () => void { + if (type === 'click' || type == 'keypress') { + try { + const el = this as InstrumentedElement; + const handlers = el.__sentry_instrumentation_handlers__ || {}; + const handlerForType = handlers[type]; + + if (handlerForType) { + handlerForType.refCount--; + // If there are no longer any custom handlers of the current type on this element, we can remove ours, too. + if (handlerForType.refCount <= 0) { + originalRemoveEventListener.call(this, type, handlerForType.handler, options); + handlerForType.handler = undefined; + delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete + } + + // If there are no longer any custom handlers of any type on this element, cleanup everything. + if (Object.keys(handlers).length === 0) { + delete el.__sentry_instrumentation_handlers__; + } + } + } catch (e) { + // Accessing dom properties is always fragile. + // Also allows us to skip `addEventListenrs` calls with no proper `this` context. + } + } + + return originalRemoveEventListener.call(this, type, listener, options); + }; + }, + ); + }); +} + +/** + * Check whether the event is similar to the last captured one. For example, two click events on the same button. + */ +function isSimilarToLastCapturedEvent(event: Event): boolean { + // If both events have different type, then user definitely performed two separate actions. e.g. click + keypress. + if (event.type !== lastCapturedEventType) { + return false; + } + + try { + // If both events have the same type, it's still possible that actions were performed on different targets. + // e.g. 2 clicks on different buttons. + if (!event.target || (event.target as SentryWrappedTarget)._sentryId !== lastCapturedEventTargetId) { + return false; + } + } catch (e) { + // just accessing `target` property can throw an exception in some rare circumstances + // see: https://github.com/getsentry/sentry-javascript/issues/838 + } + + // If both events have the same type _and_ same `target` (an element which triggered an event, _not necessarily_ + // to which an event listener was attached), we treat them as the same action, as we want to capture + // only one breadcrumb. e.g. multiple clicks on the same button, or typing inside a user input box. + return true; +} + +/** + * Decide whether an event should be captured. + * @param event event to be captured + */ +function shouldSkipDOMEvent(eventType: string, target: SentryWrappedTarget | null): boolean { + // We are only interested in filtering `keypress` events for now. + if (eventType !== 'keypress') { + return false; + } + + if (!target || !target.tagName) { + return true; + } + + // Only consider keypress events on actual input elements. This will disregard keypresses targeting body + // e.g.tabbing through elements, hotkeys, etc. + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return false; + } + + return true; +} + +/** + * Wraps addEventListener to capture UI breadcrumbs + */ +function makeDOMEventHandler( + handler: (data: HandlerDataDom) => void, + globalListener: boolean = false, +): (event: Event) => void { + return (event: Event & { _sentryCaptured?: true }): void => { + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). + // Ignore if we've already captured that event. + if (!event || event['_sentryCaptured']) { + return; + } + + const target = getEventTarget(event); + + // We always want to skip _some_ events. + if (shouldSkipDOMEvent(event.type, target)) { + return; + } + + // Mark event as "seen" + addNonEnumerableProperty(event, '_sentryCaptured', true); + + if (target && !target._sentryId) { + // Add UUID to event target so we can identify if + addNonEnumerableProperty(target, '_sentryId', uuid4()); + } + + const name = event.type === 'keypress' ? 'input' : event.type; + + // If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons. + // If there is a last captured event, see if the new event is different enough to treat it as a unique one. + // If that's the case, emit the previous event and store locally the newly-captured DOM event. + if (!isSimilarToLastCapturedEvent(event)) { + const handlerData: HandlerDataDom = { event, name, global: globalListener }; + handler(handlerData); + lastCapturedEventType = event.type; + lastCapturedEventTargetId = target ? target._sentryId : undefined; + } + + // Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together. + clearTimeout(debounceTimerID); + debounceTimerID = WINDOW.setTimeout(() => { + lastCapturedEventTargetId = undefined; + lastCapturedEventType = undefined; + }, DEBOUNCE_DURATION); + }; +} + +function getEventTarget(event: Event): SentryWrappedTarget | null { + try { + return event.target as SentryWrappedTarget | null; + } catch (e) { + // just accessing `target` property can throw an exception in some rare circumstances + // see: https://github.com/getsentry/sentry-javascript/issues/838 + return null; + } +} diff --git a/packages/utils/src/instrument/fetch.ts b/packages/utils/src/instrument/fetch.ts new file mode 100644 index 000000000000..a02dc5db42ab --- /dev/null +++ b/packages/utils/src/instrument/fetch.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { HandlerDataFetch } from '@sentry/types'; + +import { fill } from '../object'; +import { supportsNativeFetch } from '../supports'; +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +type FetchResource = string | { toString(): string } | { url: string }; + +/** + * Add an instrumentation handler for when a fetch request happens. + * The handler function is called once when the request starts and once when it ends, + * which can be identified by checking if it has an `endTimestamp`. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addFetchInstrumentationHandler(handler: (data: HandlerDataFetch) => void): void { + const type = 'fetch'; + addHandler(type, handler); + maybeInstrument(type, instrumentFetch); +} + +function instrumentFetch(): void { + if (!supportsNativeFetch()) { + return; + } + + fill(GLOBAL_OBJ, 'fetch', function (originalFetch: () => void): () => void { + return function (...args: any[]): void { + const { method, url } = parseFetchArgs(args); + + const handlerData: HandlerDataFetch = { + args, + fetchData: { + method, + url, + }, + startTimestamp: Date.now(), + }; + + triggerHandlers('fetch', { + ...handlerData, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return originalFetch.apply(GLOBAL_OBJ, args).then( + (response: Response) => { + const finishedHandlerData: HandlerDataFetch = { + ...handlerData, + endTimestamp: Date.now(), + response, + }; + + triggerHandlers('fetch', finishedHandlerData); + return response; + }, + (error: Error) => { + const erroredHandlerData: HandlerDataFetch = { + ...handlerData, + endTimestamp: Date.now(), + error, + }; + + triggerHandlers('fetch', erroredHandlerData); + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the sentry.javascript SDK caught an error invoking your application code. + // This is expected behavior and NOT indicative of a bug with sentry.javascript. + throw error; + }, + ); + }; + }); +} + +function hasProp(obj: unknown, prop: T): obj is Record { + return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; +} + +function getUrlFromResource(resource: FetchResource): string { + if (typeof resource === 'string') { + return resource; + } + + if (!resource) { + return ''; + } + + if (hasProp(resource, 'url')) { + return resource.url; + } + + if (resource.toString) { + return resource.toString(); + } + + return ''; +} + +/** + * Parses the fetch arguments to find the used Http method and the url of the request. + * Exported for tests only. + */ +export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { + if (fetchArgs.length === 0) { + return { method: 'GET', url: '' }; + } + + if (fetchArgs.length === 2) { + const [url, options] = fetchArgs as [FetchResource, object]; + + return { + url: getUrlFromResource(url), + method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', + }; + } + + const arg = fetchArgs[0]; + return { + url: getUrlFromResource(arg as FetchResource), + method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', + }; +} diff --git a/packages/utils/src/instrument/globalError.ts b/packages/utils/src/instrument/globalError.ts new file mode 100644 index 000000000000..df7ff21438cc --- /dev/null +++ b/packages/utils/src/instrument/globalError.ts @@ -0,0 +1,48 @@ +import type { HandlerDataError } from '@sentry/types'; + +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +let _oldOnErrorHandler: (typeof GLOBAL_OBJ)['onerror'] | null = null; + +/** + * Add an instrumentation handler for when an error is captured by the global error handler. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addGlobalErrorInstrumentationHandler(handler: (data: HandlerDataError) => void): void { + const type = 'error'; + addHandler(type, handler); + maybeInstrument(type, instrumentError); +} + +function instrumentError(): void { + _oldOnErrorHandler = GLOBAL_OBJ.onerror; + + GLOBAL_OBJ.onerror = function ( + msg: string | Event, + url?: string, + line?: number, + column?: number, + error?: Error, + ): boolean { + const handlerData: HandlerDataError = { + column, + error, + line, + msg, + url, + }; + triggerHandlers('error', handlerData); + + if (_oldOnErrorHandler && !_oldOnErrorHandler.__SENTRY_LOADER__) { + // eslint-disable-next-line prefer-rest-params + return _oldOnErrorHandler.apply(this, arguments); + } + + return false; + }; + + GLOBAL_OBJ.onerror.__SENTRY_INSTRUMENTED__ = true; +} diff --git a/packages/utils/src/instrument/globalUnhandledRejection.ts b/packages/utils/src/instrument/globalUnhandledRejection.ts new file mode 100644 index 000000000000..c92f4d723b0d --- /dev/null +++ b/packages/utils/src/instrument/globalUnhandledRejection.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { HandlerDataUnhandledRejection } from '@sentry/types'; + +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +let _oldOnUnhandledRejectionHandler: (typeof GLOBAL_OBJ)['onunhandledrejection'] | null = null; + +/** + * Add an instrumentation handler for when an unhandled promise rejection is captured. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addGlobalUnhandledRejectionInstrumentationHandler( + handler: (data: HandlerDataUnhandledRejection) => void, +): void { + const type = 'unhandledrejection'; + addHandler(type, handler); + maybeInstrument(type, instrumentUnhandledRejection); +} + +function instrumentUnhandledRejection(): void { + _oldOnUnhandledRejectionHandler = GLOBAL_OBJ.onunhandledrejection; + + GLOBAL_OBJ.onunhandledrejection = function (e: any): boolean { + const handlerData: HandlerDataUnhandledRejection = e; + triggerHandlers('unhandledrejection', handlerData); + + if (_oldOnUnhandledRejectionHandler && !_oldOnUnhandledRejectionHandler.__SENTRY_LOADER__) { + // eslint-disable-next-line prefer-rest-params + return _oldOnUnhandledRejectionHandler.apply(this, arguments); + } + + return true; + }; + + GLOBAL_OBJ.onunhandledrejection.__SENTRY_INSTRUMENTED__ = true; +} diff --git a/packages/utils/src/instrument/history.ts b/packages/utils/src/instrument/history.ts new file mode 100644 index 000000000000..b13eabf99e3b --- /dev/null +++ b/packages/utils/src/instrument/history.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { HandlerDataHistory } from '@sentry/types'; + +import { fill } from '../object'; +import { supportsHistory } from '../supports'; +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +const WINDOW = GLOBAL_OBJ as unknown as Window; + +let lastHref: string | undefined; + +/** + * Add an instrumentation handler for when a fetch request happens. + * The handler function is called once when the request starts and once when it ends, + * which can be identified by checking if it has an `endTimestamp`. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addHistoryInstrumentationHandler(handler: (data: HandlerDataHistory) => void): void { + const type = 'history'; + addHandler(type, handler); + maybeInstrument(type, instrumentHistory); +} + +function instrumentHistory(): void { + if (!supportsHistory()) { + return; + } + + const oldOnPopState = WINDOW.onpopstate; + WINDOW.onpopstate = function (this: WindowEventHandlers, ...args: any[]): any { + const to = WINDOW.location.href; + // keep track of the current URL state, as we always receive only the updated state + const from = lastHref; + lastHref = to; + const handlerData: HandlerDataHistory = { from, to }; + triggerHandlers('history', handlerData); + if (oldOnPopState) { + // Apparently this can throw in Firefox when incorrectly implemented plugin is installed. + // https://github.com/getsentry/sentry-javascript/issues/3344 + // https://github.com/bugsnag/bugsnag-js/issues/469 + try { + return oldOnPopState.apply(this, args); + } catch (_oO) { + // no-empty + } + } + }; + + function historyReplacementFunction(originalHistoryFunction: () => void): () => void { + return function (this: History, ...args: any[]): void { + const url = args.length > 2 ? args[2] : undefined; + if (url) { + // coerce to string (this is what pushState does) + const from = lastHref; + const to = String(url); + // keep track of the current URL state, as we always receive only the updated state + lastHref = to; + const handlerData: HandlerDataHistory = { from, to }; + triggerHandlers('history', handlerData); + } + return originalHistoryFunction.apply(this, args); + }; + } + + fill(WINDOW.history, 'pushState', historyReplacementFunction); + fill(WINDOW.history, 'replaceState', historyReplacementFunction); +} diff --git a/packages/utils/src/instrument/index.ts b/packages/utils/src/instrument/index.ts new file mode 100644 index 000000000000..800f2ec6540d --- /dev/null +++ b/packages/utils/src/instrument/index.ts @@ -0,0 +1,67 @@ +import type { + InstrumentHandlerCallback as _InstrumentHandlerCallback, + InstrumentHandlerType as _InstrumentHandlerType, +} from './_handlers'; +import { resetInstrumentationHandlers } from './_handlers'; +import { logger } from './../logger'; +import { addConsoleInstrumentationHandler } from './console'; +import { addClickKeypressInstrumentationHandler } from './dom'; +import { addFetchInstrumentationHandler } from './fetch'; +import { addGlobalErrorInstrumentationHandler } from './globalError'; +import { addGlobalUnhandledRejectionInstrumentationHandler } from './globalUnhandledRejection'; +import { addHistoryInstrumentationHandler } from './history'; +import { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './xhr'; + +/** + * Add handler that will be called when given type of instrumentation triggers. + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + * @deprecated Use the proper function per instrumentation type instead! + */ +export function addInstrumentationHandler(type: _InstrumentHandlerType, callback: _InstrumentHandlerCallback): void { + switch (type) { + case 'console': + return addConsoleInstrumentationHandler(callback); + case 'dom': + return addClickKeypressInstrumentationHandler(callback); + case 'xhr': + return addXhrInstrumentationHandler(callback); + case 'fetch': + return addFetchInstrumentationHandler(callback); + case 'history': + return addHistoryInstrumentationHandler(callback); + case 'error': + return addGlobalErrorInstrumentationHandler(callback); + case 'unhandledrejection': + return addGlobalUnhandledRejectionInstrumentationHandler(callback); + default: + __DEBUG_BUILD__ && logger.warn('unknown instrumentation type:', type); + } +} + +/** + * @deprecated Use the specific handler data types from @sentry/types instead, e.g. HandlerDataFetch, HandlerDataConsole, ... + */ +type InstrumentHandlerCallback = _InstrumentHandlerCallback; + +/** + * @deprecated Use the specific handler functions instead, e.g. addConsoleInstrumentationHandler, ... + */ +type InstrumentHandlerType = _InstrumentHandlerType; + +// eslint-disable-next-line deprecation/deprecation +export type { InstrumentHandlerCallback, InstrumentHandlerType }; + +export { + addConsoleInstrumentationHandler, + addClickKeypressInstrumentationHandler, + addXhrInstrumentationHandler, + addFetchInstrumentationHandler, + addHistoryInstrumentationHandler, + addGlobalErrorInstrumentationHandler, + addGlobalUnhandledRejectionInstrumentationHandler, + SENTRY_XHR_DATA_KEY, + + // Only exported for tests + resetInstrumentationHandlers, +}; diff --git a/packages/utils/src/instrument/xhr.ts b/packages/utils/src/instrument/xhr.ts new file mode 100644 index 000000000000..d0245dcdd2ee --- /dev/null +++ b/packages/utils/src/instrument/xhr.ts @@ -0,0 +1,158 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, WrappedFunction } from '@sentry/types'; + +import { isString } from '../is'; +import { fill } from '../object'; +import { GLOBAL_OBJ } from '../worldwide'; +import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; + +const WINDOW = GLOBAL_OBJ as unknown as Window; + +export const SENTRY_XHR_DATA_KEY = '__sentry_xhr_v3__'; + +/** + * Add an instrumentation handler for when an XHR request happens. + * The handler function is called once when the request starts and once when it ends, + * which can be identified by checking if it has an `endTimestamp`. + * + * Use at your own risk, this might break without changelog notice, only used internally. + * @hidden + */ +export function addXhrInstrumentationHandler(handler: (data: HandlerDataXhr) => void): void { + const type = 'xhr'; + addHandler(type, handler); + maybeInstrument(type, instrumentXHR); +} + +/** Exported only for tests. */ +export function instrumentXHR(): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!(WINDOW as any).XMLHttpRequest) { + return; + } + + const xhrproto = XMLHttpRequest.prototype; + + fill(xhrproto, 'open', function (originalOpen: () => void): () => void { + return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { + const startTimestamp = Date.now(); + + // open() should always be called with two or more arguments + // But to be on the safe side, we actually validate this and bail out if we don't have a method & url + const method = isString(args[0]) ? args[0].toUpperCase() : undefined; + const url = parseUrl(args[1]); + + if (!method || !url) { + return; + } + + this[SENTRY_XHR_DATA_KEY] = { + method, + url, + request_headers: {}, + }; + + // if Sentry key appears in URL, don't capture it as a request + if (method === 'POST' && url.match(/sentry_key/)) { + this.__sentry_own_request__ = true; + } + + const onreadystatechangeHandler: () => void = () => { + // For whatever reason, this is not the same instance here as from the outer method + const xhrInfo = this[SENTRY_XHR_DATA_KEY]; + + if (!xhrInfo) { + return; + } + + if (this.readyState === 4) { + try { + // touching statusCode in some platforms throws + // an exception + xhrInfo.status_code = this.status; + } catch (e) { + /* do nothing */ + } + + const handlerData: HandlerDataXhr = { + args: [method, url], + endTimestamp: Date.now(), + startTimestamp, + xhr: this, + }; + triggerHandlers('xhr', handlerData); + } + }; + + if ('onreadystatechange' in this && typeof this.onreadystatechange === 'function') { + fill(this, 'onreadystatechange', function (original: WrappedFunction): Function { + return function (this: SentryWrappedXMLHttpRequest, ...readyStateArgs: any[]): void { + onreadystatechangeHandler(); + return original.apply(this, readyStateArgs); + }; + }); + } else { + this.addEventListener('readystatechange', onreadystatechangeHandler); + } + + // Intercepting `setRequestHeader` to access the request headers of XHR instance. + // This will only work for user/library defined headers, not for the default/browser-assigned headers. + // Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`. + fill(this, 'setRequestHeader', function (original: WrappedFunction): Function { + return function (this: SentryWrappedXMLHttpRequest, ...setRequestHeaderArgs: unknown[]): void { + const [header, value] = setRequestHeaderArgs; + + const xhrInfo = this[SENTRY_XHR_DATA_KEY]; + + if (xhrInfo && isString(header) && isString(value)) { + xhrInfo.request_headers[header.toLowerCase()] = value; + } + + return original.apply(this, setRequestHeaderArgs); + }; + }); + + return originalOpen.apply(this, args); + }; + }); + + fill(xhrproto, 'send', function (originalSend: () => void): () => void { + return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { + const sentryXhrData = this[SENTRY_XHR_DATA_KEY]; + + if (!sentryXhrData) { + return; + } + + if (args[0] !== undefined) { + sentryXhrData.body = args[0]; + } + + const handlerData: HandlerDataXhr = { + args: [sentryXhrData.method, sentryXhrData.url], + startTimestamp: Date.now(), + xhr: this, + }; + triggerHandlers('xhr', handlerData); + + return originalSend.apply(this, args); + }; + }); +} + +function parseUrl(url: string | unknown): string | undefined { + if (isString(url)) { + return url; + } + + try { + // url can be a string or URL + // but since URL is not available in IE11, we do not check for it, + // but simply assume it is an URL and return `toString()` from it (which returns the full URL) + // If that fails, we just return undefined + return (url as URL).toString(); + } catch {} // eslint-disable-line no-empty + + return undefined; +} diff --git a/packages/utils/src/logger.ts b/packages/utils/src/logger.ts index 9d37855e9114..1784aa179bb1 100644 --- a/packages/utils/src/logger.ts +++ b/packages/utils/src/logger.ts @@ -1,10 +1,19 @@ +import type { ConsoleLevel } from '@sentry/types'; + import { GLOBAL_OBJ } from './worldwide'; /** Prefix for logging strings */ const PREFIX = 'Sentry Logger '; -export const CONSOLE_LEVELS = ['debug', 'info', 'warn', 'error', 'log', 'assert', 'trace'] as const; -export type ConsoleLevel = (typeof CONSOLE_LEVELS)[number]; +export const CONSOLE_LEVELS: readonly ConsoleLevel[] = [ + 'debug', + 'info', + 'warn', + 'error', + 'log', + 'assert', + 'trace', +] as const; type LoggerMethod = (...args: unknown[]) => void; type LoggerConsoleMethods = Record; diff --git a/packages/utils/src/worldwide.ts b/packages/utils/src/worldwide.ts index 12e098bf3bc7..5caf1b137d5a 100644 --- a/packages/utils/src/worldwide.ts +++ b/packages/utils/src/worldwide.ts @@ -24,7 +24,7 @@ export interface InternalGlobal { Integrations?: Integration[]; }; onerror?: { - (msg: unknown, url: unknown, line: unknown, column: unknown, error: unknown): boolean; + (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error): any; __SENTRY_INSTRUMENTED__?: true; __SENTRY_LOADER__?: true; }; diff --git a/packages/utils/test/instrument.test.ts b/packages/utils/test/instrument.test.ts deleted file mode 100644 index f1ec46b93fb1..000000000000 --- a/packages/utils/test/instrument.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { instrumentDOM, instrumentXHR, parseFetchArgs } from '../src/instrument'; - -jest.mock('../src/worldwide', () => ({ - // Return an empty object with undefined properties - getGlobalObject: () => ({ - document: undefined, - XMLHttpRequest: undefined, - }), -})); - -describe('instrument', () => { - it('instrumentXHR() does not throw if XMLHttpRequest is a key on window but not defined', () => { - expect(instrumentXHR).not.toThrow(); - }); - - it('instrumentDOM() does not throw if XMLHttpRequest is a key on window but not defined', () => { - expect(instrumentDOM).not.toThrow(); - }); - - describe('parseFetchArgs', () => { - it.each([ - ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], - ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], - ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], - [ - 'Request URL & method only', - [{ url: 'http://example.com', method: 'post' }], - { method: 'POST', url: 'http://example.com' }, - ], - [ - 'string URL & options', - ['http://example.com', { method: 'post' }], - { method: 'POST', url: 'http://example.com' }, - ], - [ - 'URL object & options', - [new URL('http://example.com'), { method: 'post' }], - { method: 'POST', url: 'http://example.com/' }, - ], - [ - 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post' }], - { method: 'POST', url: 'http://example.com' }, - ], - ])('%s', (_name, args, expected) => { - const actual = parseFetchArgs(args as unknown[]); - - expect(actual).toEqual(expected); - }); - }); -}); diff --git a/packages/utils/test/instrument/dom.test.ts b/packages/utils/test/instrument/dom.test.ts new file mode 100644 index 000000000000..28745109e0f8 --- /dev/null +++ b/packages/utils/test/instrument/dom.test.ts @@ -0,0 +1,18 @@ +import { instrumentDOM } from '../../src/instrument/dom'; + +jest.mock('../../src/worldwide', () => { + const original = jest.requireActual('../../src/worldwide'); + + return { + ...original, + GLOBAL_OBJ: { + document: undefined, + }, + }; +}); + +describe('instrumentDOM', () => { + it('it does not throw if document is a key on window but not defined', () => { + expect(instrumentDOM).not.toThrow(); + }); +}); diff --git a/packages/utils/test/instrument/fetch.test.ts b/packages/utils/test/instrument/fetch.test.ts new file mode 100644 index 000000000000..ea29e0c16c3e --- /dev/null +++ b/packages/utils/test/instrument/fetch.test.ts @@ -0,0 +1,29 @@ +import { parseFetchArgs } from '../../src/instrument/fetch'; + +describe('instrument > parseFetchArgs', () => { + it.each([ + ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], + ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], + ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], + [ + 'Request URL & method only', + [{ url: 'http://example.com', method: 'post' }], + { method: 'POST', url: 'http://example.com' }, + ], + ['string URL & options', ['http://example.com', { method: 'post' }], { method: 'POST', url: 'http://example.com' }], + [ + 'URL object & options', + [new URL('http://example.com'), { method: 'post' }], + { method: 'POST', url: 'http://example.com/' }, + ], + [ + 'Request URL & options', + [{ url: 'http://example.com' }, { method: 'post' }], + { method: 'POST', url: 'http://example.com' }, + ], + ])('%s', (_name, args, expected) => { + const actual = parseFetchArgs(args as unknown[]); + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/utils/test/instrument/xhr.test.ts b/packages/utils/test/instrument/xhr.test.ts new file mode 100644 index 000000000000..60485ff1d398 --- /dev/null +++ b/packages/utils/test/instrument/xhr.test.ts @@ -0,0 +1,18 @@ +import { instrumentXHR } from '../../src/instrument/xhr'; + +jest.mock('../../src/worldwide', () => { + const original = jest.requireActual('../../src/worldwide'); + + return { + ...original, + GLOBAL_OBJ: { + XMLHttpRequest: undefined, + }, + }; +}); + +describe('instrumentXHR', () => { + it('it does not throw if XMLHttpRequest is a key on window but not defined', () => { + expect(instrumentXHR).not.toThrow(); + }); +}); diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 3ecffad83b33..4107e617af28 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,7 +1,7 @@ import { instrumentFetchRequest } from '@sentry-internal/tracing'; import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types'; -import { addInstrumentationHandler, LRUMap, stringMatchesSomePattern } from '@sentry/utils'; +import { addFetchInstrumentationHandler, LRUMap, stringMatchesSomePattern } from '@sentry/utils'; export interface Options { /** @@ -48,7 +48,7 @@ export class WinterCGFetch implements Integration { public setupOnce(): void { const spans: Record = {}; - addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => { + addFetchInstrumentationHandler(handlerData => { const hub = getCurrentHub(); if (!hub.getIntegration(WinterCGFetch)) { return; diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 699fb4891257..22bd960defdf 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -30,7 +30,7 @@ const fakeHubInstance = new FakeHub( jest.spyOn(sentryCore, 'getCurrentHub').mockImplementation(() => fakeHubInstance); -const addInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addInstrumentationHandler'); +const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); const instrumentFetchRequestSpy = jest.spyOn(internalTracing, 'instrumentFetchRequest'); const addBreadcrumbSpy = jest.spyOn(fakeHubInstance, 'addBreadcrumb'); @@ -41,13 +41,11 @@ beforeEach(() => { describe('WinterCGFetch instrumentation', () => { it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { const integration = new WinterCGFetch(); - addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); integration.setupOnce(); - const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = - addInstrumentationHandlerSpy.mock.calls[0]; - expect(fetchInstrumentationHandlerType).toBe('fetch'); + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { @@ -76,13 +74,11 @@ describe('WinterCGFetch instrumentation', () => { it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { const integration = new WinterCGFetch(); - addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); integration.setupOnce(); - const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = - addInstrumentationHandlerSpy.mock.calls[0]; - expect(fetchInstrumentationHandlerType).toBe('fetch'); + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { @@ -101,13 +97,11 @@ describe('WinterCGFetch instrumentation', () => { return url === 'http://only-acceptable-url.com/'; }, }); - addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); integration.setupOnce(); - const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = - addInstrumentationHandlerSpy.mock.calls[0]; - expect(fetchInstrumentationHandlerType).toBe('fetch'); + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { @@ -126,13 +120,11 @@ describe('WinterCGFetch instrumentation', () => { it('should create a breadcrumb for an outgoing request', () => { const integration = new WinterCGFetch(); - addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); integration.setupOnce(); - const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = - addInstrumentationHandlerSpy.mock.calls[0]; - expect(fetchInstrumentationHandlerType).toBe('fetch'); + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startTimestamp = Date.now(); @@ -166,13 +158,11 @@ describe('WinterCGFetch instrumentation', () => { const integration = new WinterCGFetch({ breadcrumbs: false, }); - addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); integration.setupOnce(); - const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = - addInstrumentationHandlerSpy.mock.calls[0]; - expect(fetchInstrumentationHandlerType).toBe('fetch'); + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startTimestamp = Date.now(); From 08818d8a9fccb425f90c5207c8fa8d2468dda0b1 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 22 Nov 2023 12:40:18 -0500 Subject: [PATCH 14/21] fix(tracing): Filter out invalid resource sizes (#9641) There's a bug in some browsers that attaches huge resource sizes that are completely unrealistic. Here's an example in chromium: https://bugs.chromium.org/p/chromium/issues/detail?id=1324812#c25 To get around this, we add a filter to enforce that resource sizes should only be attached if the size is < 2147483647 bytes (size of maximum value of integer in c/c++). --- .../src/browser/metrics/index.ts | 26 ++++++++++++------- .../test/browser/metrics/index.test.ts | 20 ++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index edeedcee679f..4d28f6102cde 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -15,6 +15,8 @@ import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; import type { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types'; import { _startChild, isMeasurementValue } from './utils'; +const MAX_INT_AS_BYTES = 2147483647; + /** * Converts from milliseconds to seconds * @param time time in ms @@ -402,15 +404,9 @@ export function _addResourceSpans( // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: Record = {}; - if ('transferSize' in entry) { - data['http.response_transfer_size'] = entry.transferSize; - } - if ('encodedBodySize' in entry) { - data['http.response_content_length'] = entry.encodedBodySize; - } - if ('decodedBodySize' in entry) { - data['http.decoded_response_content_length'] = entry.decodedBodySize; - } + setResourceEntrySizeData(data, entry, 'transferSize', 'http.response_transfer_size'); + setResourceEntrySizeData(data, entry, 'encodedBodySize', 'http.response_content_length'); + setResourceEntrySizeData(data, entry, 'decodedBodySize', 'http.decoded_response_content_length'); if ('renderBlockingStatus' in entry) { data['resource.render_blocking_status'] = entry.renderBlockingStatus; } @@ -493,3 +489,15 @@ function _tagMetricInfo(transaction: Transaction): void { ); } } + +function setResourceEntrySizeData( + data: Record, + entry: ResourceEntry, + key: keyof Pick, + dataKey: 'http.response_transfer_size' | 'http.response_content_length' | 'http.decoded_response_content_length', +): void { + const entryVal = entry[key]; + if (entryVal !== undefined && entryVal < MAX_INT_AS_BYTES) { + data[dataKey] = entryVal; + } +} diff --git a/packages/tracing-internal/test/browser/metrics/index.test.ts b/packages/tracing-internal/test/browser/metrics/index.test.ts index 41b4cd9c1940..a549b2549a37 100644 --- a/packages/tracing-internal/test/browser/metrics/index.test.ts +++ b/packages/tracing-internal/test/browser/metrics/index.test.ts @@ -169,4 +169,24 @@ describe('_addResourceSpans', () => { }), ); }); + + it('does not attach resource sizes that exceed MAX_INT bytes', () => { + const entry: ResourceEntry = { + initiatorType: 'css', + transferSize: 2147483647, + encodedBodySize: 2147483647, + decodedBodySize: 2147483647, + }; + + _addResourceSpans(transaction, entry, '/assets/to/css', 100, 23, 345); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: {}, + }), + ); + }); }); From 8c9ff6b57d7e0ef61bb4da39106a74c583db90ae Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 Nov 2023 13:50:38 +0100 Subject: [PATCH 15/21] fix(nextjs): Fix middleware detection logic (#9637) --- .../src/config/loaders/wrappingLoader.ts | 14 ++++++-- packages/nextjs/src/config/webpack.ts | 35 ++++++++++++------- packages/nextjs/test/config/loaders.test.ts | 25 ++++++++++++- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index ed2680467a47..c43be3565ab0 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -41,8 +41,8 @@ const routeHandlerWrapperTemplatePath = path.resolve(__dirname, '..', 'templates const routeHandlerWrapperTemplateCode = fs.readFileSync(routeHandlerWrapperTemplatePath, { encoding: 'utf8' }); export type WrappingLoaderOptions = { - pagesDir: string; - appDir: string; + pagesDir: string | undefined; + appDir: string | undefined; pageExtensionRegex: string; excludeServerRoutes: Array; wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init' | 'route-handler'; @@ -101,6 +101,11 @@ export default function wrappingLoader( return; } } else if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') { + if (pagesDir === undefined) { + this.callback(null, userCode, userModuleSourceMap); + return; + } + // Get the parameterized route name from this page's filepath const parameterizedPagesRoute = path // Get the path of the file insde of the pages directory @@ -137,6 +142,11 @@ export default function wrappingLoader( // Inject the route and the path to the file we're wrapping into the template templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); } else if (wrappingTargetKind === 'server-component' || wrappingTargetKind === 'route-handler') { + if (appDir === undefined) { + this.callback(null, userCode, userModuleSourceMap); + return; + } + // Get the parameterized route name from this page's filepath const parameterizedPagesRoute = path // Get the path of the file insde of the app directory diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 5816c0bf058c..1e85ae125a92 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -98,26 +98,31 @@ export function constructWebpackConfigFunction( ], }); - let pagesDirPath: string; + let pagesDirPath: string | undefined; const maybePagesDirPath = path.join(projectDir, 'pages'); + const maybeSrcPagesDirPath = path.join(projectDir, 'src', 'pages'); if (fs.existsSync(maybePagesDirPath) && fs.lstatSync(maybePagesDirPath).isDirectory()) { - pagesDirPath = path.join(projectDir, 'pages'); - } else { - pagesDirPath = path.join(projectDir, 'src', 'pages'); + pagesDirPath = maybePagesDirPath; + } else if (fs.existsSync(maybeSrcPagesDirPath) && fs.lstatSync(maybeSrcPagesDirPath).isDirectory()) { + pagesDirPath = maybeSrcPagesDirPath; } - let appDirPath: string; + let appDirPath: string | undefined; const maybeAppDirPath = path.join(projectDir, 'app'); + const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app'); if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) { - appDirPath = path.join(projectDir, 'app'); - } else { - appDirPath = path.join(projectDir, 'src', 'app'); + appDirPath = maybeAppDirPath; + } else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) { + appDirPath = maybeSrcAppDirPath; } - const apiRoutesPath = path.join(pagesDirPath, 'api'); + const apiRoutesPath = pagesDirPath ? path.join(pagesDirPath, 'api') : undefined; - const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js'); - const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts'); + const middlewareLocationFolder = pagesDirPath + ? path.join(pagesDirPath, '..') + : appDirPath + ? path.join(appDirPath, '..') + : projectDir; // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; @@ -151,6 +156,7 @@ export function constructWebpackConfigFunction( const isPageResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); return ( + pagesDirPath !== undefined && normalizedAbsoluteResourcePath.startsWith(pagesDirPath + path.sep) && !normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) && dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) @@ -167,7 +173,10 @@ export function constructWebpackConfigFunction( const isMiddlewareResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - return normalizedAbsoluteResourcePath === middlewareJsPath || normalizedAbsoluteResourcePath === middlewareTsPath; + return ( + normalizedAbsoluteResourcePath.startsWith(middlewareLocationFolder + path.sep) && + !!normalizedAbsoluteResourcePath.match(/[\\/]middleware\.(js|jsx|ts|tsx)$/) + ); }; const isServerComponentResource = (resourcePath: string): boolean => { @@ -176,6 +185,7 @@ export function constructWebpackConfigFunction( // ".js, .jsx, or .tsx file extensions can be used for Pages" // https://beta.nextjs.org/docs/routing/pages-and-layouts#pages:~:text=.js%2C%20.jsx%2C%20or%20.tsx%20file%20extensions%20can%20be%20used%20for%20Pages. return ( + appDirPath !== undefined && normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) && !!normalizedAbsoluteResourcePath.match(/[\\/](page|layout|loading|head|not-found)\.(js|jsx|tsx)$/) ); @@ -184,6 +194,7 @@ export function constructWebpackConfigFunction( const isRouteHandlerResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); return ( + appDirPath !== undefined && normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) && !!normalizedAbsoluteResourcePath.match(/[\\/]route\.(js|jsx|ts|tsx)$/) ); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 62616c58c3a1..ed3589363539 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -1,6 +1,8 @@ // mock helper functions not tested directly in this file import './mocks'; +import * as fs from 'fs'; + import type { ModuleRuleUseProperty, WebpackModuleRule } from '../../src/config/types'; import { clientBuildContext, @@ -11,6 +13,9 @@ import { } from './fixtures'; import { materializeFinalWebpackConfig } from './testUtils'; +const existsSyncSpy = jest.spyOn(fs, 'existsSync'); +const lstatSyncSpy = jest.spyOn(fs, 'lstatSync'); + type MatcherResult = { pass: boolean; message: () => string }; expect.extend({ @@ -85,6 +90,7 @@ describe('webpack loaders', () => { }); }); + // For these tests we assume that we have an app and pages folder in {rootdir}/src it.each([ { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/testPage.tsx', @@ -139,8 +145,9 @@ describe('webpack loaders', () => { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.ts', expectedWrappingTargetKind: 'middleware', }, + // Since we assume we have a pages file in src middleware will only be included in the build if it is also in src { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.tsx', + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', expectedWrappingTargetKind: undefined, }, { @@ -182,6 +189,22 @@ describe('webpack loaders', () => { ])( 'should apply the right wrappingTargetKind with wrapping loader ($resourcePath)', async ({ resourcePath, expectedWrappingTargetKind }) => { + // We assume that we have an app and pages folder in {rootdir}/src + existsSyncSpy.mockImplementation(path => { + if ( + path.toString().startsWith('/Users/Maisey/projects/squirrelChasingSimulator/app') || + path.toString().startsWith('/Users/Maisey/projects/squirrelChasingSimulator/pages') + ) { + return false; + } + return true; + }); + + // @ts-expect-error Too lazy to mock the entire thing + lstatSyncSpy.mockImplementation(() => ({ + isDirectory: () => true, + })); + const finalWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, incomingWebpackConfig: serverWebpackConfig, From eb2d726f9c8dfc14f6d6941fedf76bc7e8449d3e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 Nov 2023 16:58:50 +0100 Subject: [PATCH 16/21] feat(nextjs): Add request data to all edge-capable functionalities (#9636) --- .../app/edge-server-components/page.tsx | 7 ++++ .../nextjs-app-dir/sentry.client.config.ts | 1 + .../nextjs-app-dir/sentry.edge.config.ts | 1 + .../nextjs-app-dir/sentry.server.config.ts | 1 + .../nextjs-app-dir/tests/edge-route.test.ts | 7 +++- .../nextjs-app-dir/tests/edge.test.ts | 15 +++++++- .../tests/route-handlers.test.ts | 3 +- .../nextjs-app-dir/tests/transactions.test.ts | 2 ++ packages/nextjs/src/common/types.ts | 23 +++++++++++- .../src/common/utils/edgeWrapperUtils.ts | 9 ++++- .../src/common/wrapRouteHandlerWithSentry.ts | 13 +++++-- .../common/wrapServerComponentWithSentry.ts | 15 ++++++-- .../templates/requestAsyncStorageShim.ts | 6 ++-- .../serverComponentWrapperTemplate.ts | 4 +++ .../nextjs/test/edge/edgeWrapperUtils.test.ts | 6 +++- .../nextjs/test/edge/withSentryAPI.test.ts | 2 +- packages/types/src/index.ts | 1 + packages/types/src/instrument.ts | 11 +++--- packages/types/src/webfetchapi.ts | 17 +++++++++ packages/utils/src/requestdata.ts | 36 +++++++++++++++++++ packages/vercel-edge/src/index.ts | 5 +-- packages/vercel-edge/src/sdk.ts | 13 +++++-- 22 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx create mode 100644 packages/types/src/webfetchapi.ts diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx new file mode 100644 index 000000000000..c7a6a8887e90 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx @@ -0,0 +1,7 @@ +export const dynamic = 'force-dynamic'; + +export const runtime = 'edge'; + +export default async function Page() { + return

Hello world!

; +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts index ad780407a5b7..85bd765c9c44 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts @@ -5,4 +5,5 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, + sendDefaultPii: true, }); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts index ad780407a5b7..85bd765c9c44 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts @@ -5,4 +5,5 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, + sendDefaultPii: true, }); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts index ad780407a5b7..85bd765c9c44 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts @@ -5,4 +5,5 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, + sendDefaultPii: true, }); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts index 0f5ee6100a19..0f02274f9e4c 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -8,7 +8,11 @@ test('Should create a transaction for edge routes', async ({ request }) => { ); }); - const response = await request.get('/api/edge-endpoint'); + const response = await request.get('/api/edge-endpoint', { + headers: { + 'x-yeet': 'test-value', + }, + }); expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); const edgerouteTransaction = await edgerouteTransactionPromise; @@ -16,6 +20,7 @@ test('Should create a transaction for edge routes', async ({ request }) => { expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value'); }); test('Should create a transaction with error status for faulty edge routes', async ({ request }) => { diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts index 26a9e7fed310..39cdd753e85c 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { waitForError } from '../event-proxy-server'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; test('Should record exceptions for faulty edge server components', async ({ page }) => { const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { @@ -10,3 +10,16 @@ test('Should record exceptions for faulty edge server components', async ({ page expect(await errorEventPromise).toBeDefined(); }); + +test('Should record transaction for edge server components', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'Page Server Component (/edge-server-components)'; + }); + + await page.goto('/edge-server-components'); + + const serverComponentTransaction = await serverComponentTransactionPromise; + + expect(serverComponentTransaction).toBeDefined(); + expect(serverComponentTransaction.request?.headers).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index cbd4414436f6..775cbe462e89 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -6,13 +6,14 @@ test('Should create a transaction for route handlers', async ({ request }) => { return transactionEvent?.transaction === 'GET /route-handlers/[param]'; }); - const response = await request.get('/route-handlers/foo'); + const response = await request.get('/route-handlers/foo', { headers: { 'x-yeet': 'test-value' } }); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); const routehandlerTransaction = await routehandlerTransactionPromise; expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.request?.headers?.['x-yeet']).toBe('test-value'); }); test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 597b9ad66072..f47b5e60c85d 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -63,6 +63,8 @@ if (process.env.TEST_ENV === 'production') { const transactionEvent = await serverComponentTransactionPromise; const transactionEventId = transactionEvent.event_id; + expect(transactionEvent.request?.headers).toBeDefined(); + await expect .poll( async () => { diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index 7c1ce7425108..9e3218959b34 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -1,17 +1,38 @@ -import type { Transaction, WrappedFunction } from '@sentry/types'; +import type { Transaction, WebFetchHeaders, WrappedFunction } from '@sentry/types'; import type { NextApiRequest, NextApiResponse } from 'next'; export type ServerComponentContext = { componentRoute: string; componentType: string; + // TODO(v8): Remove + /** + * @deprecated pass a complete `Headers` object with the `headers` field instead. + */ sentryTraceHeader?: string; + // TODO(v8): Remove + /** + * @deprecated pass a complete `Headers` object with the `headers` field instead. + */ baggageHeader?: string; + headers?: WebFetchHeaders; }; export interface RouteHandlerContext { + // TODO(v8): Remove + /** + * @deprecated The SDK will automatically pick up the method from the incoming Request object instead. + */ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; parameterizedRoute: string; + // TODO(v8): Remove + /** + * @deprecated The SDK will automatically pick up the `sentry-trace` header from the incoming Request object instead. + */ sentryTraceHeader?: string; + // TODO(v8): Remove + /** + * @deprecated The SDK will automatically pick up the `baggage` header from the incoming Request object instead. + */ baggageHeader?: string; } diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts index df169a6f7e2d..bdf7b389fa00 100644 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts @@ -1,6 +1,12 @@ import { addTracingExtensions, captureException, flush, getCurrentHub, startTransaction } from '@sentry/core'; import type { Span } from '@sentry/types'; -import { addExceptionMechanism, logger, objectify, tracingContextFromHeaders } from '@sentry/utils'; +import { + addExceptionMechanism, + logger, + objectify, + tracingContextFromHeaders, + winterCGRequestToRequestData, +} from '@sentry/utils'; import type { EdgeRouteHandler } from '../../edge/types'; @@ -44,6 +50,7 @@ export function withEdgeWrapping( origin: 'auto.ui.nextjs.withEdgeWrapping', ...traceparentData, metadata: { + request: winterCGRequestToRequestData(req), dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, source: 'route', }, diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index d407a2578b5b..c03bb3db0dbf 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; -import { tracingContextFromHeaders } from '@sentry/utils'; +import { tracingContextFromHeaders, winterCGRequestToRequestData } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; @@ -14,6 +14,7 @@ export function wrapRouteHandlerWithSentry any>( context: RouteHandlerContext, ): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise> { addTracingExtensions(); + // eslint-disable-next-line deprecation/deprecation const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context; return new Proxy(routeHandler, { apply: (originalFunction, thisArg, args) => { @@ -21,6 +22,13 @@ export function wrapRouteHandlerWithSentry any>( const hub = getCurrentHub(); const currentScope = hub.getScope(); + let req: Request | undefined; + let reqMethod: string | undefined; + if (args[0] instanceof Request) { + req = args[0]; + reqMethod = req.method; + } + const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( sentryTraceHeader, baggageHeader, @@ -32,10 +40,11 @@ export function wrapRouteHandlerWithSentry any>( res = await trace( { op: 'http.server', - name: `${method} ${parameterizedRoute}`, + name: `${reqMethod ?? method} ${parameterizedRoute}`, status: 'ok', ...traceparentData, metadata: { + request: req ? winterCGRequestToRequestData(req) : undefined, source: 'route', dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index fc215e495b58..beb17980de5e 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -6,7 +6,7 @@ import { runWithAsyncContext, startTransaction, } from '@sentry/core'; -import { tracingContextFromHeaders } from '@sentry/utils'; +import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; @@ -33,9 +33,15 @@ export function wrapServerComponentWithSentry any> let maybePromiseResult; + const completeHeadersDict: Record = context.headers + ? winterCGHeadersToDict(context.headers) + : {}; + const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - context.sentryTraceHeader, - context.baggageHeader, + // eslint-disable-next-line deprecation/deprecation + context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'], + // eslint-disable-next-line deprecation/deprecation + context.baggageHeader ?? completeHeadersDict['baggage'], ); currentScope.setPropagationContext(propagationContext); @@ -46,6 +52,9 @@ export function wrapServerComponentWithSentry any> origin: 'auto.function.nextjs', ...traceparentData, metadata: { + request: { + headers: completeHeadersDict, + }, source: 'component', dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, }, diff --git a/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts b/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts index 44222403d026..4acb61e78444 100644 --- a/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts +++ b/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts @@ -1,9 +1,9 @@ +import type { WebFetchHeaders } from '@sentry/types'; + export interface RequestAsyncStorage { getStore: () => | { - headers: { - get: Headers['get']; - }; + headers: WebFetchHeaders; } | undefined; } diff --git a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts index 6730202ecaa5..d4251c5a521a 100644 --- a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts @@ -7,6 +7,7 @@ import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM_ import * as serverComponentModule from '__SENTRY_WRAPPING_TARGET_FILE__'; // eslint-disable-next-line import/no-extraneous-dependencies import * as Sentry from '@sentry/nextjs'; +import type { WebFetchHeaders } from '@sentry/types'; import type { RequestAsyncStorage } from './requestAsyncStorageShim'; @@ -27,12 +28,14 @@ if (typeof serverComponent === 'function') { apply: (originalFunction, thisArg, args) => { let sentryTraceHeader: string | undefined | null = undefined; let baggageHeader: string | undefined | null = undefined; + let headers: WebFetchHeaders | undefined = undefined; // We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined try { const requestAsyncStore = requestAsyncStorage.getStore(); sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace'); baggageHeader = requestAsyncStore?.headers.get('baggage'); + headers = requestAsyncStore?.headers; } catch (e) { /** empty */ } @@ -42,6 +45,7 @@ if (typeof serverComponent === 'function') { componentType: '__COMPONENT_TYPE__', sentryTraceHeader, baggageHeader, + headers, }).apply(thisArg, args); }, }); diff --git a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts index 872277339c68..7ef9b6f5a85f 100644 --- a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts +++ b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts @@ -87,7 +87,11 @@ describe('withEdgeWrapping', () => { await wrappedFunction(request); expect(startTransactionSpy).toHaveBeenCalledTimes(1); expect(startTransactionSpy).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }), + expect.objectContaining({ + metadata: expect.objectContaining({ source: 'route' }), + name: 'some label', + op: 'some op', + }), ); }); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 0fb111df57a6..d2dbc9385c44 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -52,7 +52,7 @@ describe('wrapApiHandlerWithSentry', () => { expect(startTransactionSpy).toHaveBeenCalledTimes(1); expect(startTransactionSpy).toHaveBeenCalledWith( expect.objectContaining({ - metadata: { source: 'route' }, + metadata: expect.objectContaining({ source: 'route' }), name: 'POST /user/[userId]/post/[postId]', op: 'http.server', }), diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4c648f35363e..cf7d801dee8b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -122,6 +122,7 @@ export type { TransportRequestExecutor, } from './transport'; export type { User, UserFeedback } from './user'; +export type { WebFetchHeaders, WebFetchRequest } from './webfetchapi'; export type { WrappedFunction } from './wrappedfunction'; export type { Instrumenter } from './instrumenter'; export type { diff --git a/packages/types/src/instrument.ts b/packages/types/src/instrument.ts index e51f9a3eec44..16ec5a3b75b7 100644 --- a/packages/types/src/instrument.ts +++ b/packages/types/src/instrument.ts @@ -1,5 +1,8 @@ // This should be: null | Blob | BufferSource | FormData | URLSearchParams | string // But since not all of those are available in node, we just export `unknown` here for now + +import type { WebFetchHeaders } from './webfetchapi'; + // Make sure to cast it where needed! type XHRSendInput = unknown; @@ -54,13 +57,7 @@ export interface HandlerDataFetch { readonly ok: boolean; readonly status: number; readonly url: string; - headers: { - append(name: string, value: string): void; - delete(name: string): void; - get(name: string): string | null; - has(name: string): boolean; - set(name: string, value: string): void; - }; + headers: WebFetchHeaders; }; error?: unknown; } diff --git a/packages/types/src/webfetchapi.ts b/packages/types/src/webfetchapi.ts new file mode 100644 index 000000000000..78b7d464ea71 --- /dev/null +++ b/packages/types/src/webfetchapi.ts @@ -0,0 +1,17 @@ +// These are vendored types for the standard web fetch API types because typescript needs the DOM types to be able to understand the `Request`, `Headers`, ... types and not everybody has those. + +export interface WebFetchHeaders { + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + has(name: string): boolean; + set(name: string, value: string): void; + forEach(callbackfn: (value: string, key: string, parent: WebFetchHeaders) => void): void; +} + +export interface WebFetchRequest { + readonly headers: WebFetchHeaders; + readonly method: string; + readonly url: string; + clone(): WebFetchRequest; +} diff --git a/packages/utils/src/requestdata.ts b/packages/utils/src/requestdata.ts index f5c39292bd54..a138ecf3141b 100644 --- a/packages/utils/src/requestdata.ts +++ b/packages/utils/src/requestdata.ts @@ -4,10 +4,13 @@ import type { PolymorphicRequest, Transaction, TransactionSource, + WebFetchHeaders, + WebFetchRequest, } from '@sentry/types'; import { parseCookie } from './cookie'; import { isPlainObject, isString } from './is'; +import { logger } from './logger'; import { normalize } from './normalize'; import { stripUrlQueryAndFragment } from './url'; @@ -361,3 +364,36 @@ function extractQueryParams( undefined ); } + +/** + * Transforms a `Headers` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into a simple key-value dict. + * The header keys will be lower case: e.g. A "Content-Type" header will be stored as "content-type". + */ +export function winterCGHeadersToDict(winterCGHeaders: WebFetchHeaders): Record { + const headers: Record = {}; + try { + winterCGHeaders.forEach((value, key) => { + if (typeof value === 'string') { + // We check that value is a string even though it might be redundant to make sure prototype pollution is not possible. + headers[key] = value; + } + }); + } catch (e) { + __DEBUG_BUILD__ && + logger.warn('Sentry failed extracting headers from a request object. If you see this, please file an issue.'); + } + + return headers; +} + +/** + * Converts a `Request` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into the format that the `RequestData` integration understands. + */ +export function winterCGRequestToRequestData(req: WebFetchRequest): PolymorphicRequest { + const headers = winterCGHeadersToDict(req.headers); + return { + method: req.method, + url: req.url, + headers, + }; +} diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index fcb09ebd3094..bd1ce1ba6526 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -69,13 +69,14 @@ export type { SpanStatusType } from '@sentry/core'; export { VercelEdgeClient } from './client'; export { defaultIntegrations, init } from './sdk'; -import { Integrations as CoreIntegrations } from '@sentry/core'; +import { Integrations as CoreIntegrations, RequestData } from '@sentry/core'; import { WinterCGFetch } from './integrations/wintercg-fetch'; const INTEGRATIONS = { ...CoreIntegrations, - ...WinterCGFetch, + WinterCGFetch, + RequestData, }; export { INTEGRATIONS as Integrations }; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index f49115452a5d..5f1640bdf3c8 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,4 +1,5 @@ -import { getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations } from '@sentry/core'; +import { getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations, RequestData } from '@sentry/core'; +import type { Integration } from '@sentry/types'; import { createStackParser, GLOBAL_OBJ, nodeStackLineParser, stackParserFromStackParserOptions } from '@sentry/utils'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; @@ -25,8 +26,16 @@ export const defaultIntegrations = [ export function init(options: VercelEdgeOptions = {}): void { setAsyncLocalStorageAsyncContextStrategy(); + const sdkDefaultIntegrations: Integration[] = [...defaultIntegrations]; + + // TODO(v8): Add the request data integration by default. + // We don't want to add this functionality OOTB without a breaking change because it might contain PII + if (options.sendDefaultPii) { + sdkDefaultIntegrations.push(new RequestData()); + } + if (options.defaultIntegrations === undefined) { - options.defaultIntegrations = defaultIntegrations; + options.defaultIntegrations = sdkDefaultIntegrations; } if (options.dsn === undefined && process.env.SENTRY_DSN) { From 075cc6eee06bb97106aeadb371db6a58c864c1df Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 24 Nov 2023 11:38:24 +0100 Subject: [PATCH 17/21] fix: Remove Auth Token check here (#9651) --- packages/astro/src/integration/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index caaf6a073380..69d551f3f9ab 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -27,9 +27,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { const uploadOptions = options.sourceMapsUploadOptions || {}; const shouldUploadSourcemaps = uploadOptions?.enabled ?? true; - const authToken = uploadOptions.authToken || env.SENTRY_AUTH_TOKEN; - if (shouldUploadSourcemaps && authToken) { + // We don't need to check for AUTH_TOKEN here, because the plugin will pick it up from the env + if (shouldUploadSourcemaps) { updateConfig({ vite: { build: { From 739d904342aaf9327312f409952f14ceff4ae1ab Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 24 Nov 2023 12:39:50 +0100 Subject: [PATCH 18/21] fix: Make full url customizable for Spotlight (#9652) --- packages/node/src/integrations/spotlight.ts | 12 +++--- .../node/test/integrations/spotlight.test.ts | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/node/src/integrations/spotlight.ts b/packages/node/src/integrations/spotlight.ts index 4b4b9d907721..5489933fa6d9 100644 --- a/packages/node/src/integrations/spotlight.ts +++ b/packages/node/src/integrations/spotlight.ts @@ -1,4 +1,4 @@ -import type { Client, Integration } from '@sentry/types'; +import type { Client, Envelope, Integration } from '@sentry/types'; import { logger, serializeEnvelope } from '@sentry/utils'; import * as http from 'http'; import { URL } from 'url'; @@ -6,7 +6,7 @@ import { URL } from 'url'; type SpotlightConnectionOptions = { /** * Set this if the Spotlight Sidecar is not running on localhost:8969 - * By default, the Url is set to http://localhost:8969 + * By default, the Url is set to http://localhost:8969/stream */ sidecarUrl?: string; }; @@ -26,7 +26,7 @@ export class Spotlight implements Integration { public constructor(options?: SpotlightConnectionOptions) { this._options = { - sidecarUrl: options?.sidecarUrl || 'http://localhost:8969', + sidecarUrl: options?.sidecarUrl || 'http://localhost:8969/stream', }; } @@ -61,7 +61,7 @@ function connectToSpotlight(client: Client, options: Required { + client.on('beforeEnvelope', (envelope: Envelope) => { if (failedRequests > 3) { logger.warn('[Spotlight] Disabled Sentry -> Spotlight integration due to too many failed requests'); return; @@ -72,7 +72,7 @@ function connectToSpotlight(client: Client, options: Required { ); }); + it('sends an envelope POST request to a custom sidecar url', () => { + const httpSpy = jest.spyOn(http, 'request').mockImplementationOnce(() => { + return { + on: jest.fn(), + write: jest.fn(), + end: jest.fn(), + } as any; + }); + + let callback: (envelope: Envelope) => void = () => {}; + const clientWithSpy = { + ...client, + on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)), + }; + + const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + + const envelope = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); + + callback(envelope); + + expect(httpSpy).toHaveBeenCalledWith( + { + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + hostname: 'mylocalhost', + method: 'POST', + path: '/abcd', + port: '8888', + }, + expect.any(Function), + ); + }); + describe('no-ops if', () => { it('an invalid URL is passed', () => { const integration = new Spotlight({ sidecarUrl: 'invalid-url' }); From 61bcf73ef28b21a7ac190a0ff57349b1fcf8014d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 27 Nov 2023 14:26:48 +0100 Subject: [PATCH 19/21] feat(astro): Automatically add Sentry middleware in Astro integration (#9532) This change adds automatic registration of our Astro middleware. This is possible since Astro 3.5.2 by [adding middleware](https://docs.astro.build/en/reference/integrations-reference/#addmiddleware-option) entry points in the astro integration's setup hook. This is backwards compatible with previous Astro versions because we can simply check if the `addMiddleware` function exists and only make use of it if it does. --- packages/astro/README.md | 31 +- packages/astro/package.json | 8 +- packages/astro/rollup.npm.config.js | 2 +- packages/astro/src/index.server.ts | 5 +- packages/astro/src/integration/index.ts | 16 +- .../astro/src/integration/middleware/index.ts | 16 + packages/astro/src/integration/types.ts | 26 +- packages/astro/src/server/middleware.ts | 18 +- packages/astro/test/integration/index.test.ts | 84 +++++ .../test/integration/middleware/index.test.ts | 33 ++ yarn.lock | 351 ++++++++++++++---- 11 files changed, 507 insertions(+), 83 deletions(-) create mode 100644 packages/astro/src/integration/middleware/index.ts create mode 100644 packages/astro/test/integration/middleware/index.test.ts diff --git a/packages/astro/README.md b/packages/astro/README.md index c3321f5b311f..e0247bf2d55b 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -56,7 +56,11 @@ Follow [this guide](https://docs.sentry.io/product/accounts/auth-tokens/#organiz SENTRY_AUTH_TOKEN="your-token" ``` -Complete the setup by adding the Sentry middleware to your `src/middleware.js` file: +### Server Instrumentation + +For Astro apps configured for (hybrid) Server Side Rendering (SSR), the Sentry integration will automatically add middleware to your server to instrument incoming requests **if you're using Astro 3.5.0 or newer**. + +If you're using Astro <3.5.0, complete the setup by adding the Sentry middleware to your `src/middleware.js` file: ```javascript // src/middleware.js @@ -69,7 +73,30 @@ export const onRequest = sequence( ); ``` -This middleware creates server-side spans to monitor performance on the server for page load and endpoint requests. +The Sentry middleware enhances the data collected by Sentry on the server side by: +- Enabeling distributed tracing between client and server +- Collecting performance spans for incoming requests +- Enhancing captured errors with additional information + +#### Disable Automatic Server Instrumentation + +You can opt out of using the automatic sentry server instrumentation in your `astro.config.mjs` file: + +```javascript +import { defineConfig } from "astro/config"; +import sentry from "@sentry/astro"; + +export default defineConfig({ + integrations: [ + sentry({ + dsn: "__DSN__", + autoInstrumentation: { + requestHandler: false, + } + }), + ], +}); +``` ## Configuration diff --git a/packages/astro/package.json b/packages/astro/package.json index c1e52b4c67e4..07414060d2f6 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -28,6 +28,12 @@ "import": "./build/esm/index.client.js", "require": "./build/cjs/index.server.js", "types": "./build/types/index.types.d.ts" + }, + "./middleware": { + "node": "./build/esm/integration/middleware/index.js", + "import": "./build/esm/integration/middleware/index.js", + "require": "./build/cjs/integration/middleware/index.js", + "types": "./build/types/integration/middleware/index.types.d.ts" } }, "publishConfig": { @@ -45,7 +51,7 @@ "@sentry/vite-plugin": "^2.8.0" }, "devDependencies": { - "astro": "^3.2.3", + "astro": "^3.5.0", "rollup": "^3.20.2", "vite": "4.0.5" }, diff --git a/packages/astro/rollup.npm.config.js b/packages/astro/rollup.npm.config.js index 06dd0b3e4ec1..eaab8bdb45bf 100644 --- a/packages/astro/rollup.npm.config.js +++ b/packages/astro/rollup.npm.config.js @@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js' const variants = makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.server.ts', 'src/index.client.ts'], + entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/integration/middleware/index.ts'], packageSpecificConfig: { output: { dynamicImportInCjs: true, diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 94f19936dd54..6bc9db94d4b1 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -4,6 +4,7 @@ // on the top - level namespace. import { sentryAstro } from './integration'; +import { handleRequest } from './server/middleware'; // Hence, we export everything from the Node SDK explicitly: export { @@ -64,6 +65,8 @@ export { export * from '@sentry/node'; export { init } from './server/sdk'; -export { handleRequest } from './server/middleware'; export default sentryAstro; + +// This exports the `handleRequest` middleware for manual usage +export { handleRequest }; diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 69d551f3f9ab..5f5629697e3e 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -14,7 +14,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { name: PKG_NAME, hooks: { // eslint-disable-next-line complexity - 'astro:config:setup': async ({ updateConfig, injectScript, config }) => { + 'astro:config:setup': async ({ updateConfig, injectScript, addMiddleware, config }) => { // The third param here enables loading of all env vars, regardless of prefix // see: https://main.vitejs.dev/config/#using-environment-variables-in-config @@ -73,6 +73,20 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { options.debug && console.log('[sentry-astro] Using default server init.'); injectScript('page-ssr', buildServerSnippet(options || {})); } + + const isSSR = config && (config.output === 'server' || config.output === 'hybrid'); + const shouldAddMiddleware = options.autoInstrumentation?.requestHandler !== false; + + // Guarding calling the addMiddleware function because it was only introduced in astro@3.5.0 + // Users on older versions of astro will need to add the middleware manually. + const supportsAddMiddleware = typeof addMiddleware === 'function'; + + if (supportsAddMiddleware && isSSR && shouldAddMiddleware) { + addMiddleware({ + order: 'pre', + entrypoint: '@sentry/astro/middleware', + }); + } }, }, }; diff --git a/packages/astro/src/integration/middleware/index.ts b/packages/astro/src/integration/middleware/index.ts new file mode 100644 index 000000000000..e220f8843070 --- /dev/null +++ b/packages/astro/src/integration/middleware/index.ts @@ -0,0 +1,16 @@ +import type { MiddlewareResponseHandler } from 'astro'; + +import { handleRequest } from '../../server/middleware'; + +/** + * This export is used by our integration to automatically add the middleware + * to astro ^3.5.0 projects. + * + * It's not possible to pass options at this moment, so we'll call our middleware + * factory function with the default options. Users can deactiveate the automatic + * middleware registration in our integration and manually add it in their own + * `/src/middleware.js` file. + */ +export const onRequest: MiddlewareResponseHandler = (ctx, next) => { + return handleRequest()(ctx, next); +}; diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts index d54c948401ca..7d27402c5a45 100644 --- a/packages/astro/src/integration/types.ts +++ b/packages/astro/src/integration/types.ts @@ -71,6 +71,29 @@ type SourceMapsOptions = { }; }; +type InstrumentationOptions = { + /** + * Options for automatic instrumentation of your application. + */ + autoInstrumentation?: { + /** + * If this flag is `true` and your application is configured for SSR (or hybrid) mode, + * the Sentry integration will automatically add middleware to: + * + * - capture server performance data and spans for incoming server requests + * - enable distributed tracing between server and client + * - annotate server errors with more information + * + * This middleware will only be added automatically in Astro 3.5.0 and newer. + * For older versions, add the `Sentry.handleRequest` middleware manually + * in your `src/middleware.js` file. + * + * @default true in SSR/hybrid mode, false in SSG/static mode + */ + requestHandler?: boolean; + }; +}; + /** * A subset of Sentry SDK options that can be set via the `sentryAstro` integration. * Some options (e.g. integrations) are set by default and cannot be changed here. @@ -83,4 +106,5 @@ type SourceMapsOptions = { export type SentryOptions = SdkInitPaths & Pick & Pick & - SourceMapsOptions; + SourceMapsOptions & + InstrumentationOptions; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 22823f1e6130..fc63639691ea 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,6 +1,11 @@ import { captureException, configureScope, getCurrentHub, startSpan } from '@sentry/node'; import type { Hub, Span } from '@sentry/types'; -import { objectify, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils'; +import { + addNonEnumerableProperty, + objectify, + stripUrlQueryAndFragment, + tracingContextFromHeaders, +} from '@sentry/utils'; import type { APIContext, MiddlewareResponseHandler } from 'astro'; import { getTracingMetaTags } from './meta'; @@ -47,10 +52,21 @@ function sendErrorToSentry(e: unknown): unknown { return objectifiedErr; } +type AstroLocalsWithSentry = Record & { + __sentry_wrapped__?: boolean; +}; + export const handleRequest: (options?: MiddlewareOptions) => MiddlewareResponseHandler = ( options = { trackClientIp: false, trackHeaders: false }, ) => { return async (ctx, next) => { + // Make sure we don't accidentally double wrap (e.g. user added middleware and integration auto added it) + const locals = ctx.locals as AstroLocalsWithSentry; + if (locals && locals.__sentry_wrapped__) { + return next(); + } + addNonEnumerableProperty(locals, '__sentry_wrapped__', true); + const method = ctx.request.method; const headers = ctx.request.headers; diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 2f98fc991065..51132971321d 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -122,4 +122,88 @@ describe('sentryAstro integration', () => { expect(injectScript).toHaveBeenCalledWith('page', expect.stringContaining('my-client-init-path.js')); expect(injectScript).toHaveBeenCalledWith('page-ssr', expect.stringContaining('my-server-init-path.js')); }); + + it.each(['server', 'hybrid'])( + 'adds middleware by default if in %s mode and `addMiddleware` is available', + async mode => { + const integration = sentryAstro({}); + const addMiddleware = vi.fn(); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: mode }, + addMiddleware, + updateConfig, + injectScript, + }); + + expect(addMiddleware).toHaveBeenCalledTimes(1); + expect(addMiddleware).toHaveBeenCalledWith({ + order: 'pre', + entrypoint: '@sentry/astro/middleware', + }); + }, + ); + + it.each([{ output: 'static' }, { output: undefined }])( + "doesn't add middleware if in static mode (config %s)", + async config => { + const integration = sentryAstro({}); + const addMiddleware = vi.fn(); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + config, + addMiddleware, + updateConfig, + injectScript, + }); + + expect(addMiddleware).toHaveBeenCalledTimes(0); + }, + ); + + it("doesn't add middleware if disabled by users", async () => { + const integration = sentryAstro({ autoInstrumentation: { requestHandler: false } }); + const addMiddleware = vi.fn(); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: 'server' }, + addMiddleware, + updateConfig, + injectScript, + }); + + expect(addMiddleware).toHaveBeenCalledTimes(0); + }); + + it("doesn't add middleware (i.e. crash) if `addMiddleware` is N/A", async () => { + const integration = sentryAstro({ autoInstrumentation: { requestHandler: false } }); + const updateConfig = vi.fn(); + const injectScript = vi.fn(); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + // @ts-expect-error - we only need to pass what we actually use + config: { output: 'server' }, + updateConfig, + injectScript, + }); + + expect(updateConfig).toHaveBeenCalledTimes(1); + expect(injectScript).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/astro/test/integration/middleware/index.test.ts b/packages/astro/test/integration/middleware/index.test.ts new file mode 100644 index 000000000000..b9d1273261de --- /dev/null +++ b/packages/astro/test/integration/middleware/index.test.ts @@ -0,0 +1,33 @@ +import { vi } from 'vitest'; + +import { onRequest } from '../../../src/integration/middleware'; + +vi.mock('../../../src/server/meta', () => ({ + getTracingMetaTags: () => ({ + sentryTrace: '', + baggage: '', + }), +})); + +describe('Integration middleware', () => { + it('exports an onRequest middleware request handler', async () => { + expect(typeof onRequest).toBe('function'); + + const next = vi.fn().mockReturnValue(Promise.resolve(new Response(null, { status: 200, headers: new Headers() }))); + const ctx = { + request: { + method: 'GET', + url: '/users/123/details', + headers: new Headers(), + }, + url: new URL('https://myDomain.io/users/123/details'), + params: { + id: '123', + }, + }; + // @ts-expect-error - a partial ctx object is fine here + const res = await onRequest(ctx, next); + + expect(res).toBeDefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index d32acb0b89ae..81d72c38f57f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -548,20 +548,20 @@ resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06" integrity sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg== -"@astrojs/compiler@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.2.0.tgz#a6b106b7878b461e3d55715d90810a7df5df3ca2" - integrity sha512-JvmckEJgg8uXUw8Rs6VZDvN7LcweCHOdcxsCXpC+4KMDC9FaB5t9EH/NooSE+hu/rnACEhsXA3FKmf9wnhb7hA== +"@astrojs/compiler@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.3.0.tgz#c56bc982f9640e0a9e2bcb8756dd362baee022bb" + integrity sha512-pxYRAaRdMS6XUll8lbFM+Lr0DI1HKIDT+VpiC+S+9di5H/nmm3znZOgdMlLiMxADot+56eps+M1BvtKfQremXA== "@astrojs/internal-helpers@0.2.1": version "0.2.1" resolved "https://registry.yarnpkg.com/@astrojs/internal-helpers/-/internal-helpers-0.2.1.tgz#4e2e6aabaa9819f17119aa10f413c4d6122c94cf" integrity sha512-06DD2ZnItMwUnH81LBLco3tWjcZ1lGU9rLCCBaeUCGYe9cI0wKyY2W3kDyoW1I6GmcWgt1fu+D1CTvz+FIKf8A== -"@astrojs/markdown-remark@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@astrojs/markdown-remark/-/markdown-remark-3.2.1.tgz#0014c9c2d8666af4b2fee0cbd4185201eb328d76" - integrity sha512-Z4YNMRtgFZeHhB29uCZl0B9MbMZddW9ZKCNucapoysbvygbDFF1gGtqpVnf+Lyv3rUBHwM/J5qWB2MSZuTuz1g== +"@astrojs/markdown-remark@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@astrojs/markdown-remark/-/markdown-remark-3.4.0.tgz#fc15c737d41d6689223790323cb373b0fa673a7b" + integrity sha512-uzLSKBQ4e70aH8gEbBHZ2pnv/KOJKB3WrXFBOF0U5Uwjcr2LNWeIBLjPRQjA4tbtteELh84YPBHny21mhvBGVA== dependencies: "@astrojs/prism" "^3.0.0" github-slugger "^2.0.0" @@ -573,7 +573,7 @@ remark-parse "^10.0.2" remark-rehype "^10.1.0" remark-smartypants "^2.0.0" - shiki "^0.14.3" + shikiji "^0.6.8" unified "^10.1.2" unist-util-visit "^4.1.2" vfile "^5.3.7" @@ -585,10 +585,10 @@ dependencies: prismjs "^1.29.0" -"@astrojs/telemetry@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@astrojs/telemetry/-/telemetry-3.0.3.tgz#a7a87a40de74bfeaae78fc4cbec1f6ec1cbf1c36" - integrity sha512-j19Cf5mfyLt9hxgJ9W/FMdAA5Lovfp7/CINNB/7V71GqvygnL7KXhRC3TzfB+PsVQcBtgWZzCXhUWRbmJ64Raw== +"@astrojs/telemetry@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@astrojs/telemetry/-/telemetry-3.0.4.tgz#566257082c87df84fcc136db23e071e1104b13fd" + integrity sha512-A+0c7k/Xy293xx6odsYZuXiaHO0PL+bnDoXOc47sGDF5ffIKdKQGRPFl2NMlCF4L0NqN4Ynbgnaip+pPF0s7pQ== dependencies: ci-info "^3.8.0" debug "^4.3.4" @@ -5943,6 +5943,13 @@ dependencies: "@types/unist" "^2" +"@types/hast@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.3.tgz#7f75e6b43bc3f90316046a287d9ad3888309f7e1" + integrity sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ== + dependencies: + "@types/unist" "*" + "@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8", "@types/history@*": name "@types/history-4" version "4.7.8" @@ -6030,11 +6037,6 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/json5@^0.0.30": - version "0.0.30" - resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" - integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA== - "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" @@ -6298,7 +6300,7 @@ dependencies: "@types/node" "*" -"@types/resolve@1.20.3", "@types/resolve@^1.17.0": +"@types/resolve@1.20.3": version "1.20.3" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.3.tgz#066742d69a0bbba8c5d7d517f82e1140ddeb3c3c" integrity sha512-NH5oErHOtHZYcjCtg69t26aXEk4BN2zLWqf7wnDZ+dpe0iR7Rds1SPGEItl3fca21oOe0n3OCnZ4W7jBxu7FOw== @@ -6694,6 +6696,11 @@ "@typescript-eslint/types" "6.7.4" eslint-visitor-keys "^3.4.1" +"@ungap/structured-clone@^1.0.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + "@vitest/coverage-c8@^0.29.2": version "0.29.2" resolved "https://registry.yarnpkg.com/@vitest/coverage-c8/-/coverage-c8-0.29.2.tgz#30b81e32ff11c20e2f3ab78c84e21b4c6c08190c" @@ -7587,11 +7594,6 @@ ansi-regex@^6.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== -ansi-sequence-parser@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz#e0aa1cdcbc8f8bb0b5bca625aac41f5f056973cf" - integrity sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg== - ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -8086,15 +8088,15 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -astro@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/astro/-/astro-3.2.3.tgz#a6f14bf946555683ee1537a345e4a819a3aeff9b" - integrity sha512-1epnxQhTbfzgdmLP1yu51E8zjIOKYxZyA8hMTD4S2E+F5gMp/D81H4hekPbbq89GDxNJiHDRNZDHtS5vrU5E5w== +astro@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/astro/-/astro-3.5.0.tgz#45f0852e9384dc997d4285b567c8a0ee89b58c9b" + integrity sha512-Wwu9gXIlxqCUEY6Bj9e08TeBm7I/CupyzHrWTNSPL+iwrTo/3/pL+itCeYz2u84jRygBgd2oPEN0FbK/sjj+uQ== dependencies: - "@astrojs/compiler" "^2.1.0" + "@astrojs/compiler" "^2.3.0" "@astrojs/internal-helpers" "0.2.1" - "@astrojs/markdown-remark" "3.2.1" - "@astrojs/telemetry" "3.0.3" + "@astrojs/markdown-remark" "3.4.0" + "@astrojs/telemetry" "3.0.4" "@babel/core" "^7.22.10" "@babel/generator" "^7.22.10" "@babel/parser" "^7.22.10" @@ -8110,6 +8112,7 @@ astro@^3.2.3: common-ancestor-path "^1.0.1" cookie "^0.5.0" debug "^4.3.4" + deterministic-object-hash "^1.3.1" devalue "^4.3.2" diff "^5.1.0" es-module-lexer "^1.3.0" @@ -8124,9 +8127,11 @@ astro@^3.2.3: js-yaml "^4.1.0" kleur "^4.1.4" magic-string "^0.30.3" + mdast-util-to-hast "12.3.0" mime "^3.0.0" ora "^7.0.1" p-limit "^4.0.0" + p-queue "^7.4.1" path-to-regexp "^6.2.1" preferred-pm "^3.1.2" probe-image-size "^7.2.3" @@ -8135,17 +8140,17 @@ astro@^3.2.3: resolve "^1.22.4" semver "^7.5.4" server-destroy "^1.0.1" - shiki "^0.14.3" + shikiji "^0.6.8" string-width "^6.1.0" strip-ansi "^7.1.0" - tsconfig-resolver "^3.0.1" + tsconfck "^3.0.0" unist-util-visit "^4.1.2" vfile "^5.3.7" vite "^4.4.9" vitefu "^0.2.4" which-pm "^2.1.1" yargs-parser "^21.1.1" - zod "3.21.1" + zod "^3.22.4" optionalDependencies: sharp "^0.32.5" @@ -12641,6 +12646,11 @@ detective-typescript@^7.0.0: node-source-walk "^4.2.0" typescript "^3.9.7" +deterministic-object-hash@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/deterministic-object-hash/-/deterministic-object-hash-1.3.1.tgz#8df6723f71d005600041aad39054b35ecdf536ac" + integrity sha512-kQDIieBUreEgY+akq0N7o4FzZCr27dPG1xr3wq267vPwDlSXQ3UMcBXHqTGUBaM/5WDS1jwTYjxRhUzHeuiAvw== + devalue@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.0.tgz#d86db8fee63a70317c2355be0d3d1b4d8f89a44e" @@ -12651,6 +12661,13 @@ devalue@^4.3.2: resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.2.tgz#cc44e4cf3872ac5a78229fbce3b77e57032727b5" integrity sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg== +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + dezalgo@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" @@ -13830,6 +13847,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + entities@~1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -14620,6 +14642,11 @@ eventemitter3@^4.0.0, eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events-to-array@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-1.1.2.tgz#2d41f563e1fe400ed4962fe1a4d5c6a7539df7f6" @@ -16627,6 +16654,20 @@ hast-util-from-parse5@^7.0.0: vfile-location "^4.0.0" web-namespaces "^2.0.0" +hast-util-from-parse5@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz#654a5676a41211e14ee80d1b1758c399a0327651" + integrity sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + devlop "^1.0.0" + hastscript "^8.0.0" + property-information "^6.0.0" + vfile "^6.0.0" + vfile-location "^5.0.0" + web-namespaces "^2.0.0" + hast-util-parse-selector@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz#25ab00ae9e75cbc62cf7a901f68a247eade659e2" @@ -16634,6 +16675,13 @@ hast-util-parse-selector@^3.0.0: dependencies: "@types/hast" "^2.0.0" +hast-util-parse-selector@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz#352879fa86e25616036037dd8931fb5f34cb4a27" + integrity sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw@^7.0.0, hast-util-raw@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-7.2.3.tgz#dcb5b22a22073436dbdc4aa09660a644f4991d99" @@ -16651,6 +16699,25 @@ hast-util-raw@^7.0.0, hast-util-raw@^7.2.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-raw@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-9.0.1.tgz#2ba8510e4ed2a1e541cde2a4ebb5c38ab4c82c2d" + integrity sha512-5m1gmba658Q+lO5uqL5YNGQWeh1MYWZbZmWrM5lncdcuiXuo5E2HT/CIOp0rLF8ksfSwiCVJ3twlgVRyTGThGA== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-from-parse5 "^8.0.0" + hast-util-to-parse5 "^8.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + parse5 "^7.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + hast-util-to-html@^8.0.0: version "8.0.4" resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-8.0.4.tgz#0269ef33fa3f6599b260a8dc94f733b8e39e41fc" @@ -16668,6 +16735,24 @@ hast-util-to-html@^8.0.0: stringify-entities "^4.0.0" zwitch "^2.0.4" +hast-util-to-html@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.0.tgz#51c0ae2a3550b9aa988c094c4fc4e327af0dddd1" + integrity sha512-IVGhNgg7vANuUA2XKrT6sOIIPgaYZnmLx3l/CCOAK0PtgfoHrZwX7jCSYyFxHTrGmC6S9q8aQQekjp4JPZF+cw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-raw "^9.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + hast-util-to-parse5@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz#c49391bf8f151973e0c9adcd116b561e8daf29f3" @@ -16680,11 +16765,31 @@ hast-util-to-parse5@^7.0.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-to-parse5@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz#477cd42d278d4f036bc2ea58586130f6f39ee6ed" + integrity sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + hast-util-whitespace@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz#0ec64e257e6fc216c7d14c8a1b74d27d650b4557" integrity sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng== +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + hastscript@^7.0.0: version "7.2.0" resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-7.2.0.tgz#0eafb7afb153d047077fa2a833dc9b7ec604d10b" @@ -16696,6 +16801,17 @@ hastscript@^7.0.0: property-information "^6.0.0" space-separated-tokens "^2.0.0" +hastscript@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-8.0.0.tgz#4ef795ec8dee867101b9f23cc830d4baf4fd781a" + integrity sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + hast-util-parse-selector "^4.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + hdr-histogram-js@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5" @@ -16954,6 +17070,11 @@ html-void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" integrity sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A== +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + html-webpack-plugin@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz#c3911936f57681c1f9f4d8b68c158cd9dfe52f50" @@ -19106,7 +19227,7 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.3, json5@^2.2.2, json5@^2.2.3: +json5@^2.2.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -20680,7 +20801,7 @@ mdast-util-phrasing@^3.0.0: "@types/mdast" "^3.0.0" unist-util-is "^5.0.0" -mdast-util-to-hast@^12.1.0: +mdast-util-to-hast@12.3.0, mdast-util-to-hast@^12.1.0: version "12.3.0" resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz#045d2825fb04374e59970f5b3f279b5700f6fb49" integrity sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw== @@ -20694,6 +20815,20 @@ mdast-util-to-hast@^12.1.0: unist-util-position "^4.0.0" unist-util-visit "^4.0.0" +mdast-util-to-hast@^13.0.0: + version "13.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz#74c0a9f014bb2340cae6118f6fccd75467792be7" + integrity sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + mdast-util-to-markdown@^1.0.0, mdast-util-to-markdown@^1.3.0: version "1.5.0" resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz#c13343cb3fc98621911d33b5cd42e7d0731171c6" @@ -21012,6 +21147,14 @@ micromark-util-character@^1.0.0: micromark-util-symbol "^1.0.0" micromark-util-types "^1.0.0" +micromark-util-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.0.1.tgz#52b824c2e2633b6fb33399d2ec78ee2a90d6b298" + integrity sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromark-util-chunked@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b" @@ -21058,6 +21201,11 @@ micromark-util-encode@^1.0.0: resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5" integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw== +micromark-util-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz#0921ac7953dc3f1fd281e3d1932decfdb9382ab1" + integrity sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA== + micromark-util-html-tag-name@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588" @@ -21086,6 +21234,15 @@ micromark-util-sanitize-uri@^1.0.0, micromark-util-sanitize-uri@^1.1.0: micromark-util-encode "^1.0.0" micromark-util-symbol "^1.0.0" +micromark-util-sanitize-uri@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz#ec8fbf0258e9e6d8f13d9e4770f9be64342673de" + integrity sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-subtokenize@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1" @@ -21101,11 +21258,21 @@ micromark-util-symbol@^1.0.0: resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142" integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag== +micromark-util-symbol@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz#12225c8f95edf8b17254e47080ce0862d5db8044" + integrity sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw== + micromark-util-types@^1.0.0, micromark-util-types@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283" integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg== +micromark-util-types@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.0.tgz#63b4b7ffeb35d3ecf50d1ca20e68fc7caa36d95e" + integrity sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w== + micromark@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9" @@ -23257,6 +23424,14 @@ p-queue@6.6.2: eventemitter3 "^4.0.4" p-timeout "^3.2.0" +p-queue@^7.4.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-7.4.1.tgz#7f86f853048beca8272abdbb7cec1ed2afc0f265" + integrity sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^5.0.2" + p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" @@ -23283,6 +23458,11 @@ p-timeout@^3.0.0, p-timeout@^3.1.0, p-timeout@^3.2.0: dependencies: p-finally "^1.0.0" +p-timeout@^5.0.2: + version "5.1.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-5.1.0.tgz#b3c691cf4415138ce2d9cfe071dba11f0fee085b" + integrity sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew== + p-timeout@^6.0.0: version "6.1.1" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.1.tgz#bcee5e37d730f5474d973b6ff226751a1a5e6ff1" @@ -23564,6 +23744,13 @@ parse5@6.0.1, parse5@^6.0.0, parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -27461,15 +27648,12 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== -shiki@^0.14.3: - version "0.14.4" - resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.14.4.tgz#2454969b466a5f75067d0f2fa0d7426d32881b20" - integrity sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ== +shikiji@^0.6.8: + version "0.6.12" + resolved "https://registry.yarnpkg.com/shikiji/-/shikiji-0.6.12.tgz#2cd28f32380337ef2117cdd5328f868a31e6c416" + integrity sha512-sm7Wg8P4w6T3quDAZQxvk0P02o2hheIFEdbaEuGOhGnqLDjVsP28GDUVPdgbacOIc1auapNVNCVEykhPploLyg== dependencies: - ansi-sequence-parser "^1.1.0" - jsonc-parser "^3.2.0" - vscode-oniguruma "^1.7.0" - vscode-textmate "^8.0.0" + hast-util-to-html "^9.0.0" shimmer@^1.2.1: version "1.2.1" @@ -29671,6 +29855,11 @@ ts-pnp@^1.1.6: resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== +tsconfck@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.0.0.tgz#b469f1ced12973bbec3209a55ed8de3bb04223c9" + integrity sha512-w3wnsIrJNi7avf4Zb0VjOoodoO0woEqGgZGQm+LHH9przdUI+XDKsWAXwxHA1DaRTjeuZNcregSzr7RaA8zG9A== + tsconfig-paths@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" @@ -29690,18 +29879,6 @@ tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tsconfig-resolver@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/tsconfig-resolver/-/tsconfig-resolver-3.0.1.tgz#c9e62e328ecfbeaae4a4f1131a92cdbed12350c4" - integrity sha512-ZHqlstlQF449v8glscGRXzL6l2dZvASPCdXJRWG4gHEZlUVx2Jtmr+a2zeVG4LCsKhDXKRj5R3h0C/98UcVAQg== - dependencies: - "@types/json5" "^0.0.30" - "@types/resolve" "^1.17.0" - json5 "^2.1.3" - resolve "^1.17.0" - strip-bom "^4.0.0" - type-fest "^0.13.1" - tslib@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" @@ -29784,11 +29961,6 @@ type-fest@^0.11.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== -type-fest@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" - integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== - type-fest@^0.18.0: version "0.18.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" @@ -30142,6 +30314,13 @@ unist-util-position@^4.0.0: dependencies: "@types/unist" "^2.0.0" +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d" @@ -30149,6 +30328,13 @@ unist-util-stringify-position@^3.0.0: dependencies: "@types/unist" "^2.0.0" +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-visit-children@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/unist-util-visit-children/-/unist-util-visit-children-2.0.2.tgz#0f00a5caff567074568da2d89c54b5ee4a8c5440" @@ -30606,6 +30792,14 @@ vfile-location@^4.0.0: "@types/unist" "^2.0.0" vfile "^5.0.0" +vfile-location@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-5.0.2.tgz#220d9ca1ab6f8b2504a4db398f7ebc149f9cb464" + integrity sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg== + dependencies: + "@types/unist" "^3.0.0" + vfile "^6.0.0" + vfile-message@^3.0.0: version "3.1.4" resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea" @@ -30614,6 +30808,14 @@ vfile-message@^3.0.0: "@types/unist" "^2.0.0" unist-util-stringify-position "^3.0.0" +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + vfile@^5.0.0, vfile@^5.3.7: version "5.3.7" resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7" @@ -30624,6 +30826,15 @@ vfile@^5.0.0, vfile@^5.3.7: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" +vfile@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.1.tgz#1e8327f41eac91947d4fe9d237a2dd9209762536" + integrity sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + vite-node@0.29.2: version "0.29.2" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.29.2.tgz#463626197e248971774075faf3d6896c29cf8062" @@ -30721,16 +30932,6 @@ void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= -vscode-oniguruma@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz#439bfad8fe71abd7798338d1cd3dc53a8beea94b" - integrity sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA== - -vscode-textmate@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-8.0.0.tgz#2c7a3b1163ef0441097e0b5d6389cd5504b59e5d" - integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg== - vue@~3.2.41: version "3.2.45" resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.45.tgz#94a116784447eb7dbd892167784619fef379b3c8" @@ -31865,10 +32066,10 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zod@3.21.1: - version "3.21.1" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.1.tgz#ac5bb7cf68876281ebd02f95ac4bb9a080370282" - integrity sha512-+dTu2m6gmCbO9Ahm4ZBDapx2O6ZY9QSPXst2WXjcznPMwf2YNpn3RevLx4KkZp1OPW/ouFcoBtBzFz/LeY69oA== +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== zone.js@^0.11.8, zone.js@~0.11.4: version "0.11.8" From 4c75f859a299b0f92146b347fa4c58c2e55db625 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 27 Nov 2023 14:22:31 +0000 Subject: [PATCH 20/21] fix(remix): Skip capturing aborted requests. (#9659) Remix documentation suggests users avoid capturing aborted requests. --- packages/remix/src/utils/instrumentServer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 935e24124b49..714b6b51e09f 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -89,6 +89,13 @@ export async function captureRemixServerException(err: unknown, name: string, re return; } + // Skip capturing if the request is aborted as Remix docs suggest + // Ref: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror + if (request.signal.aborted) { + __DEBUG_BUILD__ && logger.warn('Skipping capture of aborted request'); + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any let normalizedRequest: Record = request as unknown as any; From cf8cda8bd8a228a79e94d25a0f44dabd517ecf35 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 27 Nov 2023 15:45:43 +0000 Subject: [PATCH 21/21] meta(changelog): Update changelog for 7.82.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b8e6c9f0f6..2a29d239945b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.82.0 + +- feat(astro): Automatically add Sentry middleware in Astro integration (#9532) +- feat(core): Add optional `setup` hook to integrations (#9556) +- feat(core): Add top level `getClient()` method (#9638) +- feat(core): Allow to pass `mechanism` as event hint (#9590) +- feat(core): Allow to use `continueTrace` without callback (#9615) +- feat(feedback): Add onClose callback to showReportDialog (#9433) (#9550) +- feat(nextjs): Add request data to all edge-capable functionalities (#9636) +- feat(node): Add Spotlight option to Node SDK (#9629) +- feat(utils): Refactor `addInstrumentationHandler` to dedicated methods (#9542) +- fix: Make full url customizable for Spotlight (#9652) +- fix(astro): Remove Auth Token existence check (#9651) +- fix(nextjs): Fix middleware detection logic (#9637) +- fix(remix): Skip capturing aborted requests (#9659) +- fix(replay): Add `BODY_PARSE_ERROR` warning & time out fetch response load (#9622) +- fix(tracing): Filter out invalid resource sizes (#9641) +- ref: Hoist `RequestData` integration to `@sentry/core` (#9597) +- ref(feedback): Rename onDialog* to onForm*, remove onActorClick (#9625) + +Work in this release contributed by @arya-s. Thank you for your contribution! + ## 7.81.1 - fix(astro): Remove method from span op (#9603)