<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Error on Tarragon</title><link>https://tarrragon.github.io/blog/tags/error/</link><description>Recent content in Error on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 24 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/error/index.xml" rel="self" type="application/rss+xml"/><item><title>四類事件的完整定義</title><link>https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/01-mental-model/four-event-types/</guid><description>&lt;p>監控資料由四類事件構成。每類事件回答不同的問題，觸發時機不同，消費方式不同。分類的目的是讓「我要收集什麼」有結構化的答案，而非在每個功能上各自決定要不要加 log。&lt;/p>
&lt;h2 id="event使用者做了什麼">Event：使用者做了什麼&lt;/h2>
&lt;p>Event 記錄使用者主動發起的操作。按鈕點擊、頁面瀏覽、表單提交、搜尋查詢 — 每個 event 代表使用者的一個意圖表達。&lt;/p>
&lt;p>Event 的觸發時機是使用者操作發生時。程式碼中的位置通常是 UI 事件處理器（onClick、onSubmit、onNavigate）。&lt;/p>
&lt;p>Event 的消費方式：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Debug context&lt;/strong>：問題發生前使用者做了哪些操作。和 error 事件搭配使用，還原問題的操作路徑。&lt;/li>
&lt;li>&lt;strong>行為分析&lt;/strong>：使用者做了哪些操作、操作順序是什麼、在哪一步停止。&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">Funnel analysis&lt;/a> 的原料（&lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>功能使用率&lt;/strong>：哪些功能被頻繁使用、哪些很少被觸發。功能優先順序的決策依據。&lt;/li>
&lt;/ul>
&lt;h2 id="error什麼出了問題">Error：什麼出了問題&lt;/h2>
&lt;p>Error 記錄程式碼執行中的非預期狀態。例外拋出、assertion 失敗、非預期的 API 回應、資源存取失敗。&lt;/p>
&lt;p>Error 的觸發時機是非預期狀態被偵測到時。來源包括：語言層級的 try/catch 捕獲、框架的全域錯誤處理器（Flutter 的 &lt;code>FlutterError.onError&lt;/code>、JavaScript 的 &lt;code>window.onerror&lt;/code>）、自訂的錯誤檢查邏輯。&lt;/p>
&lt;p>Error 的消費方式：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>即時告警&lt;/strong>：特定類型的 error 或 error 數量超過閾值時通知開發者。&lt;/li>
&lt;li>&lt;strong>趨勢分析&lt;/strong>：error 數量隨時間的變化。新版本部署後 error 是否增加。&lt;/li>
&lt;li>&lt;strong>根因分析&lt;/strong>：error 的 stack trace、觸發條件、影響範圍。和 event 搭配還原「使用者做了什麼導致 error」。&lt;/li>
&lt;/ul>
&lt;h2 id="metric系統狀態的數值快照">Metric：系統狀態的數值快照&lt;/h2>
&lt;p>Metric 記錄系統狀態的可量化指標。回應時間、記憶體使用量、佇列長度、連線數、frame rate。&lt;/p>
&lt;p>Metric 的觸發時機是定期取樣或特定事件發生時。定期取樣適合持續變化的指標（記憶體使用量每 30 秒取一次），事件觸發適合離散的測量（每次 API 回應記錄回應時間）。&lt;/p>
&lt;p>Metric 的消費方式：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>效能監控&lt;/strong>：回應時間的 P50 / P95 / P99 分佈。記憶體使用量的趨勢。&lt;/li>
&lt;li>&lt;strong>容量規劃&lt;/strong>：佇列長度接近上限、連線數接近 pool 上限 — 需要擴容的訊號。&lt;/li>
&lt;li>&lt;strong>SLA 追蹤&lt;/strong>：服務可用性、回應時間是否在承諾範圍內。&lt;/li>
&lt;/ul>
&lt;h2 id="lifecycle系統經歷了什麼階段">Lifecycle：系統經歷了什麼階段&lt;/h2>
&lt;p>Lifecycle 記錄系統本身的狀態轉換。App 啟動、前景/背景切換、連線建立/斷開、版本更新、設定變更。&lt;/p>
&lt;p>Lifecycle 的觸發時機是系統狀態轉換發生時。來源包括：app 生命週期回呼（onCreate、onResume、onPause）、連線狀態變化事件、部署和設定變更鉤子。&lt;/p>
&lt;p>Lifecycle 的消費方式：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Session 分析&lt;/strong>：使用者一次使用多久、啟動頻率、前後景切換頻率。&lt;/li>
&lt;li>&lt;strong>環境資訊&lt;/strong>：Error 發生時的系統狀態（app 版本、OS 版本、網路狀態）。&lt;/li>
&lt;li>&lt;strong>連線品質&lt;/strong>：連線建立成功率、斷線頻率、重連次數（&lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">testing 模組二 三層 log&lt;/a>）。&lt;/li>
&lt;/ul>
&lt;h2 id="四類事件的區別">四類事件的區別&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Event&lt;/th>
 &lt;th>Error&lt;/th>
 &lt;th>Metric&lt;/th>
 &lt;th>Lifecycle&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>觸發者&lt;/td>
 &lt;td>使用者操作&lt;/td>
 &lt;td>系統非預期狀態&lt;/td>
 &lt;td>定期取樣或事件觸發&lt;/td>
 &lt;td>系統狀態轉換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回答&lt;/td>
 &lt;td>使用者做了什麼&lt;/td>
 &lt;td>什麼出了問題&lt;/td>
 &lt;td>系統現在怎麼樣&lt;/td>
 &lt;td>系統經歷了什麼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>頻率&lt;/td>
 &lt;td>依使用者行為&lt;/td>
 &lt;td>低（理想狀態）&lt;/td>
 &lt;td>固定間隔或事件驅動&lt;/td>
 &lt;td>低（狀態轉換才有）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費&lt;/td>
 &lt;td>行為分析、funnel&lt;/td>
 &lt;td>告警、根因分析&lt;/td>
 &lt;td>效能監控、容量規劃&lt;/td>
 &lt;td>session、環境資訊&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>事件命名規範 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">事件命名規範&lt;/a>&lt;/li>
&lt;li>從需求推導收集策略 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/derive-collection-from-requirements/" data-link-title="從需求推導「該收集哪些事件」" data-link-desc="從 debug 需求、行為分析需求、效能需求、合規需求四個方向推導事件收集策略 — 避免「什麼都收」和「什麼都不收」">從需求推導「該收集哪些事件」&lt;/a>&lt;/li>
&lt;li>Event 類事件在商業分析中的用途 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 行為資料的商業利用&lt;/a>&lt;/li>
&lt;li>Log 點的設計方法 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>監控資料由四類事件構成。每類事件回答不同的問題，觸發時機不同，消費方式不同。分類的目的是讓「我要收集什麼」有結構化的答案，而非在每個功能上各自決定要不要加 log。</p>
<h2 id="event使用者做了什麼">Event：使用者做了什麼</h2>
<p>Event 記錄使用者主動發起的操作。按鈕點擊、頁面瀏覽、表單提交、搜尋查詢 — 每個 event 代表使用者的一個意圖表達。</p>
<p>Event 的觸發時機是使用者操作發生時。程式碼中的位置通常是 UI 事件處理器（onClick、onSubmit、onNavigate）。</p>
<p>Event 的消費方式：</p>
<ul>
<li><strong>Debug context</strong>：問題發生前使用者做了哪些操作。和 error 事件搭配使用，還原問題的操作路徑。</li>
<li><strong>行為分析</strong>：使用者做了哪些操作、操作順序是什麼、在哪一步停止。<a href="/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">Funnel analysis</a> 的原料（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八</a>）。</li>
<li><strong>功能使用率</strong>：哪些功能被頻繁使用、哪些很少被觸發。功能優先順序的決策依據。</li>
</ul>
<h2 id="error什麼出了問題">Error：什麼出了問題</h2>
<p>Error 記錄程式碼執行中的非預期狀態。例外拋出、assertion 失敗、非預期的 API 回應、資源存取失敗。</p>
<p>Error 的觸發時機是非預期狀態被偵測到時。來源包括：語言層級的 try/catch 捕獲、框架的全域錯誤處理器（Flutter 的 <code>FlutterError.onError</code>、JavaScript 的 <code>window.onerror</code>）、自訂的錯誤檢查邏輯。</p>
<p>Error 的消費方式：</p>
<ul>
<li><strong>即時告警</strong>：特定類型的 error 或 error 數量超過閾值時通知開發者。</li>
<li><strong>趨勢分析</strong>：error 數量隨時間的變化。新版本部署後 error 是否增加。</li>
<li><strong>根因分析</strong>：error 的 stack trace、觸發條件、影響範圍。和 event 搭配還原「使用者做了什麼導致 error」。</li>
</ul>
<h2 id="metric系統狀態的數值快照">Metric：系統狀態的數值快照</h2>
<p>Metric 記錄系統狀態的可量化指標。回應時間、記憶體使用量、佇列長度、連線數、frame rate。</p>
<p>Metric 的觸發時機是定期取樣或特定事件發生時。定期取樣適合持續變化的指標（記憶體使用量每 30 秒取一次），事件觸發適合離散的測量（每次 API 回應記錄回應時間）。</p>
<p>Metric 的消費方式：</p>
<ul>
<li><strong>效能監控</strong>：回應時間的 P50 / P95 / P99 分佈。記憶體使用量的趨勢。</li>
<li><strong>容量規劃</strong>：佇列長度接近上限、連線數接近 pool 上限 — 需要擴容的訊號。</li>
<li><strong>SLA 追蹤</strong>：服務可用性、回應時間是否在承諾範圍內。</li>
</ul>
<h2 id="lifecycle系統經歷了什麼階段">Lifecycle：系統經歷了什麼階段</h2>
<p>Lifecycle 記錄系統本身的狀態轉換。App 啟動、前景/背景切換、連線建立/斷開、版本更新、設定變更。</p>
<p>Lifecycle 的觸發時機是系統狀態轉換發生時。來源包括：app 生命週期回呼（onCreate、onResume、onPause）、連線狀態變化事件、部署和設定變更鉤子。</p>
<p>Lifecycle 的消費方式：</p>
<ul>
<li><strong>Session 分析</strong>：使用者一次使用多久、啟動頻率、前後景切換頻率。</li>
<li><strong>環境資訊</strong>：Error 發生時的系統狀態（app 版本、OS 版本、網路狀態）。</li>
<li><strong>連線品質</strong>：連線建立成功率、斷線頻率、重連次數（<a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">testing 模組二 三層 log</a>）。</li>
</ul>
<h2 id="四類事件的區別">四類事件的區別</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Event</th>
          <th>Error</th>
          <th>Metric</th>
          <th>Lifecycle</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>觸發者</td>
          <td>使用者操作</td>
          <td>系統非預期狀態</td>
          <td>定期取樣或事件觸發</td>
          <td>系統狀態轉換</td>
      </tr>
      <tr>
          <td>回答</td>
          <td>使用者做了什麼</td>
          <td>什麼出了問題</td>
          <td>系統現在怎麼樣</td>
          <td>系統經歷了什麼</td>
      </tr>
      <tr>
          <td>頻率</td>
          <td>依使用者行為</td>
          <td>低（理想狀態）</td>
          <td>固定間隔或事件驅動</td>
          <td>低（狀態轉換才有）</td>
      </tr>
      <tr>
          <td>消費</td>
          <td>行為分析、funnel</td>
          <td>告警、根因分析</td>
          <td>效能監控、容量規劃</td>
          <td>session、環境資訊</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>事件命名規範 → <a href="/blog/monitoring/01-mental-model/event-naming-convention/" data-link-title="事件命名規範" data-link-desc="namespace.action 格式的事件命名、命名一致性的工程價值、和商業方案命名慣例的對應">事件命名規範</a></li>
<li>從需求推導收集策略 → <a href="/blog/monitoring/01-mental-model/derive-collection-from-requirements/" data-link-title="從需求推導「該收集哪些事件」" data-link-desc="從 debug 需求、行為分析需求、效能需求、合規需求四個方向推導事件收集策略 — 避免「什麼都收」和「什麼都不收」">從需求推導「該收集哪些事件」</a></li>
<li>Event 類事件在商業分析中的用途 → <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 行為資料的商業利用</a></li>
<li>Log 點的設計方法 → <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：錯誤狀態與回復</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</guid><description>&lt;p>回答「出錯時使用者能做什麼」。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：error 狀態在狀態矩陣中的退出路徑&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一&lt;/a>：error 回復路徑需要 widget test 覆蓋&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一&lt;/a>：error 事件是四類事件之一&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「出錯時使用者能做什麼」。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）</li>
<li><input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）</li>
<li><input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）</li>
<li><input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：error 狀態在狀態矩陣中的退出路徑</li>
<li>→ <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一</a>：error 回復路徑需要 widget test 覆蓋</li>
<li>→ <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一</a>：error 事件是四類事件之一</li>
</ul>
]]></content:encoded></item><item><title>Error Fingerprint</title><link>https://tarrragon.github.io/blog/monitoring/knowledge-cards/error-fingerprint/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/knowledge-cards/error-fingerprint/</guid><description>&lt;p>Error fingerprint 的核心概念是「從 error 事件中提取關鍵欄位計算 hash，相同 hash 的事件歸為同一 error group」。沒有 fingerprint 時，1000 筆同因 error 在 dashboard 上是 1000 行；有 fingerprint 後歸為 1 組，顯示 count / first_seen / last_seen / affected_sessions。可先對照 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction&lt;/a>（事件送出前的資料脫敏）和 &lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel analysis&lt;/a>（行為事件的轉換率分析）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Error fingerprint 位在 collector 收到 error 事件之後、寫入 storage 之前。它的輸入是通過 schema validation 的 error 事件，輸出是附加了 &lt;code>_fingerprint&lt;/code> 欄位的事件和更新後的 error_groups 摘要表。Fingerprint 只作用於 &lt;code>type: &amp;quot;error&amp;quot;&lt;/code> 的事件 — 其他三類事件（event / metric / lifecycle）不需要去重分群。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 fingerprint 的訊號是「dashboard 的 error 列表中，同一個 bug 因為 error message 包含動態值（user ID、timestamp、IP）而分裂成多個不同的行」。例如 &lt;code>&amp;quot;User 12345 not found&amp;quot;&lt;/code> 和 &lt;code>&amp;quot;User 67890 not found&amp;quot;&lt;/code> 是同一個 bug，但 name-based grouping（&lt;code>GROUP BY name&lt;/code>）把它們歸為同一行時，丟失了 message 中的動態值資訊；而沒有 normalization 的 message-based grouping 會把它們分裂成兩行。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Fingerprint 承擔的設計責任是「在 error 的精確識別和分群粒度之間找到平衡」。過粗的 fingerprint（只用 error type）把不同 bug 混在同一組；過細的 fingerprint（用完整 message 含動態值）把同因 error 分裂成多組。&lt;/p>
&lt;h2 id="自架-vs-商業方案">自架 vs 商業方案&lt;/h2>
&lt;p>自架方案用規則做 fingerprint — regex normalize message（替換數字 / UUID / email / IP 等動態值）+ stack trace top N frames 做 hash。Sentry 在規則之上加了 in-app frame 過濾（忽略 framework / library frame）、source map 反解（minified JS → 原始碼位置）、和 ML-based grouping（語意相同但結構不同的 error 歸組）。差距主要在 minified / obfuscated 環境和 ML — 明文 stack trace 的場景下兩者效果相當。&lt;/p>
&lt;h2 id="完整章節">完整章節&lt;/h2>
&lt;p>Fingerprint 演算法（基礎 / 進階 / Sentry / 自定義）、message normalization 的替換規則和風險、error_groups 表的 DDL 和 UPSERT 流程、dashboard 整合、自架方案的務實邊界 → &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Error fingerprint 的核心概念是「從 error 事件中提取關鍵欄位計算 hash，相同 hash 的事件歸為同一 error group」。沒有 fingerprint 時，1000 筆同因 error 在 dashboard 上是 1000 行；有 fingerprint 後歸為 1 組，顯示 count / first_seen / last_seen / affected_sessions。可先對照 <a href="/blog/monitoring/knowledge-cards/redaction/" data-link-title="Redaction" data-link-desc="說明在事件資料離開 client 之前把敏感欄位的值替換成遮罩或移除的機制">redaction</a>（事件送出前的資料脫敏）和 <a href="/blog/monitoring/knowledge-cards/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="說明追蹤使用者在多步驟流程中每一步的轉換率和流失率的分析方法">funnel analysis</a>（行為事件的轉換率分析）。</p>
<h2 id="概念位置">概念位置</h2>
<p>Error fingerprint 位在 collector 收到 error 事件之後、寫入 storage 之前。它的輸入是通過 schema validation 的 error 事件，輸出是附加了 <code>_fingerprint</code> 欄位的事件和更新後的 error_groups 摘要表。Fingerprint 只作用於 <code>type: &quot;error&quot;</code> 的事件 — 其他三類事件（event / metric / lifecycle）不需要去重分群。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 fingerprint 的訊號是「dashboard 的 error 列表中，同一個 bug 因為 error message 包含動態值（user ID、timestamp、IP）而分裂成多個不同的行」。例如 <code>&quot;User 12345 not found&quot;</code> 和 <code>&quot;User 67890 not found&quot;</code> 是同一個 bug，但 name-based grouping（<code>GROUP BY name</code>）把它們歸為同一行時，丟失了 message 中的動態值資訊；而沒有 normalization 的 message-based grouping 會把它們分裂成兩行。</p>
<h2 id="設計責任">設計責任</h2>
<p>Fingerprint 承擔的設計責任是「在 error 的精確識別和分群粒度之間找到平衡」。過粗的 fingerprint（只用 error type）把不同 bug 混在同一組；過細的 fingerprint（用完整 message 含動態值）把同因 error 分裂成多組。</p>
<h2 id="自架-vs-商業方案">自架 vs 商業方案</h2>
<p>自架方案用規則做 fingerprint — regex normalize message（替換數字 / UUID / email / IP 等動態值）+ stack trace top N frames 做 hash。Sentry 在規則之上加了 in-app frame 過濾（忽略 framework / library frame）、source map 反解（minified JS → 原始碼位置）、和 ML-based grouping（語意相同但結構不同的 error 歸組）。差距主要在 minified / obfuscated 環境和 ML — 明文 stack trace 的場景下兩者效果相當。</p>
<h2 id="完整章節">完整章節</h2>
<p>Fingerprint 演算法（基礎 / 進階 / Sentry / 自定義）、message normalization 的替換規則和風險、error_groups 表的 DDL 和 UPSERT 流程、dashboard 整合、自架方案的務實邊界 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a>。</p>
]]></content:encoded></item><item><title>Error Fingerprint 與去重分群</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/error-fingerprint/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/error-fingerprint/</guid><description>&lt;p>Error fingerprint 把相同根因的 error 事件歸為同一組（error group），讓 dashboard 從「每筆 error 獨立一行」變成「同因 error 歸組、顯示 count / first_seen / last_seen / affected_sessions」。這是 error tracking 從「有記錄」演進到「可管理」的關鍵能力。&lt;/p>
&lt;p>Collector 搭配的 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/dashboard-developer/" data-link-title="Developer Dashboard 設計" data-link-desc="Bug 在哪、多嚴重、怎麼重現 — Error 列表和趨勢的日常監控、Session 回放和 Stack trace 的深入 debug">Developer Dashboard&lt;/a> 在 Error 列表中用 &lt;code>GROUP BY name&lt;/code> 做分群 — 同名的 error 歸為一行。這在 error name 設計良好時（&lt;code>terminal.connect.failed&lt;/code> / &lt;code>auth.biometric.timeout&lt;/code>）可以運作，但在以下情境會失效：&lt;/p>
&lt;ul>
&lt;li>同一個 name 對應多個不同的 root cause — &lt;code>app.exception&lt;/code> 的 stack trace 指向完全不同的程式碼位置&lt;/li>
&lt;li>不同 name 其實是同一個 root cause — &lt;code>ws.connect.failed&lt;/code> 和 &lt;code>ws.reconnect.failed&lt;/code> 都是同一個 server 下線造成&lt;/li>
&lt;/ul>
&lt;p>Fingerprint 提供比 name 更精確的分群維度。&lt;/p>
&lt;h2 id="fingerprint-演算法">Fingerprint 演算法&lt;/h2>
&lt;p>Fingerprint 從 error 事件中提取關鍵欄位、計算 hash，相同 hash 的事件歸為同一組。欄位的選擇決定分群的粒度。&lt;/p>
&lt;h3 id="基礎版type--message">基礎版：type + message&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">fingerprint = SHA256(error_type + &amp;#34;:&amp;#34; + error_message)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 &lt;code>error_type&lt;/code>（&lt;code>NullPointerException&lt;/code> / &lt;code>TypeError&lt;/code> / &lt;code>ConnectionError&lt;/code>）加上 &lt;code>error_message&lt;/code> 做 hash。實作最簡單，大多數情況下能正確分群。&lt;/p>
&lt;p>問題在 error message 包含動態值時。同一個 bug 產生的 error 因為動態值不同而分裂成多組：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&amp;#34;User 12345 not found&amp;#34; → fingerprint A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&amp;#34;User 67890 not found&amp;#34; → fingerprint B&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這兩筆是同一個 bug（查無使用者），但 message 中的 user ID 不同導致 fingerprint 不同。動態值的處理見下方 &lt;a href="#message-normalization">message normalization&lt;/a>。&lt;/p>
&lt;h3 id="進階版type--stack-trace-top-frames">進階版：type + stack trace top frames&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">fingerprint = SHA256(error_type + &amp;#34;:&amp;#34; + top_3_frames)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 error_type 加上 stack trace 最頂端的 N 個 frame（函式名 + 檔案名 + 行號）做 hash。Stack trace 的頂端通常是 error 發生的直接位置，相同位置的 error 歸為同組。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">// 兩筆 error 的 stack trace 頂端相同 → 同一個 fingerprint
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">TypeError: Cannot read property &amp;#39;name&amp;#39; of null
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> at UserProfile.render (UserProfile.js:42) ← frame 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> at Component.update (framework.js:108) ← frame 2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> at scheduler.flush (framework.js:203) ← frame 3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>N 的選擇是粒度 vs 穩定性的取捨。N=1 過粗（不同 bug 可能在同一個函式裡），N=5 過細（重構移動程式碼後行號改變，同一個 bug 的 fingerprint 分裂）。N=3 是常見的預設值。&lt;/p></description><content:encoded><![CDATA[<p>Error fingerprint 把相同根因的 error 事件歸為同一組（error group），讓 dashboard 從「每筆 error 獨立一行」變成「同因 error 歸組、顯示 count / first_seen / last_seen / affected_sessions」。這是 error tracking 從「有記錄」演進到「可管理」的關鍵能力。</p>
<p>Collector 搭配的 <a href="/blog/monitoring/04-collector/dashboard-developer/" data-link-title="Developer Dashboard 設計" data-link-desc="Bug 在哪、多嚴重、怎麼重現 — Error 列表和趨勢的日常監控、Session 回放和 Stack trace 的深入 debug">Developer Dashboard</a> 在 Error 列表中用 <code>GROUP BY name</code> 做分群 — 同名的 error 歸為一行。這在 error name 設計良好時（<code>terminal.connect.failed</code> / <code>auth.biometric.timeout</code>）可以運作，但在以下情境會失效：</p>
<ul>
<li>同一個 name 對應多個不同的 root cause — <code>app.exception</code> 的 stack trace 指向完全不同的程式碼位置</li>
<li>不同 name 其實是同一個 root cause — <code>ws.connect.failed</code> 和 <code>ws.reconnect.failed</code> 都是同一個 server 下線造成</li>
</ul>
<p>Fingerprint 提供比 name 更精確的分群維度。</p>
<h2 id="fingerprint-演算法">Fingerprint 演算法</h2>
<p>Fingerprint 從 error 事件中提取關鍵欄位、計算 hash，相同 hash 的事件歸為同一組。欄位的選擇決定分群的粒度。</p>
<h3 id="基礎版type--message">基礎版：type + message</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">fingerprint = SHA256(error_type + &#34;:&#34; + error_message)</span></span></code></pre></div><p>用 <code>error_type</code>（<code>NullPointerException</code> / <code>TypeError</code> / <code>ConnectionError</code>）加上 <code>error_message</code> 做 hash。實作最簡單，大多數情況下能正確分群。</p>
<p>問題在 error message 包含動態值時。同一個 bug 產生的 error 因為動態值不同而分裂成多組：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&#34;User 12345 not found&#34;  → fingerprint A
</span></span><span class="line"><span class="ln">2</span><span class="cl">&#34;User 67890 not found&#34;  → fingerprint B</span></span></code></pre></div><p>這兩筆是同一個 bug（查無使用者），但 message 中的 user ID 不同導致 fingerprint 不同。動態值的處理見下方 <a href="#message-normalization">message normalization</a>。</p>
<h3 id="進階版type--stack-trace-top-frames">進階版：type + stack trace top frames</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">fingerprint = SHA256(error_type + &#34;:&#34; + top_3_frames)</span></span></code></pre></div><p>用 error_type 加上 stack trace 最頂端的 N 個 frame（函式名 + 檔案名 + 行號）做 hash。Stack trace 的頂端通常是 error 發生的直接位置，相同位置的 error 歸為同組。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">// 兩筆 error 的 stack trace 頂端相同 → 同一個 fingerprint
</span></span><span class="line"><span class="ln">2</span><span class="cl">TypeError: Cannot read property &#39;name&#39; of null
</span></span><span class="line"><span class="ln">3</span><span class="cl">  at UserProfile.render (UserProfile.js:42)    ← frame 1
</span></span><span class="line"><span class="ln">4</span><span class="cl">  at Component.update (framework.js:108)       ← frame 2
</span></span><span class="line"><span class="ln">5</span><span class="cl">  at scheduler.flush (framework.js:203)        ← frame 3</span></span></code></pre></div><p>N 的選擇是粒度 vs 穩定性的取捨。N=1 過粗（不同 bug 可能在同一個函式裡），N=5 過細（重構移動程式碼後行號改變，同一個 bug 的 fingerprint 分裂）。N=3 是常見的預設值。</p>
<p>Stack trace 版本的前提是 error 事件帶有結構化的 stack trace。如果 SDK 只送 error message 不送 stack trace，只能用基礎版。</p>
<h3 id="sentry-的做法">Sentry 的做法</h3>
<p>Sentry 的策略核心是只用應用程式自身的 frame 做 hash，排除 framework / library 的 frame，並 normalize message 中的動態值。具體做法：</p>
<ol>
<li><strong>取 in-app frame</strong>：忽略 framework / library 的 frame（<code>framework.js</code>、<code>node_modules/</code>），只用應用程式自身的 frame。同一個 bug 在不同版本的 framework 上觸發時，framework frame 可能不同，但 app frame 相同。</li>
<li><strong>Normalize message</strong>：移除動態值（數字、UUID、email）後再 hash。</li>
<li><strong>取最後一個 in-app frame 的函式名</strong>：而非取前 N 個 frame。最後一個 in-app frame 是「error 在應用程式碼中實際發生的位置」。</li>
</ol>
<p>Sentry 的策略對 web 前端（大量 framework frame）和行動 app（大量 OS / runtime frame）的分群效果好，但實作複雜度高 — 需要維護「什麼算 in-app frame」的規則。</p>
<h3 id="sdk-端自定義-fingerprint">SDK 端自定義 fingerprint</h3>
<p>SDK 端可以手動指定 fingerprint，覆蓋 collector 的自動計算。用途是讓開發者把「技術上不同但業務上同因」的 error 歸為同組。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">monitor</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;API timeout&#34;</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s2">&#34;fingerprint&#34;</span><span class="p">:</span> <span class="s2">&#34;api-gateway-timeout&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;endpoint&#34;</span><span class="p">:</span> <span class="s2">&#34;/v1/users&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s2">&#34;duration_ms&#34;</span><span class="p">:</span> <span class="mi">30000</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>所有帶 <code>fingerprint: &quot;api-gateway-timeout&quot;</code> 的 error，無論 message 和 stack trace 是否相同，都歸入同一組。</p>
<p>自定義 fingerprint 的處理邏輯：collector 收到事件時，先檢查 <code>data.fingerprint</code> 欄位是否存在。存在則直接用這個值做 hash（或直接用作 fingerprint），不走自動計算。</p>
<h2 id="message-normalization">Message normalization</h2>
<p>動態值讓相同 bug 的 message 不同，導致 fingerprint 分裂。Normalization 在計算 fingerprint 前把動態值替換成 placeholder。</p>
<h3 id="替換規則">替換規則</h3>
<table>
  <thead>
      <tr>
          <th>Pattern</th>
          <th>替換為</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>連續數字（3 位以上）</td>
          <td><code>{N}</code></td>
          <td><code>&quot;User 12345 not found&quot;</code> → <code>&quot;User {N} not found&quot;</code></td>
      </tr>
      <tr>
          <td>UUID</td>
          <td><code>{uuid}</code></td>
          <td><code>&quot;Session a1b2...7890 expired&quot;</code> → <code>&quot;Session {uuid} expired&quot;</code></td>
      </tr>
      <tr>
          <td>Email</td>
          <td><code>{email}</code></td>
          <td><code>&quot;Invalid email foo@bar.com&quot;</code> → <code>&quot;Invalid email {email}&quot;</code></td>
      </tr>
      <tr>
          <td>IPv4 / IPv6</td>
          <td><code>{ip}</code></td>
          <td><code>&quot;Connection to 192.168.1.100 refused&quot;</code> → <code>&quot;Connection to {ip} refused&quot;</code></td>
      </tr>
      <tr>
          <td>引號內的字串（超過 20 字元）</td>
          <td><code>{string}</code></td>
          <td><code>&quot;Key 'very-long-dynamic-key...' not found&quot;</code> → <code>&quot;Key {string} not found&quot;</code></td>
      </tr>
      <tr>
          <td>絕對路徑的使用者目錄</td>
          <td><code>{path}</code></td>
          <td><code>&quot;/Users/john/project/app.js&quot;</code> → <code>&quot;{path}/project/app.js&quot;</code></td>
      </tr>
      <tr>
          <td>ISO 8601 timestamp</td>
          <td><code>{ts}</code></td>
          <td><code>&quot;Error at 2026-06-24T14:30:00&quot;</code> → <code>&quot;Error at {ts}&quot;</code></td>
      </tr>
  </tbody>
</table>
<p>後兩個屬進階規則 — 基礎五個（數字 / UUID / email / IP / 長字串）在多數場景足夠，file path 和 timestamp 在 error group 分裂嚴重時再加。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">normalizers</span> <span class="p">=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">pattern</span> <span class="o">*</span><span class="nx">regexp</span><span class="p">.</span><span class="nx">Regexp</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">replace</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`</span><span class="p">),</span> <span class="s">&#34;{uuid}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b`</span><span class="p">),</span> <span class="s">&#34;{email}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`</span><span class="p">),</span> <span class="s">&#34;{ip}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`</span><span class="p">),</span> <span class="s">&#34;{ts}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`(?:/Users/|/home/|C:\\Users\\)[^/\\]+`</span><span class="p">),</span> <span class="s">&#34;{path}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">{</span><span class="nx">regexp</span><span class="p">.</span><span class="nf">MustCompile</span><span class="p">(</span><span class="s">`\d{3,}`</span><span class="p">),</span> <span class="s">&#34;{N}&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">func</span> <span class="nf">normalizeMessage</span><span class="p">(</span><span class="nx">msg</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">n</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">normalizers</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">msg</span> <span class="p">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">pattern</span><span class="p">.</span><span class="nf">ReplaceAllString</span><span class="p">(</span><span class="nx">msg</span><span class="p">,</span> <span class="nx">n</span><span class="p">.</span><span class="nx">replace</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="nx">msg</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="normalization-的風險">Normalization 的風險</h3>
<p><strong>過度 normalize</strong>：把實際不同的 error 歸為同組。例如 HTTP status code <code>404</code> 和 <code>500</code> 都被替換成 <code>{N}</code>，導致 <code>&quot;HTTP {N}&quot;</code> 把 404 和 500 混在一起。對策：HTTP status code 等已知語意數字用具名 pattern 優先保留（<code>(\b[1-5]\d{2}\b)</code> → 不替換），再跑通用數字替換。Normalizer 的規則順序決定優先級 — 具名 pattern 放在 <code>\d{3,}</code> 之前，匹配到的數字跳過後續替換。</p>
<p><strong>不足 normalize</strong>：遺漏動態值導致同因 error 分裂。例如 message 中包含時間戳 <code>&quot;Error at 2026-06-24T14:30:00&quot;</code> 但 normalization 沒有覆蓋 ISO 8601 格式。對策：先用基礎規則上線，根據 error group 的分裂狀況逐步補規則 — 同一個 error 名稱下有大量 group 且 stack trace 相同，通常代表 normalization 不足。</p>
<h2 id="storage-設計">Storage 設計</h2>
<p>Fingerprint 的儲存分兩部分：events 表加 fingerprint 欄位、新建 error_groups 表追蹤每組的摘要。</p>
<h3 id="events-表擴充">Events 表擴充</h3>
<p>在<a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">現有的 events 表</a>加 <code>fingerprint</code> 欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">fingerprint</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_fingerprint</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="n">fingerprint</span><span class="p">);</span></span></span></code></pre></div><p><code>fingerprint</code> 存 hash 值（SHA256 hex 的前 16 字元足夠 — 自架場景的 error 種類不會多到 collision）。索引加速「查看某個 error group 的所有事件」查詢。</p>
<h3 id="error_groups-表">error_groups 表</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">error_groups</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">    </span><span class="n">fingerprint</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="n">name</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="n">error_type</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="n">normalized_message</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="k">count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="n">first_seen</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="n">last_seen</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="n">last_event_id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="n">id</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="n">session_count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="n">status</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="s1">&#39;open&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_error_groups_last_seen</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">error_groups</span><span class="p">(</span><span class="n">last_seen</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_error_groups_count</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">error_groups</span><span class="p">(</span><span class="k">count</span><span class="p">);</span></span></span></code></pre></div><p><code>status</code> 支援基本的 issue 管理 — <code>open</code>（待處理）、<code>resolved</code>（已修復）、<code>ignored</code>（已知、不處理）。Resolved 的 group 如果又收到新事件，自動 reopen。</p>
<h3 id="寫入流程">寫入流程</h3>
<p>Collector 的寫入 pipeline 在 schema validation 之後、storage 寫入之前，加一步 fingerprint 計算。下方的 UPSERT 邏輯引用 events 表的 <code>session_id</code> 欄位 — 該欄位定義在 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">Events 主表 DDL</a> 中（從 <code>session.id</code> 攤平而來）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HTTP → Schema validation → Fingerprint 計算 → Events INSERT → error_groups UPSERT</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">processErrorEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">fp</span> <span class="o">:=</span> <span class="nf">calculateFingerprint</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">event</span><span class="p">.</span><span class="nx">Fingerprint</span> <span class="p">=</span> <span class="nx">fp</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="c1">// 1. INSERT event</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">db</span><span class="p">.</span><span class="nf">InsertEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="c1">// 2. UPSERT error_group</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">db</span><span class="p">.</span><span class="nf">Exec</span><span class="p">(</span><span class="s">`
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">        INSERT INTO error_groups (fingerprint, name, error_type, normalized_message,
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">                                  count, first_seen, last_seen, last_event_id, session_count)
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">        VALUES (?, ?, ?, ?, 1, ?, ?, ?, 1)
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">        ON CONFLICT(fingerprint) DO UPDATE SET
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">            count = count + 1,
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">            last_seen = excluded.last_seen,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">            last_event_id = excluded.last_event_id,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">            session_count = session_count + CASE
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">                WHEN ? NOT IN (SELECT DISTINCT session_id FROM events WHERE fingerprint = ?)
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">                THEN 1 ELSE 0 END,
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">            status = CASE WHEN status = &#39;resolved&#39; THEN &#39;open&#39; ELSE status END
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">    `</span><span class="p">,</span> <span class="nx">fp</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ErrorType</span><span class="p">,</span> <span class="nf">normalizeMessage</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">ErrorMessage</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">       <span class="nx">event</span><span class="p">.</span><span class="nx">Timestamp</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Timestamp</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SessionID</span><span class="p">,</span> <span class="nx">fp</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>session_count</code> 的子查詢在高寫入量下可能成為瓶頸。務實的替代是在 UPSERT 時不算 session_count，改為定期 job 重新計算（每小時一次）。</p>
<h3 id="查詢模式">查詢模式</h3>
<p>Dashboard 的 Error 列表從 <code>GROUP BY name</code> 改為查 error_groups 表：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 之前：按 name 分群（粗略）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;error&#39;</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">name</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 之後：按 fingerprint 分群（精確）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">fingerprint</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">error_type</span><span class="p">,</span><span class="w"> </span><span class="n">normalized_message</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">       </span><span class="k">count</span><span class="p">,</span><span class="w"> </span><span class="n">first_seen</span><span class="p">,</span><span class="w"> </span><span class="n">last_seen</span><span class="p">,</span><span class="w"> </span><span class="n">session_count</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">error_groups</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s1">&#39;ignored&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">last_seen</span><span class="w"> </span><span class="k">DESC</span><span class="p">;</span></span></span></code></pre></div><p>error_groups 表的查詢是 index scan，不需要掃描 events 表。Dashboard 刷新頻率高的場景下（每 30 秒），查 error_groups 比 <code>GROUP BY</code> 全表掃描快幾個數量級。</p>
<p>點擊某個 group 進入詳情時，再用 fingerprint 從 events 表撈最近 N 筆事件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">fingerprint</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="p">;</span></span></span></code></pre></div><h2 id="dashboard-整合">Dashboard 整合</h2>
<p>Error fingerprint 改變了 <a href="/blog/monitoring/04-collector/dashboard-developer/" data-link-title="Developer Dashboard 設計" data-link-desc="Bug 在哪、多嚴重、怎麼重現 — Error 列表和趨勢的日常監控、Session 回放和 Stack trace 的深入 debug">Developer Dashboard</a> 的 Error 列表和詳情視圖。</p>
<h3 id="error-列表升級">Error 列表升級</h3>
<p>從按 name 分群升級為按 fingerprint 分群：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>之前（name 分群）</th>
          <th>之後（fingerprint 分群）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分群維度</td>
          <td>error.name</td>
          <td>fingerprint hash</td>
      </tr>
      <tr>
          <td>同名不同因的 error</td>
          <td>混在同一行</td>
          <td>各自獨立一行</td>
      </tr>
      <tr>
          <td>不同名同因的 error</td>
          <td>分開兩行</td>
          <td>可用自定義 fingerprint 合併</td>
      </tr>
      <tr>
          <td>影響 session 數</td>
          <td>每次查詢都做 DISTINCT</td>
          <td>error_groups 表預計算</td>
      </tr>
      <tr>
          <td>Status 管理</td>
          <td>無</td>
          <td>open / resolved / ignored</td>
      </tr>
      <tr>
          <td>查詢效能</td>
          <td>GROUP BY 掃描 events 表</td>
          <td>直接查 error_groups 表</td>
      </tr>
  </tbody>
</table>
<h3 id="error-詳情升級">Error 詳情升級</h3>
<p>點擊某個 error group 進入詳情，顯示：</p>
<ul>
<li><strong>代表性 stack trace</strong>：最近一次事件的 stack trace，讓開發者看到 error 的具體位置</li>
<li><strong>Normalized message</strong>：去除動態值後的 error message，一目了然這個 group 代表什麼問題</li>
<li><strong>趨勢</strong>：這個 group 的事件量隨時間的變化（上升 = 越來越多使用者遇到、下降 = 可能自行恢復）</li>
<li><strong>受影響版本</strong>：按 <code>source.version</code> 分佈 — 新版本出現的 group 通常是 regression</li>
<li><strong>受影響平台</strong>：按 <code>source.platform</code> 分佈 — 只影響特定平台的 group 通常是平台特定 bug</li>
</ul>
<h2 id="自架方案的務實邊界">自架方案的務實邊界</h2>
<p>自架 collector 的 fingerprint 機制和 <a href="/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &#43; performance monitoring &#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry</a> 等商業方案有明確的能力差距。</p>
<h3 id="stack-trace-可讀性">Stack trace 可讀性</h3>
<p>Stack trace 分群的前提是 stack trace 可讀 — frame 的函式名和檔名對應原始碼。兩種情境下 stack trace 會變成不可讀：</p>
<p><strong>Minified JS</strong>：production 環境的 JS 經過 minify 後，stack trace 變成 <code>a.js:1:2345</code>，無法定位原始碼位置。Sentry 支援上傳 source map，在 server 端自動反解。自架方案的對策：開發期使用未 minify 的 JS（stack trace 直接對應原始碼）；production 環境如果用 minify，需要自建 source map server 或放棄 JS 的 stack trace 分群、改用 error name + message 做 fingerprint。</p>
<p><strong>Android ProGuard / R8 混淆</strong>：混淆後 stack trace 的類名和方法名是 <code>a.b.c()</code>。Sentry 和 Crashlytics 支援上傳 mapping file 反混淆。自架方案如果目標平台包含 Android native（非 Flutter），需要自建 mapping 反混淆流程。</p>
<p>Flutter 和 Python 不受上述影響 — Flutter 的 debug / profile build 保留完整 stack trace，Dart 有自己的 stack trace 格式不經過 ProGuard；Python 的 stack trace 永遠包含原始檔名和行號。</p>
<h3 id="ml-based-grouping">ML-based grouping</h3>
<p>Sentry 的進階 grouping 使用機器學習判斷「語意相同但結構不同」的 error 是否該歸為同組。例如同一個 bug 因為 async/await 的 call chain 不同而產生不同的 stack trace，ML 模型能辨識它們是同一個 root cause。</p>
<p>自架方案用規則（fingerprint 演算法 + normalization）做 grouping。規則的覆蓋率低於 ML — 遇到規則沒覆蓋的情境時，需要手動加 normalization 規則或用 SDK 端自定義 fingerprint 修正。</p>
<h3 id="能力定位">能力定位</h3>
<table>
  <thead>
      <tr>
          <th>能力</th>
          <th>自架方案</th>
          <th>Sentry</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>基礎分群</td>
          <td>type + normalized message</td>
          <td>type + in-app frame + ML</td>
      </tr>
      <tr>
          <td>Stack trace 分群</td>
          <td>top N frames（明文 stack trace）</td>
          <td>in-app frame + source map + deobfuscation</td>
      </tr>
      <tr>
          <td>自定義 fingerprint</td>
          <td>SDK 端 <code>data.fingerprint</code></td>
          <td>SDK 端 + server-side rule</td>
      </tr>
      <tr>
          <td>Message normalize</td>
          <td>regex 替換</td>
          <td>regex + ML</td>
      </tr>
      <tr>
          <td>Issue 管理</td>
          <td>open / resolved / ignored</td>
          <td>+ assign / merge / snooze / trend</td>
      </tr>
  </tbody>
</table>
<p>基礎分群和 message normalization 覆蓋自架場景的多數需求。Stack trace 分群在明文 stack trace 的場景下（Python / Flutter / 未 minify 的 JS）和 Sentry 效果相當。差距主要在 minified / obfuscated 環境和 ML-based grouping — 這兩者恰好是商業方案的核心付費價值。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Error 列表和趨勢的日常監控 → <a href="/blog/monitoring/04-collector/dashboard-developer/" data-link-title="Developer Dashboard 設計" data-link-desc="Bug 在哪、多嚴重、怎麼重現 — Error 列表和趨勢的日常監控、Session 回放和 Stack trace 的深入 debug">Developer Dashboard 設計</a></li>
<li>Collector 的處理鏈路 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
<li>偽造 error 的辨識 → <a href="/blog/monitoring/07-security-privacy/client-sdk-authentication/" data-link-title="Client-side SDK 認證的根本限制" data-link-desc="嵌在 client 端的 credential 必然可被提取 — 認清 architecture 天花板後的多層緩解策略，從 origin 驗證到 device attestation">Client-side SDK 認證</a></li>
<li>Sentry 的 error tracking 架構 → <a href="/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &#43; performance monitoring &#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入</a></li>
<li>Error 事件的端到端完整性 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item></channel></rss>