sentry-javascript解析(一)fetch如何捕獲

前言

sentry對於前端工程師來講並不陌生,本文主要經過源碼講解sentry是如何實現捕獲各類錯誤。javascript

前置準備

咱們首先看一下兩個關鍵的工具方法前端

addInstrumentationHandler 二次封裝原生方法

咱們首先來看@sentry/uitlsinstrument.ts文件的addInstrumentationHandler方法:java

function addInstrumentationHandler(handler: InstrumentHandler): void {
  if (
    !handler || 
    typeof handler.type !== 'string' || 
    typeof handler.callback !== 'function'  
  ) {
    return;
  }
  // 初始化對應type的回調
  handlers[handler.type] = handlers[handler.type] || [];
  // 添加回調隊列
  (handlers[handler.type] as InstrumentHandlerCallback[]).push(handler.callback);
  instrument(handler.type);
}
// 全局閉包
const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};

function instrument(type: InstrumentHandlerType): void {
  if (instrumented[type]) {
    return;
  }
  // 全局閉包防止重複封裝
  instrumented[type] = true;

  switch (type) {
    case 'console':
      instrumentConsole();
      break;
    case 'dom':
      instrumentDOM();
      break;
    case 'xhr':
      instrumentXHR();
      break;
    case 'fetch':
      instrumentFetch();
      break;
    case 'history':
      instrumentHistory();
      break;
    case 'error':
      instrumentError();
      break;
    case 'unhandledrejection':
      instrumentUnhandledRejection();
      break;
    default:
      logger.warn('unknown instrumentation type:', type);
  }
}
複製代碼

addInstrumentationHandler收集相關的回調並調用對應方法對原生方法作二次封裝。git

fill 用高階函數包裝給定的對象方法

function fill( source: { [key: string]: any }, // 目標對象 name: string, // 覆蓋字段名 replacementFactory: (...args: any[]) => any // 封裝的高階函數 ): void {
  // 不存在的字段不作封裝
  if (!(name in source)) {
    return;
  }
  // 原生方法
  const original = source[name] as () => any;
  // 高階函數
  const wrapped = replacementFactory(original) as WrappedFunction;

  if (typeof wrapped === 'function') {
    try {
      // 爲高階函數指定一個空對象原型
      wrapped.prototype = wrapped.prototype || {};
      Object.defineProperties(wrapped, {
        __sentry_original__: {
          enumerable: false,
          value: original,
        },
      });
    } catch (_Oo) {
    }
  }
  // 覆蓋原生方法
  source[name] = wrapped;
}
複製代碼

fetch錯誤捕獲

指定url捕獲

sentry初始化的時候,咱們能夠經過tracingOrigins捕獲哪些urlsentry經過做用域閉包緩存全部應該捕獲的url,省去重複的遍歷。github

// 做用域閉包
const urlMap: Record<string, boolean> = {};
// 用於判斷當前url是否應該被捕獲
const defaultShouldCreateSpan = (url: string): boolean => {
  if (urlMap[url]) {
    return urlMap[url];
  }
  const origins = tracingOrigins;
  // 緩存url省去重複遍歷
  urlMap[url] =
    origins.some((origin: string | RegExp) => isMatchingPattern(url, origin)) &&
    !isMatchingPattern(url, 'sentry_key');
  return urlMap[url];
};
複製代碼

添加捕獲回調

接下來,咱們在@sentry/browser中看到:typescript

if (traceFetch) {
    addInstrumentationHandler({
        callback: (handlerData: FetchData) => {
            fetchCallback(handlerData, shouldCreateSpan, spans);
        },
        type: 'fetch',
    });
}
複製代碼

高階函數封裝fetch

按照上面的代碼咱們能夠準確看出經過type: 'fetch'接下來應該執行instrumentFetch方法,咱們來看一下這個方法的代碼:緩存

function instrumentFetch(): void {
  if (!supportsNativeFetch()) {
    return;
  }

  fill(global, 'fetch', function(originalFetch) {
    // 封裝後的fetch方法
    return function(...args: any[]): void {
      const handlerData = {
        args,
        fetchData: {
          method: getFetchMethod(args),
          url: getFetchUrl(args),
        },
        startTimestamp: Date.now(),
      };
      // 依次執行fetch type的回調方法 
      triggerHandlers('fetch', {
        ...handlerData,
      });
      // 經過apply從新指向this
      return originalFetch.apply(global, args).then(
      	// 請求成功
        (response: Response) => {
          triggerHandlers('fetch', {
            ...handlerData,
            endTimestamp: Date.now(),
            response,
          });
          return response;
        },
        // 請求失敗
        (error: Error) => {
          triggerHandlers('fetch', {
            ...handlerData,
            endTimestamp: Date.now(),
            error,
          });
          throw error;
        },
      );
    };
  });
}
複製代碼

咱們能夠經過上面的代碼發現sentry封裝了fetch方法,在請求結束以後,優先遍歷了在addInstrumentationHandler中緩存的回調,而後再將結果繼續透傳給後續的用戶回調。markdown

捕獲回調函數內都作了什麼

接下來咱們再看一下fetch回調中都作了哪些事情前端工程師

export function fetchCallback( handlerData: FetchData, // 整合的數據內容過 shouldCreateSpan: (url: string) => boolean, // 用於判斷當前url是否須要捕獲 spans: Record<string, Span>, ): void {
  // 獲取用戶配置
  const currentClientOptions = getCurrentHub().getClient()?.getOptions();
  
  if (
    !(currentClientOptions && hasTracingEnabled(currentClientOptions)) ||
    !(handlerData.fetchData && shouldCreateSpan(handlerData.fetchData.url))
  ) {
    return;
  }
  // 請求結束,只處理包含事務id的請求
  if (handlerData.endTimestamp && handlerData.fetchData.__span) {
    const span = spans[handlerData.fetchData.__span];
    if (span) {
      const response = handlerData.response;
      if (response) {
        span.setHttpStatus(response.status);
      }
      span.finish();

      delete spans[handlerData.fetchData.__span];
    }
    return;
  }
  // 開始請求,建立一個事務
  const activeTransaction = getActiveTransaction();
  if (activeTransaction) {
    const span = activeTransaction.startChild({
      data: {
        ...handlerData.fetchData,
        type: 'fetch',
      },
      description: `${handlerData.fetchData.method} ${handlerData.fetchData.url}`,
      op: 'http',
    });
    // 添加惟一id
    handlerData.fetchData.__span = span.spanId;
    // 記錄惟一id
    spans[span.spanId] = span;
    // 根據fetch的用法第一個參數能夠是 請求地址 或者是 Request對象
    const request = (handlerData.args[0] = handlerData.args[0] as string | Request);
    // 根據fetch的用法第二個參數是請求的相關配置項
    const options = (handlerData.args[1] = (handlerData.args[1] as { [key: string]: any }) || {});
    // 默認取配置項的headers(可能爲undefined)
    let headers = options.headers;
    if (isInstanceOf(request, Request)) {
      // 若是request是Request對象,則headers使用Request的
      headers = (request as Request).headers;
    }
    if (headers) {
      // 用戶已經設置了headers,則在請求頭添加sentry-trace字段
      if (typeof headers.append === 'function') {
        headers.append('sentry-trace', span.toTraceparent());
      } else if (Array.isArray(headers)) {
        headers = [...headers, ['sentry-trace', span.toTraceparent()]];
      } else {
        headers = { ...headers, 'sentry-trace': span.toTraceparent() };
      }
    } else {
      // 用戶未設置headers
      headers = { 'sentry-trace': span.toTraceparent() };
    }
    // 這裏借用了options聲明時會初始化handlerData.args[1],使用引用類型覆蓋了fetch的請求頭
    options.headers = headers;
  }
}
複製代碼

總結

到此咱們就能夠知道sentry是如何在fetch中捕獲信息的,咱們按照步驟總結一下:閉包

  • 由用戶配置traceFetch確認開啓fetch捕獲,配置tracingOrigins確認要捕獲的url
  • 經過shouldCreateSpanForRequest添加對fetch的聲明週期的回調
    • 內部調用instrumentFetch對全局的fetch作二次封裝
  • 用戶經過fetch發送請求
    • 整合上報信息
    • 遍歷上一步添加的回調函數
      • 建立惟一事務用於上報信息
      • fetch請求頭中添加sentry-trace字段
    • 調用原生方法發送請求
    • 請求響應後,根據返回的狀態再次遍歷上一步添加的回調函數
      • 請求成功時,記錄響應狀態
      • 上報本次請求
  • 結束本次捕獲
相關文章
相關標籤/搜索