Skip to content

feat(replay): Use unwrapped setTimeout to avoid e.g. angular change detection #11864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions packages/browser-utils/src/getNativeImplementation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from './debug-build';
import { WINDOW } from './types';

/**
* We generally want to use window.fetch / window.setTimeout.
* However, in some cases this may be wrapped (e.g. by Zone.js for Angular),
* so we try to get an unpatched version of this from a sandboxed iframe.
*/

interface CacheableImplementations {
setTimeout: typeof WINDOW.setTimeout;
fetch: typeof WINDOW.fetch;
}

const cachedImplementations: Partial<CacheableImplementations> = {};

/**
* Get the native implementation of a browser function.
*
* This can be used to ensure we get an unwrapped version of a function, in cases where a wrapped function can lead to problems.
*
* The following methods can be retrieved:
* - `setTimeout`: This can be wrapped by e.g. Angular, causing change detection to be triggered.
* - `fetch`: This can be wrapped by e.g. ad-blockers, causing an infinite loop when a request is blocked.
*/
export function getNativeImplementation<T extends keyof CacheableImplementations>(
name: T,
): CacheableImplementations[T] {
const cached = cachedImplementations[name];
if (cached) {
return cached;
}

const document = WINDOW.document;
let impl = WINDOW[name] as CacheableImplementations[T];
// eslint-disable-next-line deprecation/deprecation
if (document && typeof document.createElement === 'function') {
try {
const sandbox = document.createElement('iframe');
sandbox.hidden = true;
document.head.appendChild(sandbox);
const contentWindow = sandbox.contentWindow;
if (contentWindow && contentWindow[name]) {
impl = contentWindow[name] as CacheableImplementations[T];
}
document.head.removeChild(sandbox);
} catch (e) {
// Could not create sandbox iframe, just use window.xxx
DEBUG_BUILD && logger.warn(`Could not create sandbox iframe for ${name} check, bailing to window.${name}: `, e);
}
}

// Sanity check: This _should_ not happen, but if it does, we just skip caching...
// This can happen e.g. in tests where fetch may not be available in the env, or similar.
if (!impl) {
return impl;
}

return (cachedImplementations[name] = impl.bind(WINDOW) as CacheableImplementations[T]);
}

/** Clear a cached implementation. */
export function clearCachedImplementation(name: keyof CacheableImplementations): void {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete cachedImplementations[name];
}

/**
* Sets a cached implementation.
* This should NOT be used, and is only here
* @hidden
*/
export function setCachedImplementation<T extends keyof CacheableImplementations>(
name: T,
impl: CacheableImplementations[T],
): void {
cachedImplementations[name] = impl;
}

/**
* A special usecase for incorrectly wrapped Fetch APIs in conjunction with ad-blockers.
* Whenever someone wraps the Fetch API and returns the wrong promise chain,
* this chain becomes orphaned and there is no possible way to capture it's rejections
* other than allowing it bubble up to this very handler. eg.
*
* const f = window.fetch;
* window.fetch = function () {
* const p = f.apply(this, arguments);
*
* p.then(function() {
* console.log('hi.');
* });
*
* return p;
* }
*
* `p.then(function () { ... })` is producing a completely separate promise chain,
* however, what's returned is `p` - the result of original `fetch` call.
*
* This mean, that whenever we use the Fetch API to send our own requests, _and_
* some ad-blocker blocks it, this orphaned chain will _always_ reject,
* effectively causing another event to be captured.
* This makes a whole process become an infinite loop, which we need to somehow
* deal with, and break it in one way or another.
*
* To deal with this issue, we are making sure that we _always_ use the real
* browser Fetch API, instead of relying on what `window.fetch` exposes.
* The only downside to this would be missing our own requests as breadcrumbs,
* but because we are already not doing this, it should be just fine.
*
* Possible failed fetch error messages per-browser:
*
* Chrome: Failed to fetch
* Edge: Failed to Fetch
* Firefox: NetworkError when attempting to fetch resource
* Safari: resource blocked by content blocker
*/
export function fetch(...rest: Parameters<typeof WINDOW.fetch>): ReturnType<typeof WINDOW.fetch> {
return getNativeImplementation('fetch')(...rest);
}

/**
* Get an unwrapped `setTimeout` method.
* This ensures that even if e.g. Angular wraps `setTimeout`, we get the native implementation,
* avoiding triggering change detection.
*/
export function setTimeout(...rest: Parameters<typeof WINDOW.setTimeout>): ReturnType<typeof WINDOW.setTimeout> {
return getNativeImplementation('setTimeout')(...rest);
}
8 changes: 8 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export { addClickKeypressInstrumentationHandler } from './instrument/dom';

export { addHistoryInstrumentationHandler } from './instrument/history';

export {
fetch,
setTimeout,
clearCachedImplementation,
getNativeImplementation,
setCachedImplementation,
} from './getNativeImplementation';

export {
addXhrInstrumentationHandler,
SENTRY_XHR_DATA_KEY,
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/instrument/dom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HandlerDataDom } from '@sentry/types';

import { addHandler, addNonEnumerableProperty, fill, maybeInstrument, triggerHandlers, uuid4 } from '@sentry/utils';
import { WINDOW } from '../metrics/types';
import { WINDOW } from '../types';

type SentryWrappedTarget = HTMLElement & { _sentryId?: string };

Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/instrument/history.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HandlerDataHistory } from '@sentry/types';
import { addHandler, fill, maybeInstrument, supportsHistory, triggerHandlers } from '@sentry/utils';
import { WINDOW } from '../metrics/types';
import { WINDOW } from '../types';

let lastHref: string | undefined;

Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/instrument/xhr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, WrappedFunction } from '@sentry/types';

import { addHandler, fill, isString, maybeInstrument, triggerHandlers } from '@sentry/utils';
import { WINDOW } from '../metrics/types';
import { WINDOW } from '../types';

export const SENTRY_XHR_DATA_KEY = '__sentry_xhr_v3__';

Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logge

import { spanToJSON } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from './../types';
import {
addClsInstrumentationHandler,
addFidInstrumentationHandler,
addLcpInstrumentationHandler,
addPerformanceInstrumentationHandler,
addTtfbInstrumentationHandler,
} from './instrument';
import { WINDOW } from './types';
import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils';
import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry';
import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/web-vitals/getINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../types';
import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { initMetric } from './lib/initMetric';
import { observe } from './lib/observe';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/web-vitals/getLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../types';
import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { getActivationStart } from './lib/getActivationStart';
import { getVisibilityWatcher } from './lib/getVisibilityWatcher';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { WINDOW } from '../../../types';
import type { NavigationTimingPolyfillEntry } from '../types';

export const getNavigationEntry = (): PerformanceNavigationTiming | NavigationTimingPolyfillEntry | undefined => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { WINDOW } from '../../../types';

let firstHiddenTime = -1;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { WINDOW } from '../../../types';
import type { MetricType } from '../types';
import { generateUniqueID } from './generateUniqueID';
import { getActivationStart } from './getActivationStart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { WINDOW } from '../../../types';

export interface OnHiddenCallback {
(event: Event): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { WINDOW } from '../../../types';

export const whenActivated = (callback: () => void) => {
if (WINDOW.document && WINDOW.document.prerendering) {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/metrics/web-vitals/onTTFB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { WINDOW } from '../types';
import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { getActivationStart } from './lib/getActivationStart';
import { getNavigationEntry } from './lib/getNavigationEntry';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/test/browser/browserMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import type { Span } from '@sentry/types';
import type { ResourceEntry } from '../../src/metrics/browserMetrics';
import { _addMeasureSpans, _addResourceSpans } from '../../src/metrics/browserMetrics';
import { WINDOW } from '../../src/metrics/types';
import { WINDOW } from '../../src/types';
import { TestClient, getDefaultClientOptions } from '../utils/TestClient';

const mockWindowLocation = {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ module.exports = {
env: {
browser: true,
},
ignorePatterns: ['test/integration/**', 'src/loader.js'],
ignorePatterns: ['test/integration/**', 'test/loader.js'],
extends: ['../../.eslintrc.js'],
};
10 changes: 5 additions & 5 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { clearCachedImplementation, getNativeImplementation } from '@sentry-internal/browser-utils';
import { createTransport } from '@sentry/core';
import type { Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types';
import { rejectedSyncPromise } from '@sentry/utils';
import type { WINDOW } from '../helpers';

import type { BrowserTransportOptions } from './types';
import type { FetchImpl } from './utils';
import { clearCachedFetchImplementation, getNativeFetchImplementation } from './utils';

/**
* Creates a Transport that uses the Fetch API to send events to Sentry.
*/
export function makeFetchTransport(
options: BrowserTransportOptions,
nativeFetch: FetchImpl | undefined = getNativeFetchImplementation(),
nativeFetch: typeof WINDOW.fetch | undefined = getNativeImplementation('fetch'),
): Transport {
let pendingBodySize = 0;
let pendingCount = 0;
Expand Down Expand Up @@ -42,7 +42,7 @@ export function makeFetchTransport(
};

if (!nativeFetch) {
clearCachedFetchImplementation();
clearCachedImplementation('fetch');
return rejectedSyncPromise('No fetch implementation available');
}

Expand All @@ -59,7 +59,7 @@ export function makeFetchTransport(
};
});
} catch (e) {
clearCachedFetchImplementation();
clearCachedImplementation('fetch');
pendingBodySize -= requestSize;
pendingCount--;
return rejectedSyncPromise(e);
Expand Down
Loading
Loading