Skip to content
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

ref(browser): Refactor sending client reports w. fetch fallback #4008

Merged
merged 2 commits into from
Sep 27, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/browser/src/transports/base.ts
Expand Up @@ -17,6 +17,8 @@ import {
SentryError,
} from '@sentry/utils';

import { sendReport } from './utils';

const CATEGORY_MAPPING: {
[key in SentryRequestType]: string;
} = {
Expand Down Expand Up @@ -99,11 +101,6 @@ export abstract class BaseTransport implements Transport {
return;
}

if (!global.navigator || typeof global.navigator.sendBeacon !== 'function') {
logger.warn('Beacon API not available, skipping sending outcomes.');
return;
}

const outcomes = this._outcomes;
this._outcomes = {};

Expand Down Expand Up @@ -134,7 +131,7 @@ export abstract class BaseTransport implements Transport {
});
const envelope = `${envelopeHeader}\n${itemHeaders}\n${item}`;

global.navigator.sendBeacon(url, envelope);
sendReport(url, envelope);
}

/**
Expand Down
80 changes: 2 additions & 78 deletions packages/browser/src/transports/fetch.ts
@@ -1,85 +1,9 @@
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
import { Event, Outcome, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
import {
getGlobalObject,
isNativeFetch,
logger,
SentryError,
supportsReferrerPolicy,
SyncPromise,
} from '@sentry/utils';
import { SentryError, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';

import { BaseTransport } from './base';

type FetchImpl = typeof fetch;

/**
* 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
*/
function getNativeFetchImplementation(): FetchImpl {
/* eslint-disable @typescript-eslint/unbound-method */

// Fast path to avoid DOM I/O
const global = getGlobalObject<Window>();
if (isNativeFetch(global.fetch)) {
return global.fetch.bind(global);
}

const document = global.document;
let fetchImpl = global.fetch;
// eslint-disable-next-line deprecation/deprecation
if (typeof document?.createElement === `function`) {
try {
const sandbox = document.createElement('iframe');
sandbox.hidden = true;
document.head.appendChild(sandbox);
if (sandbox.contentWindow?.fetch) {
fetchImpl = sandbox.contentWindow.fetch;
}
document.head.removeChild(sandbox);
} catch (e) {
logger.warn('Could not create sandbox iframe for pure fetch check, bailing to window.fetch: ', e);
}
}

return fetchImpl.bind(global);
/* eslint-enable @typescript-eslint/unbound-method */
}
import { FetchImpl, getNativeFetchImplementation } from './utils';

/** `fetch` based transport */
export class FetchTransport extends BaseTransport {
Expand Down
106 changes: 106 additions & 0 deletions packages/browser/src/transports/utils.ts
@@ -0,0 +1,106 @@
import { forget, getGlobalObject, isNativeFetch, logger, supportsFetch } from '@sentry/utils';

const global = getGlobalObject<Window>();
let cachedFetchImpl: FetchImpl;

export type FetchImpl = typeof fetch;

/**
* 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 getNativeFetchImplementation(): FetchImpl {
if (cachedFetchImpl) {
return cachedFetchImpl;
}

/* eslint-disable @typescript-eslint/unbound-method */

// Fast path to avoid DOM I/O
if (isNativeFetch(global.fetch)) {
return (cachedFetchImpl = global.fetch.bind(global));
}

const document = global.document;
let fetchImpl = global.fetch;
// eslint-disable-next-line deprecation/deprecation
if (typeof document?.createElement === `function`) {
try {
const sandbox = document.createElement('iframe');
sandbox.hidden = true;
document.head.appendChild(sandbox);
if (sandbox.contentWindow?.fetch) {
fetchImpl = sandbox.contentWindow.fetch;
}
document.head.removeChild(sandbox);
} catch (e) {
logger.warn('Could not create sandbox iframe for pure fetch check, bailing to window.fetch: ', e);
}
}

return (cachedFetchImpl = fetchImpl.bind(global));
/* eslint-enable @typescript-eslint/unbound-method */
}

/**
* Sends sdk client report using sendBeacon or fetch as a fallback if available
*
* @param url report endpoint
* @param body report payload
*/
export function sendReport(url: string, body: string): void {
const isRealNavigator = Object.prototype.toString.call(global && global.navigator) === '[object Navigator]';
const hasSendBeacon = isRealNavigator && typeof global.navigator.sendBeacon === 'function';

if (hasSendBeacon) {
// Prevent illegal invocations - https://xgwang.me/posts/you-may-not-know-beacon/#it-may-throw-error%2C-be-sure-to-catch
const sendBeacon = global.navigator.sendBeacon.bind(global.navigator);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol

Should we also wrap in a try catch just in case?

return sendBeacon(url, body);
}

if (supportsFetch()) {
const fetch = getNativeFetchImplementation();
return forget(
fetch(url, {
body,
method: 'POST',
credentials: 'omit',
keepalive: true,
}),
);
}
}