api.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. import * as Sentry from '@sentry/react';
  2. import Cookies from 'js-cookie';
  3. import * as qs from 'query-string';
  4. import {redirectToProject} from 'sentry/actionCreators/redirectToProject';
  5. import {openSudo} from 'sentry/actionCreators/sudoModal';
  6. import {EXPERIMENTAL_SPA} from 'sentry/constants';
  7. import {
  8. PROJECT_MOVED,
  9. SUDO_REQUIRED,
  10. SUPERUSER_REQUIRED,
  11. } from 'sentry/constants/apiErrorCodes';
  12. import controlsilopatterns from 'sentry/data/controlsiloUrlPatterns';
  13. import {metric} from 'sentry/utils/analytics';
  14. import {browserHistory} from 'sentry/utils/browserHistory';
  15. import getCsrfToken from 'sentry/utils/getCsrfToken';
  16. import {uniqueId} from 'sentry/utils/guid';
  17. import RequestError from 'sentry/utils/requestError/requestError';
  18. import {sanitizePath} from 'sentry/utils/requestError/sanitizePath';
  19. import ConfigStore from './stores/configStore';
  20. import OrganizationStore from './stores/organizationStore';
  21. export class Request {
  22. /**
  23. * Is the request still in flight
  24. */
  25. alive: boolean;
  26. /**
  27. * Promise which will be resolved when the request has completed
  28. */
  29. requestPromise: Promise<Response>;
  30. /**
  31. * AbortController to cancel the in-flight request. This will not be set in
  32. * unsupported browsers.
  33. */
  34. aborter?: AbortController;
  35. constructor(requestPromise: Promise<Response>, aborter?: AbortController) {
  36. this.requestPromise = requestPromise;
  37. this.aborter = aborter;
  38. this.alive = true;
  39. }
  40. cancel() {
  41. this.alive = false;
  42. this.aborter?.abort();
  43. metric('app.api.request-abort', 1);
  44. }
  45. }
  46. export type ApiResult<Data = any> = [
  47. data: Data,
  48. statusText: string | undefined,
  49. resp: ResponseMeta | undefined,
  50. ];
  51. export type ResponseMeta<R = any> = {
  52. /**
  53. * Get a header value from the response
  54. */
  55. getResponseHeader: (header: string) => string | null;
  56. /**
  57. * The response body decoded from json
  58. */
  59. responseJSON: R;
  60. /**
  61. * The string value of the response
  62. */
  63. responseText: string;
  64. /**
  65. * The response status code
  66. */
  67. status: Response['status'];
  68. /**
  69. * The response status code text
  70. */
  71. statusText: Response['statusText'];
  72. };
  73. /**
  74. * Check if the requested method does not require CSRF tokens
  75. */
  76. function csrfSafeMethod(method?: string): boolean {
  77. // these HTTP methods do not require CSRF protection
  78. return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method ?? '');
  79. }
  80. /**
  81. * Check if we a request is going to the same or similar origin.
  82. * similar origins are those that share an ancestor. Example `sentry.sentry.io` and `us.sentry.io`
  83. * are similar origins, but sentry.sentry.io and sentry.example.io are not.
  84. */
  85. export function isSimilarOrigin(target: string, origin: string): boolean {
  86. const targetUrl = new URL(target, origin);
  87. const originUrl = new URL(origin);
  88. // If one of the domains is a child of the other.
  89. if (
  90. originUrl.hostname.endsWith(targetUrl.hostname) ||
  91. targetUrl.hostname.endsWith(originUrl.hostname)
  92. ) {
  93. return true;
  94. }
  95. // Check if the target and origin are on sibiling subdomains.
  96. const targetHost = targetUrl.hostname.split('.');
  97. const originHost = originUrl.hostname.split('.');
  98. // Remove the subdomains. If don't have at least 2 segments we aren't subdomains.
  99. targetHost.shift();
  100. originHost.shift();
  101. if (targetHost.length < 2 || originHost.length < 2) {
  102. return false;
  103. }
  104. return targetHost.join('.') === originHost.join('.');
  105. }
  106. // TODO: Need better way of identifying anonymous pages that don't trigger redirect
  107. const ALLOWED_ANON_PAGES = [
  108. /^\/accept\//,
  109. /^\/share\//,
  110. /^\/auth\/login\//,
  111. /^\/join-request\//,
  112. /^\/unsubscribe\//,
  113. ];
  114. /**
  115. * Return true if we should skip calling the normal error handler
  116. */
  117. const globalErrorHandlers: ((resp: ResponseMeta, options: RequestOptions) => boolean)[] =
  118. [];
  119. export const initApiClientErrorHandling = () =>
  120. globalErrorHandlers.push((resp: ResponseMeta, options: RequestOptions) => {
  121. const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex =>
  122. regex.test(window.location.pathname)
  123. );
  124. // Ignore error unless it is a 401
  125. if (!resp || resp.status !== 401 || pageAllowsAnon) {
  126. return false;
  127. }
  128. if (resp && options.allowAuthError && resp.status === 401) {
  129. return false;
  130. }
  131. const code = resp?.responseJSON?.detail?.code;
  132. const extra = resp?.responseJSON?.detail?.extra;
  133. // 401s can also mean sudo is required or it's a request that is allowed to fail
  134. // Ignore if these are the cases
  135. if (
  136. [
  137. 'sudo-required',
  138. 'ignore',
  139. '2fa-required',
  140. 'app-connect-authentication-error',
  141. ].includes(code)
  142. ) {
  143. return false;
  144. }
  145. // If user must login via SSO, redirect to org login page
  146. if (code === 'sso-required') {
  147. window.location.assign(extra.loginUrl);
  148. return true;
  149. }
  150. if (code === 'member-disabled-over-limit') {
  151. browserHistory.replace(extra.next);
  152. return true;
  153. }
  154. // Otherwise, the user has become unauthenticated. Send them to auth
  155. Cookies.set('session_expired', '1');
  156. if (EXPERIMENTAL_SPA) {
  157. browserHistory.replace('/auth/login/');
  158. } else {
  159. window.location.reload();
  160. }
  161. return true;
  162. });
  163. /**
  164. * Construct a full request URL
  165. */
  166. function buildRequestUrl(baseUrl: string, path: string, options: RequestOptions) {
  167. let params: string;
  168. try {
  169. params = qs.stringify(options.query ?? []);
  170. } catch (err) {
  171. Sentry.withScope(scope => {
  172. scope.setExtra('path', path);
  173. scope.setExtra('query', options.query);
  174. Sentry.captureException(err);
  175. });
  176. throw err;
  177. }
  178. // Append the baseUrl if required
  179. let fullUrl = path.includes(baseUrl) ? path : baseUrl + path;
  180. // Apply path and domain transforms for hybrid-cloud
  181. fullUrl = resolveHostname(fullUrl, options.host);
  182. // Append query parameters
  183. if (params) {
  184. fullUrl += fullUrl.includes('?') ? `&${params}` : `?${params}`;
  185. }
  186. return fullUrl;
  187. }
  188. /**
  189. * Check if the API response says project has been renamed. If so, redirect
  190. * user to new project slug
  191. */
  192. // TODO(ts): refine this type later
  193. export function hasProjectBeenRenamed(response: ResponseMeta) {
  194. const code = response?.responseJSON?.detail?.code;
  195. // XXX(billy): This actually will never happen because we can't intercept the 302
  196. // jQuery ajax will follow the redirect by default...
  197. //
  198. // TODO(epurkhiser): We use fetch now, is the above comment still true?
  199. if (code !== PROJECT_MOVED) {
  200. return false;
  201. }
  202. const slug = response?.responseJSON?.detail?.extra?.slug;
  203. redirectToProject(slug);
  204. return true;
  205. }
  206. // TODO(ts): move this somewhere
  207. export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
  208. type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
  209. export type RequestCallbacks = {
  210. /**
  211. * Callback for the request completing (success or error)
  212. */
  213. complete?: (resp: ResponseMeta, textStatus: string) => void;
  214. /**
  215. * Callback for the request failing with an error
  216. */
  217. // TODO(ts): Update this when sentry is mostly migrated to TS
  218. error?: FunctionCallback;
  219. /**
  220. * Callback for the request completing successfully
  221. */
  222. success?: (data: any, textStatus?: string, resp?: ResponseMeta) => void;
  223. };
  224. export type RequestOptions = RequestCallbacks & {
  225. /**
  226. * Set true, if an authentication required error is allowed for
  227. * a request.
  228. */
  229. allowAuthError?: boolean;
  230. /**
  231. * Values to attach to the body of the request.
  232. */
  233. data?: any;
  234. /**
  235. * Headers add to the request.
  236. */
  237. headers?: Record<string, string>;
  238. /**
  239. * The host the request should be made to.
  240. */
  241. host?: string;
  242. /**
  243. * The HTTP method to use when making the API request
  244. */
  245. method?: APIRequestMethod;
  246. /**
  247. * Because of the async nature of API requests, errors will happen outside of
  248. * the stack that initated the request. a preservedError can be passed to
  249. * coalesce the stacks together.
  250. */
  251. preservedError?: Error;
  252. /**
  253. * Query parameters to add to the requested URL.
  254. */
  255. query?: Record<string, any>;
  256. /**
  257. * By default, requests will be aborted anytime api.clear() is called,
  258. * which is commonly used on unmounts. When skipAbort is true, the
  259. * request is opted out of this behavior. Useful for when you still
  260. * want to cache a request on unmount.
  261. */
  262. skipAbort?: boolean;
  263. };
  264. type ClientOptions = {
  265. /**
  266. * The base URL path to prepend to API request URIs.
  267. */
  268. baseUrl?: string;
  269. /**
  270. * Credentials policy to apply to each request
  271. */
  272. credentials?: RequestCredentials;
  273. /**
  274. * Base set of headers to apply to each request
  275. */
  276. headers?: HeadersInit;
  277. };
  278. type HandleRequestErrorOptions = {
  279. id: string;
  280. path: string;
  281. requestOptions: Readonly<RequestOptions>;
  282. };
  283. /**
  284. * The API client is used to make HTTP requests to Sentry's backend.
  285. *
  286. * This is they preferred way to talk to the backend.
  287. */
  288. export class Client {
  289. baseUrl: string;
  290. activeRequests: Record<string, Request>;
  291. headers: HeadersInit;
  292. credentials?: RequestCredentials;
  293. static JSON_HEADERS = {
  294. Accept: 'application/json; charset=utf-8',
  295. 'Content-Type': 'application/json',
  296. };
  297. constructor(options: ClientOptions = {}) {
  298. this.baseUrl = options.baseUrl ?? '/api/0';
  299. this.headers = options.headers ?? Client.JSON_HEADERS;
  300. this.activeRequests = {};
  301. this.credentials = options.credentials ?? 'include';
  302. }
  303. wrapCallback<T extends any[]>(
  304. id: string,
  305. func: FunctionCallback<T> | undefined,
  306. cleanup: boolean = false
  307. ) {
  308. return (...args: T) => {
  309. const req = this.activeRequests[id];
  310. if (cleanup === true) {
  311. delete this.activeRequests[id];
  312. }
  313. if (!req?.alive) {
  314. return undefined;
  315. }
  316. // Check if API response is a 302 -- means project slug was renamed and user
  317. // needs to be redirected
  318. // @ts-expect-error
  319. if (hasProjectBeenRenamed(...args)) {
  320. return undefined;
  321. }
  322. // Call success callback
  323. return func?.apply(req, args);
  324. };
  325. }
  326. /**
  327. * Attempt to cancel all active fetch requests
  328. */
  329. clear() {
  330. Object.values(this.activeRequests).forEach(r => r.cancel());
  331. }
  332. handleRequestError(
  333. {id, path, requestOptions}: HandleRequestErrorOptions,
  334. response: ResponseMeta,
  335. textStatus: string,
  336. errorThrown: string
  337. ) {
  338. const code = response?.responseJSON?.detail?.code;
  339. const isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED;
  340. let didSuccessfullyRetry = false;
  341. if (isSudoRequired) {
  342. openSudo({
  343. isSuperuser: code === SUPERUSER_REQUIRED,
  344. sudo: code === SUDO_REQUIRED,
  345. retryRequest: async () => {
  346. try {
  347. const data = await this.requestPromise(path, requestOptions);
  348. requestOptions.success?.(data);
  349. didSuccessfullyRetry = true;
  350. } catch (err) {
  351. requestOptions.error?.(err);
  352. }
  353. },
  354. onClose: () =>
  355. // If modal was closed, then forward the original response
  356. !didSuccessfullyRetry && requestOptions.error?.(response),
  357. });
  358. return;
  359. }
  360. // Call normal error callback
  361. const errorCb = this.wrapCallback<[ResponseMeta, string, string]>(
  362. id,
  363. requestOptions.error
  364. );
  365. errorCb?.(response, textStatus, errorThrown);
  366. }
  367. /**
  368. * Initiate a request to the backend API.
  369. *
  370. * Consider using `requestPromise` for the async Promise version of this method.
  371. */
  372. request(path: string, options: Readonly<RequestOptions> = {}): Request {
  373. const method = options.method || (options.data ? 'POST' : 'GET');
  374. let fullUrl = buildRequestUrl(this.baseUrl, path, options);
  375. let data = options.data;
  376. if (data !== undefined && method !== 'GET' && !(data instanceof FormData)) {
  377. data = JSON.stringify(data);
  378. }
  379. // TODO(epurkhiser): Mimicking the old jQuery API, data could be a string /
  380. // object for GET requests. jQuery just sticks it onto the URL as query
  381. // parameters
  382. if (method === 'GET' && data) {
  383. const queryString = typeof data === 'string' ? data : qs.stringify(data);
  384. if (queryString.length > 0) {
  385. fullUrl = fullUrl + (fullUrl.includes('?') ? '&' : '?') + queryString;
  386. }
  387. }
  388. const id = uniqueId();
  389. const startMarker = `api-request-start-${id}`;
  390. metric.mark({name: startMarker});
  391. /**
  392. * Called when the request completes with a 2xx status
  393. */
  394. const successHandler = (
  395. resp: ResponseMeta,
  396. textStatus: string,
  397. responseData: any
  398. ) => {
  399. metric.measure({
  400. name: 'app.api.request-success',
  401. start: startMarker,
  402. data: {status: resp?.status},
  403. });
  404. if (options.success !== undefined) {
  405. this.wrapCallback<[any, string, ResponseMeta]>(id, options.success)(
  406. responseData,
  407. textStatus,
  408. resp
  409. );
  410. }
  411. };
  412. /**
  413. * Called when the request is non-2xx
  414. */
  415. const errorHandler = (
  416. resp: ResponseMeta,
  417. textStatus: string,
  418. errorThrown: string
  419. ) => {
  420. metric.measure({
  421. name: 'app.api.request-error',
  422. start: startMarker,
  423. data: {status: resp?.status},
  424. });
  425. this.handleRequestError(
  426. {id, path, requestOptions: options},
  427. resp,
  428. textStatus,
  429. errorThrown
  430. );
  431. };
  432. /**
  433. * Called when the request completes
  434. */
  435. const completeHandler = (resp: ResponseMeta, textStatus: string) =>
  436. this.wrapCallback<[ResponseMeta, string]>(
  437. id,
  438. options.complete,
  439. true
  440. )(resp, textStatus);
  441. // AbortController is optional, though most browser should support it.
  442. const aborter =
  443. typeof AbortController !== 'undefined' && !options.skipAbort
  444. ? new AbortController()
  445. : undefined;
  446. // GET requests may not have a body
  447. const body = method !== 'GET' ? data : undefined;
  448. const requestHeaders = new Headers({...this.headers, ...options.headers});
  449. // Do not set the X-CSRFToken header when making a request outside of the
  450. // current domain. Because we use subdomains we loosely compare origins
  451. if (!csrfSafeMethod(method) && isSimilarOrigin(fullUrl, window.location.origin)) {
  452. requestHeaders.set('X-CSRFToken', getCsrfToken());
  453. }
  454. const fetchRequest = fetch(fullUrl, {
  455. method,
  456. body,
  457. headers: requestHeaders,
  458. credentials: this.credentials,
  459. signal: aborter?.signal,
  460. });
  461. // XXX(epurkhiser): We migrated off of jquery, so for now we have a
  462. // compatibility layer which mimics that of the jquery response objects.
  463. fetchRequest
  464. .then(
  465. async response => {
  466. // The Response's body can only be resolved/used at most once.
  467. // So we clone the response so we can resolve the body content as text content.
  468. // Response objects need to be cloned before its body can be used.
  469. let responseJSON: any;
  470. let responseText: any;
  471. const {status, statusText} = response;
  472. let {ok} = response;
  473. let errorReason = 'Request not OK'; // the default error reason
  474. let twoHundredErrorReason: string | undefined;
  475. // Try to get text out of the response no matter the status
  476. try {
  477. responseText = await response.text();
  478. } catch (error) {
  479. twoHundredErrorReason = 'Failed awaiting response.text()';
  480. ok = false;
  481. if (error.name === 'AbortError') {
  482. errorReason = 'Request was aborted';
  483. } else {
  484. errorReason = error.toString();
  485. }
  486. }
  487. const responseContentType = response.headers.get('content-type');
  488. const isResponseJSON = responseContentType?.includes('json');
  489. const wasExpectingJson =
  490. requestHeaders.get('Accept') === Client.JSON_HEADERS.Accept;
  491. const isStatus3XX = status >= 300 && status < 400;
  492. if (status !== 204 && !isStatus3XX) {
  493. try {
  494. responseJSON = JSON.parse(responseText);
  495. } catch (error) {
  496. twoHundredErrorReason = 'Failed trying to parse responseText';
  497. if (error.name === 'AbortError') {
  498. ok = false;
  499. errorReason = 'Request was aborted';
  500. } else if (isResponseJSON && error instanceof SyntaxError) {
  501. // If the MIME type is `application/json` but decoding failed,
  502. // this should be an error.
  503. ok = false;
  504. errorReason = 'JSON parse error';
  505. } else if (
  506. // Empty responses from POST 201 requests are valid
  507. responseText?.length > 0 &&
  508. wasExpectingJson &&
  509. error instanceof SyntaxError
  510. ) {
  511. // Was expecting json but was returned something else. Possibly HTML.
  512. // Ideally this would not be a 200, but we should reject the promise
  513. ok = false;
  514. errorReason = 'JSON parse error. Possibly returned HTML';
  515. }
  516. }
  517. }
  518. const responseMeta: ResponseMeta = {
  519. status,
  520. statusText,
  521. responseJSON,
  522. responseText,
  523. getResponseHeader: (header: string) => response.headers.get(header),
  524. };
  525. // Respect the response content-type header
  526. const responseData = isResponseJSON ? responseJSON : responseText;
  527. if (ok) {
  528. successHandler(responseMeta, statusText, responseData);
  529. } else {
  530. // There's no reason we should be here with a 200 response, but we get
  531. // tons of events from this codepath with a 200 status nonetheless.
  532. // Until we know why, let's do what is essentially some very fancy print debugging.
  533. if (status === 200 && responseText) {
  534. const parameterizedPath = sanitizePath(path);
  535. const message = '200 treated as error';
  536. Sentry.withScope(scope => {
  537. scope.setTags({endpoint: `${method} ${parameterizedPath}`, errorReason});
  538. scope.setExtras({
  539. twoHundredErrorReason,
  540. responseJSON,
  541. responseText,
  542. responseContentType,
  543. errorReason,
  544. });
  545. // Make sure all of these errors group, so we don't produce a bunch of noise
  546. scope.setFingerprint([message]);
  547. Sentry.captureException(
  548. new Error(`${message}: ${method} ${parameterizedPath}`)
  549. );
  550. });
  551. }
  552. const shouldSkipErrorHandler =
  553. globalErrorHandlers
  554. .map(handler => handler(responseMeta, options))
  555. .filter(Boolean).length > 0;
  556. if (!shouldSkipErrorHandler) {
  557. errorHandler(responseMeta, statusText, errorReason);
  558. }
  559. }
  560. completeHandler(responseMeta, statusText);
  561. },
  562. () => {
  563. // Ignore failed fetch calls or errors in the fetch request itself (e.g. cancelled requests)
  564. // Not related to errors in responses
  565. }
  566. )
  567. .catch(error => {
  568. // eslint-disable-next-line no-console
  569. console.error(error);
  570. Sentry.captureException(error);
  571. });
  572. const request = new Request(fetchRequest, aborter);
  573. this.activeRequests[id] = request;
  574. return request;
  575. }
  576. requestPromise<IncludeAllArgsType extends boolean>(
  577. path: string,
  578. {
  579. includeAllArgs,
  580. ...options
  581. }: {includeAllArgs?: IncludeAllArgsType} & Readonly<RequestOptions> = {}
  582. ): Promise<IncludeAllArgsType extends true ? ApiResult : any> {
  583. // Create an error object here before we make any async calls so that we
  584. // have a helpful stack trace if it errors
  585. //
  586. // This *should* get logged to Sentry only if the promise rejection is not handled
  587. // (since SDK captures unhandled rejections). Ideally we explicitly ignore rejection
  588. // or handle with a user friendly error message
  589. const preservedError = new Error('API Request Error');
  590. return new Promise((resolve, reject) =>
  591. this.request(path, {
  592. ...options,
  593. preservedError,
  594. success: (data, textStatus, resp) => {
  595. if (includeAllArgs) {
  596. resolve([data, textStatus, resp] as any);
  597. } else {
  598. resolve(data);
  599. }
  600. },
  601. error: (resp: ResponseMeta) => {
  602. const errorObjectToUse = new RequestError(
  603. options.method,
  604. path,
  605. preservedError,
  606. resp
  607. );
  608. // Although `this.request` logs all error responses, this error object can
  609. // potentially be logged by Sentry's unhandled rejection handler
  610. reject(errorObjectToUse);
  611. },
  612. })
  613. );
  614. }
  615. }
  616. export function resolveHostname(path: string, hostname?: string): string {
  617. const storeState = OrganizationStore.get();
  618. const configLinks = ConfigStore.get('links');
  619. hostname = hostname ?? '';
  620. if (!hostname && storeState.organization?.features.includes('frontend-domainsplit')) {
  621. // /_admin/ is special: since it doesn't update OrganizationStore, it's
  622. // commonly the case that requests will be made for data which does not
  623. // exist in the same region as the one in configLinks.regionUrl. Because of
  624. // this we want to explicitly default those requests to be proxied through
  625. // the control silo which can handle region resolution in exchange for a
  626. // bit of latency.
  627. const isAdmin = window.location.pathname.startsWith('/_admin/');
  628. const isControlSilo = detectControlSiloPath(path);
  629. if (!isAdmin && !isControlSilo && configLinks.regionUrl) {
  630. hostname = configLinks.regionUrl;
  631. }
  632. if (isControlSilo && configLinks.sentryUrl) {
  633. hostname = configLinks.sentryUrl;
  634. }
  635. }
  636. // If we're making a request to the applications' root
  637. // domain, we can drop the domain as webpack devserver will add one.
  638. // TODO(hybridcloud) This can likely be removed when sentry.types.region.Region.to_url()
  639. // loses the monolith mode condition.
  640. if (window.__SENTRY_DEV_UI && hostname === configLinks.sentryUrl) {
  641. hostname = '';
  642. }
  643. // When running as yarn dev-ui we can't spread requests across domains because
  644. // of CORS. Instead we extract the subdomain from the hostname
  645. // and prepend the URL with `/region/$name` so that webpack-devserver proxy
  646. // can route requests to the regions.
  647. if (hostname && window.__SENTRY_DEV_UI) {
  648. const domainpattern = /https?\:\/\/([^.]*)\.sentry\.io/;
  649. const domainmatch = hostname.match(domainpattern);
  650. if (domainmatch) {
  651. hostname = '';
  652. path = `/region/${domainmatch[1]}${path}`;
  653. }
  654. }
  655. if (hostname) {
  656. path = `${hostname}${path}`;
  657. }
  658. return path;
  659. }
  660. function detectControlSiloPath(path: string): boolean {
  661. // We sometimes include querystrings in paths.
  662. // Using URL() to avoid handrolling URL parsing
  663. const url = new URL(path, 'https://sentry.io');
  664. path = url.pathname;
  665. path = path.startsWith('/') ? path.substring(1) : path;
  666. for (const pattern of controlsilopatterns) {
  667. if (pattern.test(path)) {
  668. return true;
  669. }
  670. }
  671. return false;
  672. }