內容整理自官方開發文件
本文件的目標是將 Sentry SDK
中效能監控
功能的演變置於上下文中。
我們首先總結了如何將效能監控
新增到 Sentry
和 SDK
,
然後我們討論 identified issues(已確定的問題)
吸取的經驗教訓以及解決這些問題的舉措。
介紹
早在 2019
年初,Sentry
就開始嘗試向 SDK
新增跟蹤功能。
Python 和 JavaScript SDK 是設計和開發第一個概念的測試平臺。
概念驗證於 2019 年 4 月 29 日 釋出,
並於 2019 年 5 月 7 日交付給 Sentry。
Python
和 JavaScript
是顯而易見的選擇,因為它們允許我們試驗檢測 Sentry
自己的後端
和前端
。
- https://github.com/getsentry/sentry-python/pull/342
- https://github.com/getsentry/sentry-javascript/pull/1918
- https://github.com/getsentry/sentry-python/releases/tag/0.7.13
- https://github.com/getsentry/sentry/pull/12952
請注意,上述工作與 OpenCensus 和 OpenTracing 合併形成 OpenTelemetry 是同時代的。
Sentry
的 API
和 SDK
實現借鑑了 OpenTelemetry 1.0
之前版本的靈感,並結合了我們自己的想法。
例如,我們的 Span 狀態列表與 2019 年底左右在 OpenTelemetry 規範中可以找到的匹配。
- https://medium.com/opentracing/a-roadmap-to-convergence-b074e5815289
- https://github.com/getsentry/relay/blob/55127c75d4eeebf787848a05a12150ee5c59acd9/relay-common/src/constants.rs#L179-L181
使用 API
後,效能監控支援隨後擴充套件到其他 SDK
。Sentry 的效能監控 解決方案於 2020 年 7 月普遍可用。
OpenTelemetry 的跟蹤規範 1.0 版於 2021 年 2 月釋出。
- https://blog.sentry.io/2020/07/14/see-slow-faster-with-performance-monitoring
- https://medium.com/opentelemetry/opentelemetry-specification-v1-0-0-tracing-edition-72dd08936978
我們最初的實現重用了我們現有的錯誤報告機制:
- Event type 擴充套件了新欄位。 這意味著我們可以節省時間並快速開始向
Sentry
傳送事件
,而不是設計
和實現
全新的攝取管道,這一次,不是error
,而是一種新的transaction
事件型別。 - 由於我們只是傳送一種新型事件,因此也重用了
SDK
傳輸層。 - 由於我們共享
攝取管道(ingestion pipeline)
,這意味著我們共享儲存以及發生在所有事件上的處理的許多部分。
我們的實現演變成明確強調 Transaction
和 Span
之間的區別。部分原因是重用 Event 介面的副作用。
Transaction
與客戶產生了良好的共鳴。
他們允許突出顯示程式碼中的重要工作塊,例如瀏覽器頁面載入或 http 伺服器請求。
客戶可以檢視和瀏覽 transaction
列表,而在 transaction
中,span
為更細粒度的工作單元提供詳細的時間安排。
在下一節中,我們將討論當前模型的一些缺點。
已確定的問題
雖然統一 SDK 架構(hub
、client
、scope
)
和 transaction ingestion
模型的重用有其優點,但經驗揭示了一些我們將其分為兩類的問題。
第一組與 scope
傳播有關,本質上是確定 當前 scope
是什麼的能力。使用者程式碼中的手動檢測
以及 SDK 整合
中的自動檢測
都需要此操作。
第二組是與用於將 transaction
資料從 SDK
傳送到 Sentry
的 wire
格式相關的問題。
Scope 傳播
該問題由 getsentry/sentry-javascript#3751 跟蹤。
Unified SDK 架構 基本上是基於每個併發單元存在一個 hub
,每個 hub
有一堆 client
和 scope
對。
Client
儲存配置並負責通過 transport
向 Sentry 傳送資料,而 scope
儲存附加到傳出事件(例如 tag
和 breadcrumb
)的上下文資料。
每個 hub
都知道當前的 scope
是什麼。它始終是堆疊頂部的 scope
。困難的部分是 “per unit of concurrency(每單位併發)”
有一個 hub
。
例如,JavaScript
是具有事件迴圈和非同步程式碼執行的單執行緒。
沒有標準的方法來承載跨非同步呼叫工作的上下文資料。
因此,對於 JavaScript
瀏覽器應用程式,只有一個全域性 hub
共享用於同步
和非同步
程式碼。
類似的情況出現在 Mobile SDK
上。
使用者期望上下文資料(例如 tags
、current user
是什麼、
breadcrumbs
以及儲存在 scope
上的其他資訊)可以從任何執行緒獲得和設定。
因此,在這些 SDK
中,只有一個全域性 hub
。
在這兩種情況下,當 SDK
必須處理 reporting errors
時,一切都相對較好。
隨著跟蹤 transaction
和 span
的額外責任,scope
變得不適合儲存當前的 span
,因為它限制了併發 span
的存在。
對於瀏覽器 JavaScript
,一個可能的解決方案是使用 Zone.js,Angular
框架的一部分。
主要挑戰是它增加了包的大小,並且可能會無意中影響終端使用者應用程式,因為它對 JavaScript
執行時引擎的關鍵部分進行了猴子修補(monkey-patches)
。
當我們嘗試為手動檢測建立更簡單的 API
時,scope
傳播問題變得尤為明顯。
這個想法是公開一個 Sentry.trace
函式,該函式將隱式傳播 tracing
和 scope
資料,
並支援同步和非同步程式碼的深度巢狀。
舉個例子,假設有人想測量搜尋 DOM
樹需要多長時間。Tracing(跟蹤)
此操作將如下所示:
await Sentry.trace(
{
op: 'dom',
description: 'Walk DOM Tree',
},
async () => await walkDomTree()
);
使用 Sentry.trace
功能,使用者在新增計時資料時不必擔心保留對正確 transaction
或 span
的引用。
使用者可以在 walkDomTree
函式中自由建立子 Span
,Span
將在正確的層次結構中排序。
實際 trace
函式的實現相對簡單
(參見具有示例實現的 PR)。
然而,瞭解非同步程式碼和全域性整合中的當前 span
是一個尚未克服的挑戰。
以下兩個示例綜合了 scope
傳播問題。
無法確定當前 Span
考慮一些需要獲取對當前 span
的引用的自動檢測程式碼,在這種情況下,手動 scope
傳播不可用。
// SDK code
function fetchWrapper(/* ... */) {
/*
... some code omitted for simplicity ...
*/
const parent = getCurrentHub().getScope().getSpan(); // <1>
const span = parent.startChild({
data: { type: 'fetch' },
description: `${method} ${url}`,
op: 'http.client',
});
try {
// ...
// return fetch(...);
} finally {
span.finish();
}
}
window.fetch = fetchWrapper;
// User code
async function f1() {
const hub = getCurrentHub();
let t = hub.startTransaction({ name: 't1' });
hub.getScope().setSpan(t);
try {
await fetch('https://example.com/f1');
} finally {
t.finish();
}
}
async function f2() {
const hub = getCurrentHub();
let t = hub.startTransaction({ name: 't2' });
hub.getScope().setSpan(t);
try {
await fetch('https://example.com/f2');
} finally {
t.finish();
}
}
Promise.all([f1(), f2()]); // run f1 and f2 concurrently
在上面的例子中,幾個併發的 fetch
請求觸發了 fetchWrapper
helper 的執行。 行 <1>
必須能夠根據當前的執行流程觀察到不同的 span
,導致如下兩個 span
樹:
t1
\
|- http.client GET https://example.com/f1
t2
\
|- http.client GET https://example.com/f2
這意味著,當 f1
執行時,parent
必須引用 t1
,而當 f2
執行時,parent
必須是 t2
。
不幸的是,上面的所有程式碼都在爭先恐後地更新和讀取單個 hub
例項,因此觀察到的 span
樹不是確定性的。例如,結果可能錯誤地為:
t1
t2
\
|- http.client GET https://example.com/f1
|- http.client GET https://example.com/f2
作為無法正確確定當前 span
的副作用,
fetch
整合的顯示實現(和其他)在JavaScript 瀏覽器 SDK 中選擇建立 flat transactions,
其中所有子 span
都是 transaction
的直接子代(而不是具有適當的多級樹結構)。
請注意,其他跟蹤庫也面臨同樣的挑戰。
在 OpenTelemetry for JavaScript
中有幾個(在開放時)問題與確定父跨度和正確的上下文傳播(包括非同步程式碼)相關:
- 如果使用多個 TracerProvider 例項,則上下文洩漏 #1932
- 如何在不傳遞 parent 的情況下建立巢狀 span #1963
- 巢狀的子 span 沒有得到正確的父級 #1940
- OpenTracing shim 不會改變上下文 #2016
- Http Span 未連結/未設定父 Span #2333
相互衝突的資料傳播預期
每當我們新增前面討論過的 trace
函式,或者只是嘗試使用 Zones
解決 scope
傳播時,就會出現預期衝突。
當前的 span
與 tags
、breadcrumbs
等一起儲存在 scope
中的事實使資料傳播變得混亂,
因為 scope
的某些部分旨在僅傳播到內部函式呼叫中(例如,tags
),
而其他人預計會傳播回撥用者(例如,breadcrumbs
),尤其是在出現 error
時。
這是一個例子:
function a() {
trace((span, scope) => {
scope.setTag('func', 'a');
scope.setTag('id', '123');
scope.addBreadcrumb('was in a');
try {
b();
} catch(e) {
// How to report the SpanID from the span in b?
} finally {
captureMessage('hello from a');
// tags: {func: 'a', id: '123'}
// breadcrumbs: ['was in a', 'was in b']
}
})
}
function b() {
trace((span, scope) => {
const fail = Math.random() > 0.5;
scope.setTag('func', 'b');
scope.setTag('fail', fail.toString());
scope.addBreadcrumb('was in b');
captureMessage('hello from b');
// tags: {func: 'b', id: '123', fail: ?}
// breadcrumbs: ['was in a', 'was in b']
if (fail) {
throw Error('b failed');
}
});
}
在上面的示例中,如果 error
在呼叫堆疊中冒泡,我們希望能夠報告 error
發生在哪個 span
(通過引用 SpanID
)。
我們希望有面包屑來描述發生的一切,無論哪個 Zones
正在執行,
我們希望在內部 Zone
中設定一個 tag
來覆蓋來自父 Zone
的同名 tag
,
同時繼承來自父 Zone
的所有其他 tag
。每個 Zone
都有自己的 "current span"
。
所有這些不同的期望使得很難以一種可以理解的方式重用當前的 scope
概念、麵包屑的記錄方式以及這些不同的概念如何相互作用。
最後,值得注意的是,在不破壞現有 SDK API
的情況下,重組 scope
管理的更改很可能無法完成。
現有的 SDK
概念 — 如 hubs
、scopes
、breadcrumbs
、user
、tags
和 contexts
— 都必須重新建模。
Span 攝取模型
考慮由以下 span
樹描述的跟蹤:
F*
├─ B*
│ ├─ B
│ ├─ B
│ ├─ B
│ │ ├─ S*
│ │ ├─ S*
│ ├─ B
│ ├─ B
│ │ ├─ S*
│ ├─ B
│ ├─ B
│ ├─ B
│ │ ├─ S*
where
F: span created on frontend service
B: span created on backend service
S: span created on storage service
此跟蹤說明了 3
個被檢測的服務,當使用者單擊網頁上的按鈕 (F
) 時,後端 (B
) 執行一些工作,然後需要對儲存服務 (S
) 進行多次查詢。位於給定服務入口點的 Span
標有 *
以表示它們是 transaction
。
我們可以通過這個例子來比較和理解 Sentry
的 span
攝取模型與 OpenTelemetry
和其他類似跟蹤系統使用的模型之間的區別。
在 Sentry
的 span
攝取模型中,屬於 transaction
的所有 span
必須在單個請求中一起傳送。
這意味著在整個 B*
transaction 期間,所有 B
span 都必須儲存在記憶體中,包括在下游服務(示例中的儲存服務)上花費的時間。
在 OpenTelemetry
的模型中,span
在完成時被一起批處理,並且一旦 a)
批次中有一定數量的 span
或 b)
過了一定的時間就會傳送批次。
在我們的示例中,這可能意味著前 3
個 B
跨度將一起批處理併傳送,
而第一個 S*
事務仍在儲存服務中進行。隨後,其他 B
span 將一起批處理並在完成時傳送,直到最終 B*
transaction span 也被髮送。
雖然 transaction
作為將 span
組合在一起並探索 Sentry
中感興趣的操作的一種方式特別有用,
但它們目前存在的形式會帶來額外的認知負擔。
SDK
維護人員和終端使用者在編寫檢測程式碼時都必須瞭解並在 transaction
或 span
之間進行選擇。
在當前的攝取模型中已經確定了接下來幾節中的問題,並且都與這種二分法有關。
事務的複雜 JSON 序列化
在 OpenTelemetry
的模型中,
所有跨度都遵循相同的邏輯格式。
使用者和檢測庫可以通過將 key-value
屬性附加到任何 span
來為其提供更多含義。
wire
協議使用 span
列表將資料從一個系統傳送到另一個系統。
與 OpenTelemetry
不同,Sentry
的模型對兩種型別的 span
進行了嚴格區分:transaction span
(通常稱為 transactions
)和 regular span
。
在記憶體中,transaction span
和 regular span
有一個區別:transaction span
有一個額外的屬性,即 transaction name
。
但是,當序列化為 JSON
時,差異更大。
Sentry SDK
以直接類似於記憶體中的 span
的格式將常規 span
序列化為 JSON
。
相比之下,transaction span
的序列化需要將其 span
屬性對映到 Sentry Event
(最初用於 report errors
,擴充套件為專門用於 transactions
的新欄位),並將所有子 span
作為列表嵌入 Event
中。
Transaction Span 獲取 Event 屬性
當 transaction
從其記憶體表示轉換為 Event
時,
它會獲得更多無法分配給 regular span
的屬性,
例如 breadcrumbs
, extra
, contexts
, event_id
, fingerprint
, release
, environment
, user
等。
生命週期鉤子
Sentry SDK
為 error
事件公開了一個 BeforeSend
hook,允許使用者在將事件
傳送到 Sentry
之前修改
和/或丟棄
事件。
當引入新的 transaction
型別事件時,很快就決定此類事件不會通過 BeforeSend
hook,主要有兩個原因:
- 防止使用者程式碼依賴
transaction
的雙重形式(有時看起來像一個span
,有時像一個event
,如前幾節所述); - 為了防止現有的
BeforeSend
函式在編寫時只考慮到error
而干擾transaction
,無論是意外地改變它們、完全丟棄它們,還是導致一些其他意想不到的副作用。
然而,也很明顯需要某種形式的 lifecycle hook
,以允許使用者執行諸如更新 transaction
名稱之類的操作。
我們最終達成了中間立場,即通過使用 EventProcessor
(一種更通用的 BeforeSend
形式)來允許更改/丟棄
transaction 事件。
這通過在資料離開 SDK
之前讓使用者立即訪問他們的資料來解決問題,但它也有缺點,它比 BeforeSend
使用起來更復雜,並且還暴露了從未打算洩漏的 transaction
二元性。
相比之下,在 OpenTelemetry
中,span
通過 span processor
,這是兩個生命週期鉤子:一個是在 span
開始時,一個是在它結束時。
巢狀事務
Sentry
的攝取模型不是為服務中的巢狀 transaction
而設計的。Transaction
旨在標記服務轉換。
在實踐中,SDK
無法防止 transaction
巢狀。最終結果可能會讓使用者感到驚訝,因為每筆 transaction
都會開始一棵新樹。關聯這些樹的唯一方法是通過 trace_id
。
Sentry 的計費模型是針對每個事件的,無論是 error
事件還是 transaction
事件。這意味著 transaction
中的 transaction
會生成兩個可計費事件。
在 SDK
中,在 transaction
中進行 transaction
將導致內部 span
被圍繞它們的最內層 transaction
“吞噬”。
在這些情況下,建立 span
的程式碼只會將它們新增到兩個 transaction
之一,從而導致另一個 transaction
中的檢測間隙。
Sentry
的 UI
並非旨在以有用的方式處理巢狀 transaction
。
當檢視任何一個 transaction
時,就好像 transaction
中的所有其他 transaction
都不存在(樹檢視上沒有直接表示其他 transaction
)。
有一個 trace view
功能來視覺化共享一個 trace_id
的所有 transaction
,
但 trace view
僅通過顯示 transaction
而不是子 span
來提供跟蹤的概述。如果不先訪問某個 transaction
,就無法導航到 trace view
。
對於這種情況(虛擬碼),使用者對 UI
中的期望也存在混淆:
# if do_a_database_query returns 10 results, is the user
# - seeing 11 transactions in the UI?
# - billed for 11 transactions?
# - see spans within create_thumbnail in the innermost transaction only?
with transaction("index-page"):
results = do_a_database_query()
for result in results:
if result["needs_thumbnail"]:
with transaction("create-thumbnail", {"resource": result["id"]}):
create_thumbnail(result)
跨度不能存在於事務之外
Sentry
的追蹤體驗完全圍繞著存在於 transaction
中的 trace
部分。這意味著資料不能存在於 transaction
之外,即使它存在於 trace
中。
如果 SDK
沒有進行 transaction
,則由 instrumentation
建立的 regular span
將完全丟失。
也就是說,這對 Web server
來說不是什麼問題,因為自動檢測的 transaction
隨著每個傳入請求開始和結束。
Transaction
的要求在前端(瀏覽器、移動和桌面應用程式)上尤其具有挑戰性,
因為在這些情況下,自動檢測的 transaction
不太可靠地捕獲所有 span
,因為它們在自動完成之前只持續有限的時間。
在 trace
以僅作為 span
而不是 transaction
進行檢測的操作開始的情況下,會出現另一個問題。在我們的 示例跟蹤
中,產生 trace
的第一個 span
是由於單擊按鈕。
如果按鈕點選 F*
被檢測為常規的 span
而不是 transaction
,則很可能不會捕獲來自前端的資料。然而,仍會捕獲 B
和 S
span,導致不完整的蹤跡。
在 Sentry
的模型中,如果一個 span
不是一個 transaction
並且沒有作為 transaction
的祖先 span
,那麼該 span
將不會被攝取。
反過來,這意味著在很多情況下,跟蹤丟失了有助於除錯問題的關鍵資訊,特別是在前端,transaction
需要在某個時刻結束但執行可能會繼續。
自動和手動檢測面臨著決定是開始 span
還是 transaction
的挑戰,考慮到以下因素,決定尤其困難:
- 如果沒有
transaction
,則span
丟失。 - 如果已經存在
transaction
,則存在巢狀事務
問題。
缺少 Web Vitals 測量
Sentry
的瀏覽器工具收集 Web Vitals
測量值。但是,因為這些測量值是使用自動檢測的 transaction
作為載體傳送到 Sentry
的,所以在自動 transaction
完成後由瀏覽器提供的測量值將丟失。
這會導致 transaction
丟失一些 Web Vitals
或對 LCP
等指標進行非最終測量。
前端事務持續時間不可靠
因為所有的資料都必須在一個 transaction
中。Sentry
的瀏覽器 SDK
為每個頁面載入和每個導航建立一個 transaction
。這些 transaction
必須在某個時間結束。
如果在 transaction
完成之前關閉瀏覽器選項卡並將其傳送到 Sentry
,則所有收集的資料都會丟失。
因此,SDK 需要平衡丟失所有資料的風險與收集不完整和可能不準確的資料的風險。
在觀察到最後一個活動(例如傳出的 HTTP
請求)後空閒了一段時間後,Transaction
就完成了。
這意味著頁面載入
或導航 transaction
的持續時間是一個相當隨意的值,不一定能改進或與其他事務相比,因為它不能準確代表任何具體和可理解的過程的持續時間。
我們通過將 LCP Web Vital
作為瀏覽器的預設效能指標來應對這一限制。
但是,如上所述,LCP
值可能會在最終確定之前傳送,因此這不是理想的解決方案。
記憶體緩衝影響伺服器
如前所述,當前的攝取模型需要 Sentry SDK
來觀察記憶體中的完整 span
樹。
以恆定的併發 transaction
流執行的應用程式將需要大量的系統資源來收集和處理跟蹤資料。Web 伺服器是出現此問題的典型案例。
這意味著記錄 100%
的 span
和 100%
的 transaction
對於許多伺服器端應用程式來說是不可行的,因為所產生的開銷太高了。
無法批處理事務
Sentry
的攝取模型不支援一次攝取多個事件。特別是,SDK
不能將多個 transaction
批處理為一個請求。
因此,當多筆 transaction
幾乎同時完成時,SDK
需要為每個 transaction
發出單獨的請求。
這種行為在最好的情況下是非常低效的,在最壞的情況下是對資源(如網路頻寬和CPU週期)的嚴重且有問題的消耗。
相容性
Transaction Span
的特殊處理與 OpenTelemetry
不相容。使用 OpenTelemetry SDK
檢測現有應用程式的使用者無法輕鬆使用 Sentry
來獲取和分析他們的資料。
Sentry
確實為 OpenTelemetry Collector
提供了一個 Sentry Exporter
,但是,由於當前的攝取模型,Sentry Exporter 有一個主要的正確性限制。
總結
通過在 Sentry
中構建當前的跟蹤實現,我們學到了很多。
本文件試圖捕捉許多已知的限制,以作為未來改進的基礎。
追蹤是一個複雜的主題,馴服這種複雜性並非易事。
第一組中的問題 - 與 scope propagation(作用域傳播) 相關的問題 - 是 SDK
及其設計方式獨有的問題。
解決這些問題將需要對所有 SDK
進行內部架構更改,包括重新設計麵包屑
等舊功能,
但進行此類更改是實現簡單易用的 tracing helper
(如可在任何上下文中工作並捕獲準確可靠的效能資料的 trace
函式)的先決條件。
請注意,此類更改幾乎肯定意味著釋出新的主要 SDK
版本,這會破壞與現有版本的相容性。
第二組中的問題 - 與 span ingestion model(跨度攝取模型) 相關的問題要複雜得多,因為為解決這些問題所做的任何更改都會影響產品的更多部分,並且需要多個團隊的協調努力。
儘管如此,對 ingestion model
進行更改將對產品產生不可估量的積極影響,因為這樣做會提高效率,使我們能夠收集更多資料,並減少 instrumentation
的負擔。