Sentry 監控 – 全棧開發人員的分散式跟蹤 101 系列教程(第一部分)

  • 2021 年 10 月 6 日
  • 筆記

系列

歡迎來到我們關於全棧開發人員分散式跟蹤(Distributed Tracing)的系列的第 1 部分。在本系列中,我們將學習分散式跟蹤的細節,以及它如何幫助您監控全棧應用程式日益複雜的需求。

Web 的早期,編寫 Web 應用程式很簡單。開發人員使用 PHP 等語言在伺服器上生成 HTML,與 MySQL 等單一關係資料庫進行通訊,大多數交互性由靜態 HTML 表單組件驅動。 雖然調試工具很原始,但理解程式碼的執行流程很簡單。

在今天的現代 web 棧中,它什麼都不是。全棧開發人員需要編寫在瀏覽器中執行的 JavaScript,與多種資料庫技術互操作,並在不同的伺服器架構(例如:serverless)上部署伺服器端程式碼。如果沒有合適的工具,了解瀏覽器中的用戶交互如何關聯到伺服器堆棧深處的 500 server error 幾乎是不可能的。Enter:分散式跟蹤。

我試圖解釋 2021 年我的 web 堆棧中的瓶頸。

分散式跟蹤(Distributed tracing)是一種監控技術,它將多個服務之間發生的操作和請求聯繫起來。 這允許開發人員在端到端請求從一個服務移動到另一個服務時「跟蹤(trace)」它的路徑,讓他們能夠查明對整個系統產生負面影響的單個服務中的錯誤或性能瓶頸。

在這篇文章中,我們將了解有關分散式跟蹤概念的更多資訊,在程式碼中查看端到端(end-to-end)跟蹤示例,並了解如何使用跟蹤元數據為您的日誌記錄和監控工具添加有價值的上下文。 完成後,您不僅會了解分散式跟蹤的基礎知識,還會了解如何應用跟蹤技術來更有效地調試全棧 Web 應用程式。

但首先,讓我們回到開頭:什麼是分散式追蹤?

分散式追蹤基礎

分散式跟蹤是一種記錄多個服務的連接操作的方法。 通常,這些操作是由從一個服務到另一個服務的請求發起的,其中「請求(request)」可以是實際的 HTTP 請求,也可以是通過任務隊列或其他一些非同步方式調用的工作。

跟蹤由兩個基本組件組成:

  • Span 描述發生在服務上的操作或 「work」Span 可以描述廣泛的操作——例如,響應 HTTP 請求的 web 伺服器的操作——也可以描述單個函數的調用。
  • trace 描述了一個或多個連接 span 的端到端(end-to-end)旅程。 如果 trace 連接在多個服務上執行的 span「work」),則該 trace 被認為是分散式跟蹤。

讓我們看一個假設的分散式跟蹤示例。

上圖說明了 trace 如何從一個服務(一個在瀏覽器上運行的 React 應用程式)開始,並通過調用 API Web Server 繼續,甚至進一步調用後台任務 worker。此圖中的 span 是在每個服務中執行的 work,每個 span 都可以「追溯到(traced)」由瀏覽器應用程式啟動的初始工作(initial work)。 最後,由於這些操作發生在不同的服務上,因此該跟蹤被認為是分散式的。

描述廣泛操作的跨度(例如:響應 HTTP requestWeb server 的完整生命周期)有時被稱為事務跨度(transaction spans),甚至只是事務。 我們將在本系列的第 2 部分中更多地討論事務與跨度(transactions vs. spans)。

跟蹤和跨度標識符

到目前為止,我們已經確定了跟蹤的組件,但我們還沒有描述這些組件是如何鏈接在一起的。

首先,每個跟蹤都用跟蹤標識符(trace identifier)唯一標識。 這是通過在根跨度(root span)中創建一個唯一的隨機生成值(即 UUID)來完成的——這是啟動整個跟蹤的初始操作。 在我們上面的示例中,根跨度出現在瀏覽器應用程式中。

其次,每個 span 首先需要被唯一標識。 這通過在跨度開始其操作時創建唯一的跨度標識符(或 span_id)來完成。這個 span_id 創建應該發生在 trace 內發生的每個 span(或操作)處進行。

讓我們重新審視我們假設的跟蹤示例。 在上圖中,您會注意到跟蹤標識符唯一地標識了跟蹤,並且該跟蹤中的每個跨度也擁有一個唯一的跨度標識符。

然而,生成 trace_idspan_id 是不夠的。 要實際連接這些服務,您的應用程式必須在從一個服務向另一個服務發出請求時傳播所謂的跟蹤上下文(trace context)。

跟蹤上下文

跟蹤上下文(trace context)通常僅由兩個值組成:

  • 跟蹤標識符(或 trace_id):在根跨度中生成的唯一標識符,用於標識整個跟蹤。 這與我們在上一節中介紹的跟蹤標識符相同;它以不變的方式傳播到每個下游服務。
  • 父標識符(或 parent_id):產生當前操作的「父」跨度的 span_id

下圖顯示了在一個服務中啟動的請求如何將跟蹤上下文傳播到下游的下一個服務。 您會注意到 trace_id 保持不變,而 parent_id 在請求之間發生變化,指向啟動最新操作的父跨度。

有了這兩個值,對於任何給定的操作,就可以確定原始(root)服務,並按照導致當前操作的順序重建所有父/祖先(parent/ancestor)服務。

工作示例(程式碼演示)

示例源碼:

為了更好地理解這一點,讓我們實際實現一個基本的跟蹤實現,其中瀏覽器應用程式是由跟蹤上下文連接的一系列分散式操作的發起者。

首先,瀏覽器應用程式呈現一個表單:就本示例而言,是一個「邀請用戶(invite user)」表單。表單有一個提交事件處理程式,它在表單提交時觸發。 讓我們將此提交處理程式視為我們的根跨度(root span),這意味著當調用處理程式時,會生成 trace_idspan_id

接下來,完成一些工作以從表單中收集用戶輸入的值,然後最後向我們的 Web 伺服器發出一個到 /inviteUser API 端點的 fetch 請求。作為此 fetch 請求的一部分,跟蹤上下文作為兩個自定義 HTTP header 傳遞:trace-idparent-id(即當前 spanspan_id)。

// browser app (JavaScript)
import uuid from 'uuid';

const traceId = uuid.v4();
const spanId = uuid.v4();

console.log('Initiate inviteUser POST request', `traceId: ${traceId}`);

fetch('/api/v1/inviteUser?email=' + encodeURIComponent(email), {
   method: 'POST',
   headers: {
       'trace-id': traceId,
       'parent-id': spanId,
   }
}).then((data) => {
   console.log('Success!');
}).catch((err) => {
   console.log('Something bad happened', `traceId: ${traceId}`);
});

請注意,這些是用於說明目的的非標準 HTTP header。 作為 W3C traceparent 規範的一部分,正在積極努力標準化 tracing HTTP header,該規範仍處於 「Recommendation」 階段。

在接收端,API web server 處理請求並從 HTTP 請求中提取跟蹤元數據(tracing metadata)。然後它會排隊一個 job 以向用戶發送電子郵件,並將跟蹤上下文作為 job 描述中「meta」欄位的一部分附加。最後,它返回一個帶有 200 狀態 code 的響應,表明該方法成功。

請注意,雖然伺服器返回了成功的響應,但實際的「工作」直到後台任務 worker 拿起新排隊的 job 並實際發送電子郵件後才完成。

在某個點上,隊列處理器開始處理排隊的電子郵件作業。再一次,跟蹤(trace)和父標識符(parent identifier)被提取出來,就像它們在 web server 中的早些時候一樣。

// API Web Server
const Queue = require('bull');
const emailQueue = new Queue('email');
const uuid = require('uuid');

app.post("/api/v1/inviteUser", (req, res) => {
  const spanId = uuid.v4(),
    traceId = req.headers["trace-id"],
    parentId = req.headers["parent-id"];

  console.log(
    "Adding job to email queue",
    `[traceId: ${traceId},`,
    `parentId: ${parentId},`,
    `spanId: ${spanId}]`
  );

  emailQueue.add({
    title: "Welcome to our product",
    to: req.params.email,
    meta: {
      traceId: traceId,

      // the downstream span's parent_id is this span's span_id
      parentId: spanId,
    },
  });

  res.status(200).send("ok");
});

// Background Task Worker
emailQueue.process((job, done) => {
  const spanId = uuid.v4();
  const { traceId, parentId } = job.data.meta;

  console.log(
    "Sending email",
    `[traceId: ${traceId},`,
    `parentId: ${parentId},`,
    `spanId: ${spanId}]`
  );

  // actually send the email
  // ...

  done();
});

分散式系統 Logging

您會注意到,在我們示例的每個階段,都會使用 console.log 進行 logging 調用,該調用還發出當前 tracespanparent 標識符。 在完美的同步世界中——每個服務都可以登錄到同一個集中式 logging 工具——這些日誌語句中的每一個都會依次出現:

如果在這些操作過程中發生異常或錯誤行為,使用這些或額外的日誌語句來查明來源將相對簡單。但不幸的現實是,這些都是分散式服務,這意味著:

  • Web 伺服器通常處理許多並發請求。Web 伺服器可能正在執行歸因於其他請求的工作(並發出日誌記錄語句)。
  • 網路延遲會影響操作順序。 從上游服務發出的請求可能不會按照它們被觸發的順序到達目的地。
  • 後台 worker 可能有排隊的 job。在到達此跟蹤中排隊的確切 job 之前,worker 可能必須先完成先前排隊的 job

在一個更現實的例子中,我們的日誌調用可能看起來像這樣,它反映了同時發生的多個操作:

如果不跟蹤 metadata,就不可能了解哪個動作調用哪個動作的拓撲結構。 但是通過在每次 logging 調用時發出跟蹤 meta 資訊,可以通過過濾 traceId 快速過濾跟蹤中的所有 logging 調用,並通過檢查 spanIdparentId 關係重建確切的順序。

這就是分散式跟蹤的威力:通過附加描述當前操作(span id)、產生它的父操作(parent id)和跟蹤標識符(trace id)的元數據,我們可以增加日誌記錄和遙測數據以更好地理解 分散式服務中發生的事件的確切順序。

在真實的分散式跟蹤環境中

在本文的過程中,我們一直在使用一個有點人為的示例。 在真正的分散式跟蹤環境中,您不會手動生成和傳遞所有的跨度和跟蹤標識符。 您也不會依賴 console.log(或其他日誌記錄)調用來自己發出跟蹤元數據。 您將使用適當的跟蹤庫來為您處理檢測和發送跟蹤數據。

OpenTelemetry

OpenTelemetry 是一組開源工具、APISDK,用於檢測、生成和導出正在運行的軟體中的遙測數據。 它為大多數流行的程式語言提供了特定於語言的實現,包括瀏覽器 JavaScriptNode.js

Sentry

Sentry 以多種方式使用這種遙測。例如,Sentry 的性能監控功能集使用跟蹤數據生成瀑布圖,說明跟蹤中分散式服務操作的端到端延遲。

Sentry 還使用跟蹤元數據來增強它的錯誤監控功能,以了解在一個服務(如伺服器後端)中觸發的錯誤如何傳播到另一個服務(如前端)中的錯誤。