mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
752 lines
23 KiB
TypeScript
752 lines
23 KiB
TypeScript
import {browserHistory} from 'react-router';
|
|
import * as Sentry from '@sentry/react';
|
|
import Cookies from 'js-cookie';
|
|
import * as qs from 'query-string';
|
|
|
|
import {redirectToProject} from 'sentry/actionCreators/redirectToProject';
|
|
import {openSudo} from 'sentry/actionCreators/sudoModal';
|
|
import {EXPERIMENTAL_SPA} from 'sentry/constants';
|
|
import {
|
|
PROJECT_MOVED,
|
|
SUDO_REQUIRED,
|
|
SUPERUSER_REQUIRED,
|
|
} from 'sentry/constants/apiErrorCodes';
|
|
import controlsilopatterns from 'sentry/data/controlsiloUrlPatterns';
|
|
import {metric} from 'sentry/utils/analytics';
|
|
import getCsrfToken from 'sentry/utils/getCsrfToken';
|
|
import {uniqueId} from 'sentry/utils/guid';
|
|
import RequestError from 'sentry/utils/requestError/requestError';
|
|
import {sanitizePath} from 'sentry/utils/requestError/sanitizePath';
|
|
|
|
import ConfigStore from './stores/configStore';
|
|
import OrganizationStore from './stores/organizationStore';
|
|
|
|
export class Request {
|
|
/**
|
|
* Is the request still in flight
|
|
*/
|
|
alive: boolean;
|
|
/**
|
|
* Promise which will be resolved when the request has completed
|
|
*/
|
|
requestPromise: Promise<Response>;
|
|
/**
|
|
* AbortController to cancel the in-flight request. This will not be set in
|
|
* unsupported browsers.
|
|
*/
|
|
aborter?: AbortController;
|
|
|
|
constructor(requestPromise: Promise<Response>, aborter?: AbortController) {
|
|
this.requestPromise = requestPromise;
|
|
this.aborter = aborter;
|
|
this.alive = true;
|
|
}
|
|
|
|
cancel() {
|
|
this.alive = false;
|
|
this.aborter?.abort();
|
|
metric('app.api.request-abort', 1);
|
|
}
|
|
}
|
|
|
|
export type ApiResult<Data = any> = [
|
|
data: Data,
|
|
statusText: string | undefined,
|
|
resp: ResponseMeta | undefined,
|
|
];
|
|
|
|
export type ResponseMeta<R = any> = {
|
|
/**
|
|
* Get a header value from the response
|
|
*/
|
|
getResponseHeader: (header: string) => string | null;
|
|
/**
|
|
* The response body decoded from json
|
|
*/
|
|
responseJSON: R;
|
|
/**
|
|
* The string value of the response
|
|
*/
|
|
responseText: string;
|
|
/**
|
|
* The response status code
|
|
*/
|
|
status: Response['status'];
|
|
/**
|
|
* The response status code text
|
|
*/
|
|
statusText: Response['statusText'];
|
|
};
|
|
|
|
/**
|
|
* Check if the requested method does not require CSRF tokens
|
|
*/
|
|
function csrfSafeMethod(method?: string): boolean {
|
|
// these HTTP methods do not require CSRF protection
|
|
return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method ?? '');
|
|
}
|
|
|
|
/**
|
|
* Check if we a request is going to the same or similar origin.
|
|
* similar origins are those that share an ancestor. Example `sentry.sentry.io` and `us.sentry.io`
|
|
* are similar origins, but sentry.sentry.io and sentry.example.io are not.
|
|
*/
|
|
export function isSimilarOrigin(target: string, origin: string): boolean {
|
|
const targetUrl = new URL(target, origin);
|
|
const originUrl = new URL(origin);
|
|
// If one of the domains is a child of the other.
|
|
if (
|
|
originUrl.hostname.endsWith(targetUrl.hostname) ||
|
|
targetUrl.hostname.endsWith(originUrl.hostname)
|
|
) {
|
|
return true;
|
|
}
|
|
// Check if the target and origin are on sibiling subdomains.
|
|
const targetHost = targetUrl.hostname.split('.');
|
|
const originHost = originUrl.hostname.split('.');
|
|
|
|
// Remove the subdomains. If don't have at least 2 segments we aren't subdomains.
|
|
targetHost.shift();
|
|
originHost.shift();
|
|
if (targetHost.length < 2 || originHost.length < 2) {
|
|
return false;
|
|
}
|
|
return targetHost.join('.') === originHost.join('.');
|
|
}
|
|
|
|
// TODO: Need better way of identifying anonymous pages that don't trigger redirect
|
|
const ALLOWED_ANON_PAGES = [
|
|
/^\/accept\//,
|
|
/^\/share\//,
|
|
/^\/auth\/login\//,
|
|
/^\/join-request\//,
|
|
/^\/unsubscribe\//,
|
|
];
|
|
|
|
/**
|
|
* Return true if we should skip calling the normal error handler
|
|
*/
|
|
const globalErrorHandlers: ((resp: ResponseMeta) => boolean)[] = [];
|
|
|
|
export const initApiClientErrorHandling = () =>
|
|
globalErrorHandlers.push((resp: ResponseMeta) => {
|
|
const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex =>
|
|
regex.test(window.location.pathname)
|
|
);
|
|
|
|
// Ignore error unless it is a 401
|
|
if (!resp || resp.status !== 401 || pageAllowsAnon) {
|
|
return false;
|
|
}
|
|
|
|
const code = resp?.responseJSON?.detail?.code;
|
|
const extra = resp?.responseJSON?.detail?.extra;
|
|
|
|
// 401s can also mean sudo is required or it's a request that is allowed to fail
|
|
// Ignore if these are the cases
|
|
if (
|
|
[
|
|
'sudo-required',
|
|
'ignore',
|
|
'2fa-required',
|
|
'app-connect-authentication-error',
|
|
].includes(code)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// If user must login via SSO, redirect to org login page
|
|
if (code === 'sso-required') {
|
|
window.location.assign(extra.loginUrl);
|
|
return true;
|
|
}
|
|
|
|
if (code === 'member-disabled-over-limit') {
|
|
browserHistory.replace(extra.next);
|
|
return true;
|
|
}
|
|
|
|
// Otherwise, the user has become unauthenticated. Send them to auth
|
|
Cookies.set('session_expired', '1');
|
|
|
|
if (EXPERIMENTAL_SPA) {
|
|
browserHistory.replace('/auth/login/');
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
return true;
|
|
});
|
|
|
|
/**
|
|
* Construct a full request URL
|
|
*/
|
|
function buildRequestUrl(baseUrl: string, path: string, options: RequestOptions) {
|
|
let params: string;
|
|
try {
|
|
params = qs.stringify(options.query ?? []);
|
|
} catch (err) {
|
|
Sentry.withScope(scope => {
|
|
scope.setExtra('path', path);
|
|
scope.setExtra('query', options.query);
|
|
Sentry.captureException(err);
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
// Append the baseUrl if required
|
|
let fullUrl = path.includes(baseUrl) ? path : baseUrl + path;
|
|
|
|
// Apply path and domain transforms for hybrid-cloud
|
|
fullUrl = resolveHostname(fullUrl, options.host);
|
|
|
|
// Append query parameters
|
|
if (params) {
|
|
fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`;
|
|
}
|
|
|
|
return fullUrl;
|
|
}
|
|
|
|
/**
|
|
* Check if the API response says project has been renamed. If so, redirect
|
|
* user to new project slug
|
|
*/
|
|
// TODO(ts): refine this type later
|
|
export function hasProjectBeenRenamed(response: ResponseMeta) {
|
|
const code = response?.responseJSON?.detail?.code;
|
|
|
|
// XXX(billy): This actually will never happen because we can't intercept the 302
|
|
// jQuery ajax will follow the redirect by default...
|
|
//
|
|
// TODO(epurkhiser): We use fetch now, is the above comment still true?
|
|
if (code !== PROJECT_MOVED) {
|
|
return false;
|
|
}
|
|
|
|
const slug = response?.responseJSON?.detail?.extra?.slug;
|
|
|
|
redirectToProject(slug);
|
|
return true;
|
|
}
|
|
|
|
// TODO(ts): move this somewhere
|
|
export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
|
|
|
|
type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
|
|
|
|
export type RequestCallbacks = {
|
|
/**
|
|
* Callback for the request completing (success or error)
|
|
*/
|
|
complete?: (resp: ResponseMeta, textStatus: string) => void;
|
|
/**
|
|
* Callback for the request failing with an error
|
|
*/
|
|
// TODO(ts): Update this when sentry is mostly migrated to TS
|
|
error?: FunctionCallback;
|
|
/**
|
|
* Callback for the request completing successfully
|
|
*/
|
|
success?: (data: any, textStatus?: string, resp?: ResponseMeta) => void;
|
|
};
|
|
|
|
export type RequestOptions = RequestCallbacks & {
|
|
/**
|
|
* Values to attach to the body of the request.
|
|
*/
|
|
data?: any;
|
|
/**
|
|
* Headers add to the request.
|
|
*/
|
|
headers?: Record<string, string>;
|
|
/**
|
|
* The host the request should be made to.
|
|
*/
|
|
host?: string;
|
|
/**
|
|
* The HTTP method to use when making the API request
|
|
*/
|
|
method?: APIRequestMethod;
|
|
/**
|
|
* Because of the async nature of API requests, errors will happen outside of
|
|
* the stack that initated the request. a preservedError can be passed to
|
|
* coalesce the stacks together.
|
|
*/
|
|
preservedError?: Error;
|
|
/**
|
|
* Query parameters to add to the requested URL.
|
|
*/
|
|
query?: Record<string, any>;
|
|
/**
|
|
* By default, requests will be aborted anytime api.clear() is called,
|
|
* which is commonly used on unmounts. When skipAbort is true, the
|
|
* request is opted out of this behavior. Useful for when you still
|
|
* want to cache a request on unmount.
|
|
*/
|
|
skipAbort?: boolean;
|
|
};
|
|
|
|
type ClientOptions = {
|
|
/**
|
|
* The base URL path to prepend to API request URIs.
|
|
*/
|
|
baseUrl?: string;
|
|
/**
|
|
* Credentials policy to apply to each request
|
|
*/
|
|
credentials?: RequestCredentials;
|
|
/**
|
|
* Base set of headers to apply to each request
|
|
*/
|
|
headers?: HeadersInit;
|
|
};
|
|
|
|
type HandleRequestErrorOptions = {
|
|
id: string;
|
|
path: string;
|
|
requestOptions: Readonly<RequestOptions>;
|
|
};
|
|
|
|
/**
|
|
* The API client is used to make HTTP requests to Sentry's backend.
|
|
*
|
|
* This is they preferred way to talk to the backend.
|
|
*/
|
|
export class Client {
|
|
baseUrl: string;
|
|
activeRequests: Record<string, Request>;
|
|
headers: HeadersInit;
|
|
credentials?: RequestCredentials;
|
|
|
|
static JSON_HEADERS = {
|
|
Accept: 'application/json; charset=utf-8',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
constructor(options: ClientOptions = {}) {
|
|
this.baseUrl = options.baseUrl ?? '/api/0';
|
|
this.headers = options.headers ?? Client.JSON_HEADERS;
|
|
this.activeRequests = {};
|
|
this.credentials = options.credentials ?? 'include';
|
|
}
|
|
|
|
wrapCallback<T extends any[]>(
|
|
id: string,
|
|
func: FunctionCallback<T> | undefined,
|
|
cleanup: boolean = false
|
|
) {
|
|
return (...args: T) => {
|
|
const req = this.activeRequests[id];
|
|
|
|
if (cleanup === true) {
|
|
delete this.activeRequests[id];
|
|
}
|
|
|
|
if (!req?.alive) {
|
|
return undefined;
|
|
}
|
|
|
|
// Check if API response is a 302 -- means project slug was renamed and user
|
|
// needs to be redirected
|
|
// @ts-expect-error
|
|
if (hasProjectBeenRenamed(...args)) {
|
|
return undefined;
|
|
}
|
|
|
|
// Call success callback
|
|
return func?.apply(req, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempt to cancel all active fetch requests
|
|
*/
|
|
clear() {
|
|
Object.values(this.activeRequests).forEach(r => r.cancel());
|
|
}
|
|
|
|
handleRequestError(
|
|
{id, path, requestOptions}: HandleRequestErrorOptions,
|
|
response: ResponseMeta,
|
|
textStatus: string,
|
|
errorThrown: string
|
|
) {
|
|
const code = response?.responseJSON?.detail?.code;
|
|
const isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED;
|
|
|
|
let didSuccessfullyRetry = false;
|
|
|
|
if (isSudoRequired) {
|
|
openSudo({
|
|
isSuperuser: code === SUPERUSER_REQUIRED,
|
|
sudo: code === SUDO_REQUIRED,
|
|
retryRequest: async () => {
|
|
try {
|
|
const data = await this.requestPromise(path, requestOptions);
|
|
requestOptions.success?.(data);
|
|
didSuccessfullyRetry = true;
|
|
} catch (err) {
|
|
requestOptions.error?.(err);
|
|
}
|
|
},
|
|
onClose: () =>
|
|
// If modal was closed, then forward the original response
|
|
!didSuccessfullyRetry && requestOptions.error?.(response),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Call normal error callback
|
|
const errorCb = this.wrapCallback<[ResponseMeta, string, string]>(
|
|
id,
|
|
requestOptions.error
|
|
);
|
|
errorCb?.(response, textStatus, errorThrown);
|
|
}
|
|
|
|
/**
|
|
* Initiate a request to the backend API.
|
|
*
|
|
* Consider using `requestPromise` for the async Promise version of this method.
|
|
*/
|
|
request(path: string, options: Readonly<RequestOptions> = {}): Request {
|
|
const method = options.method || (options.data ? 'POST' : 'GET');
|
|
|
|
let fullUrl = buildRequestUrl(this.baseUrl, path, options);
|
|
|
|
let data = options.data;
|
|
|
|
if (data !== undefined && method !== 'GET' && !(data instanceof FormData)) {
|
|
data = JSON.stringify(data);
|
|
}
|
|
|
|
// TODO(epurkhiser): Mimicking the old jQuery API, data could be a string /
|
|
// object for GET requests. jQuery just sticks it onto the URL as query
|
|
// parameters
|
|
if (method === 'GET' && data) {
|
|
const queryString = typeof data === 'string' ? data : qs.stringify(data);
|
|
|
|
if (queryString.length > 0) {
|
|
fullUrl = fullUrl + (fullUrl.includes('?') ? '&' : '?') + queryString;
|
|
}
|
|
}
|
|
|
|
const id = uniqueId();
|
|
const startMarker = `api-request-start-${id}`;
|
|
|
|
metric.mark({name: startMarker});
|
|
|
|
/**
|
|
* Called when the request completes with a 2xx status
|
|
*/
|
|
const successHandler = (
|
|
resp: ResponseMeta,
|
|
textStatus: string,
|
|
responseData: any
|
|
) => {
|
|
metric.measure({
|
|
name: 'app.api.request-success',
|
|
start: startMarker,
|
|
data: {status: resp?.status},
|
|
});
|
|
if (options.success !== undefined) {
|
|
this.wrapCallback<[any, string, ResponseMeta]>(id, options.success)(
|
|
responseData,
|
|
textStatus,
|
|
resp
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Called when the request is non-2xx
|
|
*/
|
|
const errorHandler = (
|
|
resp: ResponseMeta,
|
|
textStatus: string,
|
|
errorThrown: string
|
|
) => {
|
|
metric.measure({
|
|
name: 'app.api.request-error',
|
|
start: startMarker,
|
|
data: {status: resp?.status},
|
|
});
|
|
|
|
this.handleRequestError(
|
|
{id, path, requestOptions: options},
|
|
resp,
|
|
textStatus,
|
|
errorThrown
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Called when the request completes
|
|
*/
|
|
const completeHandler = (resp: ResponseMeta, textStatus: string) =>
|
|
this.wrapCallback<[ResponseMeta, string]>(
|
|
id,
|
|
options.complete,
|
|
true
|
|
)(resp, textStatus);
|
|
|
|
// AbortController is optional, though most browser should support it.
|
|
const aborter =
|
|
typeof AbortController !== 'undefined' && !options.skipAbort
|
|
? new AbortController()
|
|
: undefined;
|
|
|
|
// GET requests may not have a body
|
|
const body = method !== 'GET' ? data : undefined;
|
|
|
|
const requestHeaders = new Headers({...this.headers, ...options.headers});
|
|
|
|
// Do not set the X-CSRFToken header when making a request outside of the
|
|
// current domain. Because we use subdomains we loosely compare origins
|
|
if (!csrfSafeMethod(method) && isSimilarOrigin(fullUrl, window.location.origin)) {
|
|
requestHeaders.set('X-CSRFToken', getCsrfToken());
|
|
}
|
|
|
|
const fetchRequest = fetch(fullUrl, {
|
|
method,
|
|
body,
|
|
headers: requestHeaders,
|
|
credentials: this.credentials,
|
|
signal: aborter?.signal,
|
|
});
|
|
|
|
// XXX(epurkhiser): We migrated off of jquery, so for now we have a
|
|
// compatibility layer which mimics that of the jquery response objects.
|
|
fetchRequest
|
|
.then(
|
|
async response => {
|
|
// The Response's body can only be resolved/used at most once.
|
|
// So we clone the response so we can resolve the body content as text content.
|
|
// Response objects need to be cloned before its body can be used.
|
|
let responseJSON: any;
|
|
let responseText: any;
|
|
|
|
const {status, statusText} = response;
|
|
let {ok} = response;
|
|
let errorReason = 'Request not OK'; // the default error reason
|
|
let twoHundredErrorReason;
|
|
|
|
// Try to get text out of the response no matter the status
|
|
try {
|
|
responseText = await response.text();
|
|
} catch (error) {
|
|
twoHundredErrorReason = 'Failed awaiting response.text()';
|
|
ok = false;
|
|
if (error.name === 'AbortError') {
|
|
errorReason = 'Request was aborted';
|
|
} else {
|
|
errorReason = error.toString();
|
|
}
|
|
}
|
|
|
|
const responseContentType = response.headers.get('content-type');
|
|
const isResponseJSON = responseContentType?.includes('json');
|
|
const wasExpectingJson =
|
|
requestHeaders.get('Accept') === Client.JSON_HEADERS.Accept;
|
|
|
|
const isStatus3XX = status >= 300 && status < 400;
|
|
if (status !== 204 && !isStatus3XX) {
|
|
try {
|
|
responseJSON = JSON.parse(responseText);
|
|
} catch (error) {
|
|
twoHundredErrorReason = 'Failed trying to parse responseText';
|
|
if (error.name === 'AbortError') {
|
|
ok = false;
|
|
errorReason = 'Request was aborted';
|
|
} else if (isResponseJSON && error instanceof SyntaxError) {
|
|
// If the MIME type is `application/json` but decoding failed,
|
|
// this should be an error.
|
|
ok = false;
|
|
errorReason = 'JSON parse error';
|
|
} else if (
|
|
// Empty responses from POST 201 requests are valid
|
|
responseText?.length > 0 &&
|
|
wasExpectingJson &&
|
|
error instanceof SyntaxError
|
|
) {
|
|
// Was expecting json but was returned something else. Possibly HTML.
|
|
// Ideally this would not be a 200, but we should reject the promise
|
|
ok = false;
|
|
errorReason = 'JSON parse error. Possibly returned HTML';
|
|
}
|
|
}
|
|
}
|
|
|
|
const responseMeta: ResponseMeta = {
|
|
status,
|
|
statusText,
|
|
responseJSON,
|
|
responseText,
|
|
getResponseHeader: (header: string) => response.headers.get(header),
|
|
};
|
|
|
|
// Respect the response content-type header
|
|
const responseData = isResponseJSON ? responseJSON : responseText;
|
|
|
|
if (ok) {
|
|
successHandler(responseMeta, statusText, responseData);
|
|
} else {
|
|
// There's no reason we should be here with a 200 response, but we get
|
|
// tons of events from this codepath with a 200 status nonetheless.
|
|
// Until we know why, let's do what is essentially some very fancy print debugging.
|
|
if (status === 200 && responseText) {
|
|
const parameterizedPath = sanitizePath(path);
|
|
const message = '200 treated as error';
|
|
|
|
const scope = new Sentry.Scope();
|
|
scope.setTags({endpoint: `${method} ${parameterizedPath}`, errorReason});
|
|
scope.setExtras({
|
|
twoHundredErrorReason,
|
|
responseJSON,
|
|
responseText,
|
|
responseContentType,
|
|
errorReason,
|
|
});
|
|
// Make sure all of these errors group, so we don't produce a bunch of noise
|
|
scope.setFingerprint([message]);
|
|
|
|
Sentry.captureException(
|
|
new Error(`${message}: ${method} ${parameterizedPath}`),
|
|
scope
|
|
);
|
|
}
|
|
|
|
const shouldSkipErrorHandler =
|
|
globalErrorHandlers.map(handler => handler(responseMeta)).filter(Boolean)
|
|
.length > 0;
|
|
|
|
if (!shouldSkipErrorHandler) {
|
|
errorHandler(responseMeta, statusText, errorReason);
|
|
}
|
|
}
|
|
|
|
completeHandler(responseMeta, statusText);
|
|
},
|
|
() => {
|
|
// Ignore failed fetch calls or errors in the fetch request itself (e.g. cancelled requests)
|
|
// Not related to errors in responses
|
|
}
|
|
)
|
|
.catch(error => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(error);
|
|
Sentry.captureException(error);
|
|
});
|
|
|
|
const request = new Request(fetchRequest, aborter);
|
|
this.activeRequests[id] = request;
|
|
|
|
return request;
|
|
}
|
|
|
|
requestPromise<IncludeAllArgsType extends boolean>(
|
|
path: string,
|
|
{
|
|
includeAllArgs,
|
|
...options
|
|
}: {includeAllArgs?: IncludeAllArgsType} & Readonly<RequestOptions> = {}
|
|
): Promise<IncludeAllArgsType extends true ? ApiResult : any> {
|
|
// Create an error object here before we make any async calls so that we
|
|
// have a helpful stack trace if it errors
|
|
//
|
|
// This *should* get logged to Sentry only if the promise rejection is not handled
|
|
// (since SDK captures unhandled rejections). Ideally we explicitly ignore rejection
|
|
// or handle with a user friendly error message
|
|
const preservedError = new Error('API Request Error');
|
|
|
|
return new Promise((resolve, reject) =>
|
|
this.request(path, {
|
|
...options,
|
|
preservedError,
|
|
success: (data, textStatus, resp) => {
|
|
if (includeAllArgs) {
|
|
resolve([data, textStatus, resp] as any);
|
|
} else {
|
|
resolve(data);
|
|
}
|
|
},
|
|
error: (resp: ResponseMeta) => {
|
|
const errorObjectToUse = new RequestError(
|
|
options.method,
|
|
path,
|
|
preservedError,
|
|
resp
|
|
);
|
|
|
|
// Although `this.request` logs all error responses, this error object can
|
|
// potentially be logged by Sentry's unhandled rejection handler
|
|
reject(errorObjectToUse);
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
export function resolveHostname(path: string, hostname?: string): string {
|
|
const storeState = OrganizationStore.get();
|
|
const configLinks = ConfigStore.get('links');
|
|
|
|
hostname = hostname ?? '';
|
|
if (!hostname && storeState.organization?.features.includes('frontend-domainsplit')) {
|
|
// /_admin/ is special: since it doesn't update OrganizationStore, it's
|
|
// commonly the case that requests will be made for data which does not
|
|
// exist in the same region as the one in configLinks.regionUrl. Because of
|
|
// this we want to explicitly default those requests to be proxied through
|
|
// the control silo which can handle region resolution in exchange for a
|
|
// bit of latency.
|
|
const isAdmin = window.location.pathname.startsWith('/_admin/');
|
|
const isControlSilo = detectControlSiloPath(path);
|
|
if (!isAdmin && !isControlSilo && configLinks.regionUrl) {
|
|
hostname = configLinks.regionUrl;
|
|
}
|
|
if (isControlSilo && configLinks.sentryUrl) {
|
|
hostname = configLinks.sentryUrl;
|
|
}
|
|
}
|
|
|
|
// If we're making a request to the applications' root
|
|
// domain, we can drop the domain as webpack devserver will add one.
|
|
// TODO(hybridcloud) This can likely be removed when sentry.types.region.Region.to_url()
|
|
// loses the monolith mode condition.
|
|
if (window.__SENTRY_DEV_UI && hostname === configLinks.sentryUrl) {
|
|
hostname = '';
|
|
}
|
|
|
|
// When running as yarn dev-ui we can't spread requests across domains because
|
|
// of CORS. Instead we extract the subdomain from the hostname
|
|
// and prepend the URL with `/region/$name` so that webpack-devserver proxy
|
|
// can route requests to the regions.
|
|
if (hostname && window.__SENTRY_DEV_UI) {
|
|
const domainpattern = /https?\:\/\/([^.]*)\.sentry\.io/;
|
|
const domainmatch = hostname.match(domainpattern);
|
|
if (domainmatch) {
|
|
hostname = '';
|
|
path = `/region/${domainmatch[1]}${path}`;
|
|
}
|
|
}
|
|
if (hostname) {
|
|
path = `${hostname}${path}`;
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
function detectControlSiloPath(path: string): boolean {
|
|
// We sometimes include querystrings in paths.
|
|
// Using URL() to avoid handrolling URL parsing
|
|
const url = new URL(path, 'https://sentry.io');
|
|
path = url.pathname;
|
|
path = path.startsWith('/') ? path.substring(1) : path;
|
|
for (const pattern of controlsilopatterns) {
|
|
if (pattern.test(path)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|