123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733 |
- import {browserHistory} from 'react-router';
- import * as Sentry from '@sentry/react';
- import Cookies from 'js-cookie';
- import * as qs from 'query-string';
- import {openSudo, redirectToProject} from 'sentry/actionCreators/modal';
- 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\//,
- ];
- /**
- * 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>;
- };
- 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);
- }
- /**
- * Initate 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 = JSON.stringify(data);
- }
- // TODO(epurkhiser): Mimicking the old jQuery API, data could be a string /
- // object for GET requets. 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' ? 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')) {
- const isControlSilo = detectControlSiloPath(path);
- if (!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;
- }
|